Compare commits

...
23 changed files with 2160 additions and 47 deletions
@@ -2230,9 +2230,9 @@ public static class PublishedContentExtensions
// with a non-existing published node, will get cache misses and call the DB
// making it a very slow operation.
return publishedStatusFilteringService
.FilterAvailable(childrenKeys, culture)
.OrderBy(x => x.SortOrder);
// INavigationQueryService.TryGetChildrenKeys returns keys already ordered by SortOrder
// and FilterAvailable preserves enumeration order, so no further OrderBy is needed.
return publishedStatusFilteringService.FilterAvailable(childrenKeys, culture);
}
private static IEnumerable<IPublishedContent> EnumerateDescendantsOrSelfInternal(
@@ -8,7 +8,25 @@ namespace Umbraco.Cms.Core.Models.Navigation;
/// </summary>
public sealed class NavigationNode
{
private ConcurrentHashSet<Guid> _children;
private static readonly Comparison<(Guid Key, int SortOrder)> _sortBySortOrder =
static (a, b) => a.SortOrder.CompareTo(b.SortOrder);
private readonly ConcurrentHashSet<Guid> _children;
/// <summary>
/// Cached snapshot of <see cref="Children"/> ordered by each child's <c>SortOrder</c>.
/// </summary>
/// <remarks>
/// Built lazily by <see cref="GetOrderedChildren"/> on first access and invalidated
/// (set to <c>null</c>) by <see cref="AddChild"/> / <see cref="RemoveChild"/> /
/// <see cref="InvalidateOrderedChildren"/>. Reads are lock-free on the fast path; the
/// build and invalidation paths take <see cref="_orderedChildrenLock"/> so concurrent
/// first-access threads agree on a single canonical array and an in-flight build
/// cannot finish after a concurrent invalidation has cleared it.
/// </remarks>
private Guid[]? _orderedChildren;
private readonly Lock _orderedChildrenLock = new();
/// <summary>
/// Gets the unique key of this navigation node.
@@ -53,6 +71,17 @@ public sealed class NavigationNode
/// Updates the sort order of this node.
/// </summary>
/// <param name="newSortOrder">The new sort order value.</param>
/// <remarks>
/// The parent node's cached ordered-children list (if any) is now stale because it sorts
/// by child <c>SortOrder</c>. Callers that hold a reference to the parent should call
/// <see cref="InvalidateOrderedChildren"/> on it; <see cref="NavigationNode"/> does not
/// hold a reference to its parent <see cref="NavigationNode"/> so cannot invalidate it
/// itself.
/// </remarks>
// TODO (V19): Make internal. The contract requires the caller to invalidate the parent's
// ordered-children cache (InvalidateOrderedChildren is internal, so external callers cannot
// satisfy that contract and would silently observe stale ordering on subsequent reads).
// Internal callers in ContentNavigationServiceBase already do the invalidation correctly.
public void UpdateSortOrder(int newSortOrder) => SortOrder = newSortOrder;
/// <summary>
@@ -74,6 +103,8 @@ public sealed class NavigationNode
child.SortOrder = _children.Count;
_children.Add(childKey);
InvalidateOrderedChildren();
}
/// <summary>
@@ -91,5 +122,91 @@ public sealed class NavigationNode
_children.Remove(childKey);
child.Parent = null;
InvalidateOrderedChildren();
}
/// <summary>
/// Returns this node's children ordered by <c>SortOrder</c>.
/// </summary>
/// <param name="navigationStructure">The navigation structure dictionary containing all nodes; needed to look up each child's current <c>SortOrder</c>.</param>
/// <returns>An immutable, sort-order-presorted snapshot of the children. The result is cached and reused across calls until the children set or a child's <c>SortOrder</c> is mutated.</returns>
/// <remarks>
/// Lock-free fast path: a non-null cached array is returned without acquiring the lock.
/// If the cache is empty, <see cref="BuildOrderedChildren"/> is called under the lock to
/// build (with double-checked re-read) and store the canonical array.
/// </remarks>
internal IReadOnlyList<Guid> GetOrderedChildren(ConcurrentDictionary<Guid, NavigationNode> navigationStructure)
{
// Volatile.Read provides the acquire fence that pairs with the release fence on the
// lock-protected stores in BuildOrderedChildren / InvalidateOrderedChildren. On weak
// memory architectures (e.g. ARM64) a plain read can observe writes out of order with
// the lock release, so without this barrier a reader could in principle see a torn or
// unpublished reference; on x86/x64 the TSO model already gives acquire semantics so
// this compiles to a normal load. Matches the lock-free read idiom in System.Lazy<T>
// and LazyInitializer.EnsureInitialized.
Guid[]? cached = Volatile.Read(ref _orderedChildren);
if (cached is not null)
{
return cached;
}
return BuildOrderedChildren(navigationStructure);
}
/// <summary>
/// Invalidates the cached ordered-children snapshot.
/// </summary>
/// <remarks>
/// Called by <see cref="AddChild"/> and <see cref="RemoveChild"/> automatically. Must be
/// called externally when a child's <c>SortOrder</c> changes (the parent's cache sorts by
/// child <c>SortOrder</c> and so is stale after such an update).
/// </remarks>
internal void InvalidateOrderedChildren()
{
lock (_orderedChildrenLock)
{
_orderedChildren = null;
}
}
private Guid[] BuildOrderedChildren(ConcurrentDictionary<Guid, NavigationNode> navigationStructure)
{
lock (_orderedChildrenLock)
{
// Double-check under the lock — another thread may have built the cache while we
// were waiting to acquire it.
Guid[]? cached = _orderedChildren;
if (cached is not null)
{
return cached;
}
if (_children.Count == 0)
{
_orderedChildren = [];
return _orderedChildren;
}
var sorted = new List<(Guid Key, int SortOrder)>(_children.Count);
foreach (Guid childKey in _children)
{
if (navigationStructure.TryGetValue(childKey, out NavigationNode? childNode))
{
sorted.Add((childKey, childNode.SortOrder));
}
}
sorted.Sort(_sortBySortOrder);
var result = new Guid[sorted.Count];
for (var i = 0; i < sorted.Count; i++)
{
result[i] = sorted[i].Key;
}
_orderedChildren = result;
return result;
}
}
}
@@ -28,6 +28,27 @@ public interface IDocumentCacheService
/// <returns>The published content, or <c>null</c> if not found.</returns>
Task<IPublishedContent?> GetByIdAsync(int id, bool? preview = null);
/// <summary>
/// Attempts to retrieve a content item from the in-memory converted-content cache without
/// touching the distributed cache or the database.
/// </summary>
/// <param name="key">The unique key of the content.</param>
/// <param name="preview">Whether to consider unpublished content.</param>
/// <param name="content">When this method returns, contains the cached published content if a hit was made; otherwise <c>null</c>.</param>
/// <returns><c>true</c> if the content was served from the in-memory cache; <c>false</c> if a slower retrieval (HybridCache or database) is required.</returns>
/// <remarks>
/// Synchronous fast-path used by sync consumers (e.g. <c>IPublishedContentCache.GetById(bool, Guid)</c>)
/// to avoid setting up the async state machine on the dominant warm-cache case. On a miss
/// the caller falls back to the existing async path. The default implementation always
/// returns <c>false</c> so the caller takes the async path.
/// </remarks>
// TODO (V19): Remove the default implementation.
bool TryGetCached(Guid key, bool preview, out IPublishedContent? content)
{
content = null;
return false;
}
/// <summary>
/// Seeds the cache with initial content data.
/// </summary>
@@ -26,6 +26,26 @@ public interface IMediaCacheService
/// <returns>The published media content, or <c>null</c> if not found.</returns>
Task<IPublishedContent?> GetByIdAsync(int id);
/// <summary>
/// Attempts to retrieve a media item from the in-memory converted-content cache without
/// touching the distributed cache or the database.
/// </summary>
/// <param name="key">The unique key of the media.</param>
/// <param name="content">When this method returns, contains the cached published media if a hit was made; otherwise <c>null</c>.</param>
/// <returns><c>true</c> if the media was served from the in-memory cache; <c>false</c> if a slower retrieval (HybridCache or database) is required.</returns>
/// <remarks>
/// Synchronous fast-path used by sync consumers (e.g. <c>IPublishedMediaCache.GetById(bool, Guid)</c>)
/// to avoid setting up the async state machine on the dominant warm-cache case. On a miss
/// the caller falls back to the existing async path. The default implementation always
/// returns <c>false</c> so the caller takes the async path.
/// </remarks>
// TODO (V19): Remove the default implementation.
bool TryGetCached(Guid key, out IPublishedContent? content)
{
content = null;
return false;
}
/// <summary>
/// Determines whether media with the specified identifier exists in the cache.
/// </summary>
@@ -30,11 +30,48 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
/// <summary>
/// Bundles a navigation structure dictionary and its root keys into a single reference so that
/// <see cref="HandleRebuildAsync"/> can swap both atomically with one <see cref="Interlocked.Exchange{T}"/>
/// call and readers always observe a consistent pair.
/// call and readers always observe a consistent pair. Also carries the per-snapshot
/// descendants cache populated by <see cref="TryGetDescendantsKeysFromStructure"/>.
/// </summary>
private sealed record NavigationSnapshot(
ConcurrentDictionary<Guid, NavigationNode> Structure,
HashSet<Guid> Roots);
HashSet<Guid> Roots)
{
private long _generation;
/// <summary>
/// Cache of descendants <c>Guid[]</c> keyed by parent and an optional content-type
/// filter. Populated lazily by <see cref="TryGetDescendantsKeysFromStructure"/> and
/// cleared by <see cref="Invalidate"/> on any structural mutation.
/// </summary>
/// <remarks>
/// The composite key allows both <c>TryGetDescendantsKeys</c> (content-type =
/// <c>null</c>) and <c>TryGetDescendantsKeysOfType</c> (content-type = the resolved
/// <c>Guid</c>) to share one cache without their results contaminating each other.
/// Realistic per-parent fan-out is bounded by the "allowed types" content model
/// (typically 1-5 types per parent), and the cache is populated only for queries
/// that actually run, so memory grows with the templates exercised rather than the
/// theoretical product of (parents × content types).
/// </remarks>
public ConcurrentDictionary<(Guid Parent, Guid? ContentType), Guid[]> DescendantsCache { get; } = new();
/// <summary>
/// A monotonic counter incremented on every mutation. Used by readers to detect a
/// concurrent mutation that occurred during their compute, so they can avoid writing
/// a now-stale result back to <see cref="DescendantsCache"/>.
/// </summary>
public long Generation => Interlocked.Read(ref _generation);
/// <summary>
/// Clears the descendants cache and bumps the generation. Call after any mutation to
/// this snapshot's <see cref="Structure"/> or <see cref="Roots"/>.
/// </summary>
public void Invalidate()
{
Interlocked.Increment(ref _generation);
DescendantsCache.Clear();
}
}
private NavigationSnapshot _navigation = new(new(), []);
private NavigationSnapshot _recycleBinNavigation = new(new(), []);
@@ -164,7 +201,12 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
/// </param>
/// <returns><c>true</c> if the parent node exists in the structure; otherwise, <c>false</c>.</returns>
public bool TryGetDescendantsKeys(Guid parentKey, out IEnumerable<Guid> descendantsKeys)
=> TryGetDescendantsKeysFromStructure(_navigation.Structure, parentKey, out descendantsKeys);
{
// Snapshot to a local so cache lookups, the structure walk, and the generation check
// all see the same NavigationSnapshot instance even if a rebuild swaps it in mid-call.
NavigationSnapshot snapshot = _navigation;
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey: null, cachingSnapshot: snapshot);
}
/// <summary>
/// Attempts to get all descendant node keys of a specific content type under a parent node.
@@ -182,7 +224,11 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
{
if (TryGetContentTypeKey(contentTypeAlias, out Guid? contentTypeKey))
{
return TryGetDescendantsKeysFromStructure(_navigation.Structure, parentKey, out descendantsKeys, contentTypeKey);
// Snapshot to a local so cache lookups, the structure walk, and the generation
// check all see the same NavigationSnapshot instance even if a rebuild swaps it
// in mid-call.
NavigationSnapshot snapshot = _navigation;
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey, cachingSnapshot: snapshot);
}
// Content type alias doesn't exist
@@ -297,7 +343,10 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
/// </param>
/// <returns><c>true</c> if the parent node exists in the recycle bin; otherwise, <c>false</c>.</returns>
public bool TryGetDescendantsKeysInBin(Guid parentKey, out IEnumerable<Guid> descendantsKeys)
=> TryGetDescendantsKeysFromStructure(_recycleBinNavigation.Structure, parentKey, out descendantsKeys);
{
NavigationSnapshot snapshot = _recycleBinNavigation;
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey: null, cachingSnapshot: snapshot);
}
/// <summary>
/// Attempts to get all ancestor node keys of a child node in the recycle bin navigation structure.
@@ -375,8 +424,14 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
// Reset the SortOrder based on its new position in the bin
nodeToRemove.UpdateSortOrder(_recycleBinNavigation.Structure.Count);
return _recycleBinNavigation.Structure.TryAdd(nodeToRemove.Key, nodeToRemove) &&
_navigation.Structure.TryRemove(key, out _);
var moved = _recycleBinNavigation.Structure.TryAdd(nodeToRemove.Key, nodeToRemove) &&
_navigation.Structure.TryRemove(key, out _);
// Both snapshots' descendant lists are now potentially stale.
_navigation.Invalidate();
_recycleBinNavigation.Invalidate();
return moved;
}
/// <summary>
@@ -418,6 +473,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
parentNode?.AddChild(_navigation.Structure, key);
_navigation.Invalidate();
return true;
}
@@ -468,6 +524,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
// Set the new parent for the node (if parent node is null - the node is moved to root)
targetParentNode?.AddChild(_navigation.Structure, key);
_navigation.Invalidate();
return true;
}
@@ -488,6 +545,18 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
node.UpdateSortOrder(newSortOrder);
// The parent's cached ordered-children snapshot sorts by child SortOrder and is now
// stale — invalidate so the next read rebuilds against the new value.
if (node.Parent is not null
&& _navigation.Structure.TryGetValue(node.Parent.Value, out NavigationNode? parentNode))
{
parentNode.InvalidateOrderedChildren();
}
// Descendants lists are sort-order-presorted (depth-first using each parent's
// ordered children), so re-ordering a child re-orders any cached ancestor descendants.
_navigation.Invalidate();
return true;
}
@@ -510,7 +579,9 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
RemoveDescendantsRecursively(nodeToRemove);
return _recycleBinNavigation.Structure.TryRemove(key, out _);
var removed = _recycleBinNavigation.Structure.TryRemove(key, out _);
_recycleBinNavigation.Invalidate();
return removed;
}
/// <summary>
@@ -545,8 +616,14 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
// Restore the node and its descendants from the recycle bin to the main structure
RestoreNodeAndDescendantsRecursively(nodeToRestore);
return _navigation.Structure.TryAdd(nodeToRestore.Key, nodeToRestore) &&
_recycleBinNavigation.Structure.TryRemove(key, out _);
var restored = _navigation.Structure.TryAdd(nodeToRestore.Key, nodeToRestore) &&
_recycleBinNavigation.Structure.TryRemove(key, out _);
// Both snapshots' descendant lists are now potentially stale.
_navigation.Invalidate();
_recycleBinNavigation.Invalidate();
return restored;
}
/// <summary>
@@ -655,10 +732,9 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
ConcurrentDictionary<Guid, NavigationNode> structure,
Guid parentKey,
out IEnumerable<Guid> descendantsKeys,
Guid? contentTypeKey = null)
Guid? contentTypeKey = null,
NavigationSnapshot? cachingSnapshot = null)
{
var descendants = new List<Guid>();
if (structure.TryGetValue(parentKey, out NavigationNode? parentNode) is false)
{
// Parent doesn't exist
@@ -666,9 +742,50 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
return false;
}
// Both unfiltered and content-type-filtered queries are cached, distinguished by the
// optional contentTypeKey in the composite key. Realistic per-parent fan-out is bounded
// by the "allowed types" model (a few types per parent), and entries are populated
// lazily for queries that actually run — so memory tracks the templates exercised, not
// the theoretical product of (parents × types).
var useCache = cachingSnapshot is not null;
#pragma warning disable IDE0008 // Use explicit type (in this case using var improves the readability of the tuple key).
var cacheKey = (parentKey, contentTypeKey);
#pragma warning restore IDE0008 // Use explicit type
if (useCache && cachingSnapshot!.DescendantsCache.TryGetValue(cacheKey, out Guid[]? cached))
{
descendantsKeys = cached;
return true;
}
// Capture the snapshot's mutation generation BEFORE walking. If a mutation invalidates
// between here and the cache write, the result we computed may be stale relative to
// the now-current Structure; we still hand it to the caller (it was correct at the
// moment we read), but skip the cache write so future readers don't see stale data.
var startGeneration = useCache ? cachingSnapshot!.Generation : 0;
var descendants = new List<Guid>();
GetDescendantsRecursively(structure, parentNode, descendants, contentTypeKey);
descendantsKeys = descendants;
if (useCache)
{
Guid[] result = [.. descendants];
// Only install if no mutation happened during compute, and skip caching empty
// results — they're cheap to recompute and caching them bloats the dictionary with
// one entry per (parent, type) pair queried with no measurable benefit.
if (result.Length > 0 && cachingSnapshot!.Generation == startGeneration)
{
cachingSnapshot.DescendantsCache[cacheKey] = result;
}
descendantsKeys = result;
}
else
{
descendantsKeys = descendants;
}
return true;
}
@@ -859,6 +976,15 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
return [];
}
// Unfiltered case uses the cached snapshot maintained on the node — returns the same
// sorted Guid[] across calls until the children set or a child's SortOrder changes.
if (contentTypeKey.HasValue is false)
{
return node.GetOrderedChildren(structure);
}
// Filtered-by-content-type case stays uncached: it would need a composite (node, type)
// key to memoise, and the call site is rare enough not to be worth it.
var childrenWithSortOrder = new List<(Guid ChildNodeKey, int SortOrder)>(node.Children.Count);
foreach (Guid childNodeKey in node.Children)
{
@@ -867,8 +993,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
continue;
}
// Apply contentTypeKey filter
if (contentTypeKey.HasValue && childNode.ContentTypeKey != contentTypeKey.Value)
if (childNode.ContentTypeKey != contentTypeKey.Value)
{
continue;
}
@@ -56,14 +56,17 @@ internal sealed class PublishedContentStatusFilteringService : IPublishedContent
_publishStatusQueryService.IsDocumentPublished(key, culture)
&& _publishStatusQueryService.HasPublishedAncestorPath(key, culture));
return WhereIsInvariantOrHasCultureOrRequestedAllCultures(candidateKeys, culture, preview).ToArray();
// Returned lazily so consumers like .FirstOrDefault() / .Take(n) can short-circuit
// without materialising the full result. Callers that need to enumerate the result
// more than once should buffer it themselves (.ToList() / .ToArray()).
return WhereIsInvariantOrHasCultureOrRequestedAllCultures(candidateKeys, culture, preview);
}
/// <inheritdoc />
public IEnumerable<IPublishedContent> Unfiltered(IEnumerable<Guid> candidateKeys)
{
var preview = _previewService.IsInPreview();
return candidateKeys.Select(key => _publishedContentCache.GetById(preview, key)).WhereNotNull().ToArray();
return candidateKeys.Select(key => _publishedContentCache.GetById(preview, key)).WhereNotNull();
}
/// <summary>
@@ -24,10 +24,15 @@ internal sealed class PublishedMediaStatusFilteringService : IPublishedMediaStat
=> _publishedMediaCache = publishedMediaCache;
/// <inheritdoc />
/// <remarks>
/// Returned lazily so consumers like .FirstOrDefault() / .Take(n) can short-circuit without
/// materialising the full result. Callers that need to enumerate the result more than once
/// should buffer it themselves (.ToList() / .ToArray()).
/// </remarks>
public IEnumerable<IPublishedContent> FilterAvailable(IEnumerable<Guid> candidateKeys, string? culture)
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull().ToArray();
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull();
/// <inheritdoc />
public IEnumerable<IPublishedContent> Unfiltered(IEnumerable<Guid> candidateKeys)
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull().ToArray();
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull();
}
@@ -35,7 +35,19 @@ public sealed class DocumentCache : IPublishedContentCache
public IPublishedContent? GetById(bool preview, int contentId) => GetByIdAsync(contentId, preview).GetAwaiter().GetResult();
public IPublishedContent? GetById(bool preview, Guid contentId) => GetByIdAsync(contentId, preview).GetAwaiter().GetResult();
public IPublishedContent? GetById(bool preview, Guid contentId)
{
// Sync fast path: when the converted-content L0 cache already holds the item we can
// return it without spinning up an async state machine. This is the dominant case on
// a warm site and is hit per-key by the FilterAvailable lazy chain. On a miss we fall
// through to the async path which handles HybridCache (L1/L2) and database lookups.
if (_documentCacheService.TryGetCached(contentId, preview, out IPublishedContent? cached))
{
return cached;
}
return GetByIdAsync(contentId, preview).GetAwaiter().GetResult();
}
public IPublishedContent? GetById(int contentId) => GetByIdAsync(contentId).GetAwaiter().GetResult();
@@ -24,8 +24,19 @@ public sealed class MediaCache : IPublishedMediaCache
public IPublishedContent? GetById(bool preview, int contentId) => GetByIdAsync(contentId).GetAwaiter().GetResult();
public IPublishedContent? GetById(bool preview, Guid contentId) =>
GetByIdAsync(contentId).GetAwaiter().GetResult();
public IPublishedContent? GetById(bool preview, Guid contentId)
{
// Sync fast path: when the converted-content L0 cache already holds the item we can
// return it without spinning up an async state machine. This is the dominant case on
// a warm site and is hit per-key by the FilterAvailable lazy chain. On a miss we fall
// through to the async path which handles HybridCache (L1/L2) and database lookups.
if (_mediaCacheService.TryGetCached(contentId, out IPublishedContent? cached))
{
return cached;
}
return GetByIdAsync(contentId).GetAwaiter().GetResult();
}
public IPublishedContent? GetById(int contentId) => GetByIdAsync(contentId).GetAwaiter().GetResult();
@@ -3,19 +3,32 @@ using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Exceptions;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Extensions;
using Umbraco.Cms.Core.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache;
internal class PublishedContent : PublishedContentBase
{
private IPublishedProperty[] _properties;
/// <summary>
/// Backing array of materialized properties for this content item. Built lazily on first
/// access via <see cref="EnsureProperties"/>; <c>null</c> until then.
/// </summary>
/// <remarks>
/// Lazy construction avoids allocating a <see cref="PublishedProperty"/> wrapper per
/// property type for traversal-only operations (e.g. <c>Children().Count()</c>,
/// <c>Descendants()</c> without property reads), which is a significant slice of
/// allocation for tree traversals.
/// </remarks>
private IPublishedProperty[]? _properties;
private readonly Dictionary<string, PropertyData[]> _propertyData;
private readonly IElementsCache _elementsCache;
private readonly ContentNode _contentNode;
private IReadOnlyDictionary<string, PublishedCultureInfo>? _cultures;
private readonly string? _urlSegment;
@@ -44,21 +57,11 @@ internal class PublishedContent : PublishedContentBase
_contentName = contentData.Name;
_urlSegment = contentData.UrlSegment;
_published = contentData.Published;
_propertyData = contentData.Properties;
_elementsCache = elementsCache;
IsPreviewing = preview;
var properties = new IPublishedProperty[_contentNode.ContentType.PropertyTypes.Count()];
var i = 0;
foreach (IPublishedPropertyType propertyType in _contentNode.ContentType.PropertyTypes)
{
// add one property per property type - this is required, for the indexing to work
// if contentData supplies pdatas, use them, else use null
contentData.Properties.TryGetValue(propertyType.Alias, out PropertyData[]? propertyDatas); // else will be null
properties[i++] = new PublishedProperty(propertyType, this, propertyDatas, elementsCache, propertyType.CacheLevel);
}
_properties = properties;
Id = contentNode.Id;
Key = contentNode.Key;
CreatorId = contentNode.CreatorId;
@@ -73,7 +76,7 @@ internal class PublishedContent : PublishedContentBase
public override Guid Key { get; }
public override IEnumerable<IPublishedProperty> Properties => _properties;
public override IEnumerable<IPublishedProperty> Properties => EnsureProperties();
public override int Id { get; }
@@ -213,15 +216,45 @@ internal class PublishedContent : PublishedContentBase
return null; // happens when 'alias' does not match a content type property alias
}
IPublishedProperty[] properties = EnsureProperties();
// should never happen - properties array must be in sync with property type
if (index >= _properties.Length)
if (index >= properties.Length)
{
throw new IndexOutOfRangeException(
"Index points outside the properties array, which means the properties array is corrupt.");
}
IPublishedProperty property = _properties[index];
return property;
return properties[index];
}
private IPublishedProperty[] EnsureProperties()
{
IPublishedProperty[]? properties = _properties;
if (properties is not null)
{
return properties;
}
return BuildProperties();
}
private IPublishedProperty[] BuildProperties()
{
IEnumerable<IPublishedPropertyType> propertyTypes = _contentNode.ContentType.PropertyTypes;
var newProperties = new IPublishedProperty[propertyTypes.Count()];
var i = 0;
foreach (IPublishedPropertyType propertyType in propertyTypes)
{
// add one property per property type - this is required for the indexing to work
// if propertyData supplies pdatas, use them, else use null
_propertyData.TryGetValue(propertyType.Alias, out PropertyData[]? propertyDatas);
newProperties[i++] = new PublishedProperty(propertyType, this, propertyDatas, _elementsCache, propertyType.CacheLevel);
}
// Use CompareExchange so concurrent first-access threads agree on a single canonical
// array — losers discard their newly built array and use the winner's.
return Interlocked.CompareExchange(ref _properties, newProperties, null) ?? newProperties;
}
public override bool IsDraft(string? culture = null)
@@ -107,6 +107,18 @@ internal sealed class DocumentCacheService : IDocumentCacheService
return await GetNodeAsync(key, calculatedPreview);
}
public bool TryGetCached(Guid key, bool preview, out IPublishedContent? content)
{
// Mirror the L0 (published content cache) fast path in GetNodeAsync.
if (preview is false && _publishedContentCache.TryGetValue(GetCacheKey(key, preview), out content))
{
return true;
}
content = null;
return false;
}
private async Task<IPublishedContent?> GetNodeAsync(Guid key, bool preview)
{
var cacheKey = GetCacheKey(key, preview);
@@ -103,6 +103,18 @@ internal sealed class MediaCacheService : IMediaCacheService
return await GetNodeAsync(key);
}
public bool TryGetCached(Guid key, out IPublishedContent? content)
{
// Mirror the L0 (published content cache) fast path in GetNodeAsync.
if (_publishedContentCache.TryGetValue(key, out content))
{
return true;
}
content = null;
return false;
}
private async Task<IPublishedContent?> GetNodeAsync(Guid key)
{
if (_publishedContentCache.TryGetValue(key, out IPublishedContent? cached))
@@ -30,6 +30,9 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests.Integration</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests.Benchmarks</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>DynamicProxyGenAssembly2</_Parameter1>
</AssemblyAttribute>
@@ -0,0 +1,252 @@
using System.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
using Umbraco.Cms.Infrastructure.HybridCache;
using Umbraco.Cms.Infrastructure.HybridCache.Factories;
using Umbraco.Cms.Infrastructure.HybridCache.Persistence;
using Umbraco.Cms.Infrastructure.HybridCache.Serialization;
using Umbraco.Cms.Infrastructure.HybridCache.Services;
using Umbraco.Cms.Infrastructure.Serialization;
namespace Umbraco.Tests.Benchmarks.Fixtures;
/// <summary>
/// Builds an in-memory published-content stack populated with a synthetic tree of a given shape,
/// so benchmarks can exercise <c>Children()</c> / <c>Descendants()</c> without a database.
/// </summary>
internal sealed class SyntheticPublishedTreeFixture
{
private readonly List<Guid> _allKeys = new();
public IDocumentNavigationQueryService NavigationQueryService { get; private set; } = null!;
public IPublishedContentStatusFilteringService StatusFilteringService { get; private set; } = null!;
public IPublishedContentCache PublishedContentCache { get; private set; } = null!;
public IPublishedContent Root { get; private set; } = null!;
public IPublishedValueFallback PublishedValueFallback { get; } = new NoopPublishedValueFallback();
public IReadOnlyList<Guid> AllKeys => _allKeys;
public Guid RootKey { get; private set; }
public async Task InitialiseAsync(int branchCount, int leafCount, int propertyCount = 10)
{
IPublishedModelFactory publishedModelFactory = new NoopPublishedModelFactory();
IVariationContextAccessor variationContextAccessor = new ThreadCultureVariationContextAccessor();
IPropertyRenderingContextAccessor propertyRenderingContextAccessor = Mock.Of<IPropertyRenderingContextAccessor>();
IElementsCache elementsCache = new ElementsDictionaryAppCache();
var converters = new PropertyValueConverterCollection(() => Enumerable.Empty<IPropertyValueConverter>());
IPublishedContentType contentType = BuildTestContentType(converters, publishedModelFactory, propertyCount);
Guid contentTypeKey = contentType.Key;
DocumentNavigationService navigationService = BuildNavigationService();
RootKey = Guid.NewGuid();
navigationService.Add(RootKey, contentTypeKey, parentKey: null, sortOrder: 0);
_allKeys.Add(RootKey);
for (var b = 0; b < branchCount; b++)
{
Guid branchKey = Guid.NewGuid();
navigationService.Add(branchKey, contentTypeKey, RootKey, b);
_allKeys.Add(branchKey);
for (var l = 0; l < leafCount; l++)
{
Guid leafKey = Guid.NewGuid();
navigationService.Add(leafKey, contentTypeKey, branchKey, l);
_allKeys.Add(leafKey);
}
}
// HybridCache requires a service provider for registration; everything else is wired by hand.
// Logging must be registered because HybridCacheSerializer takes an ILogger<T> dependency.
var services = new ServiceCollection();
services.AddLogging();
#pragma warning disable EXTEXP0018
services.AddHybridCache(opts =>
{
opts.MaximumPayloadBytes = 100 * 1024 * 1024;
}).AddSerializer<ContentCacheNode, HybridCacheSerializer>();
#pragma warning restore EXTEXP0018
ServiceProvider sp = services.BuildServiceProvider();
Microsoft.Extensions.Caching.Hybrid.HybridCache hybridCache = sp.GetRequiredService<Microsoft.Extensions.Caching.Hybrid.HybridCache>();
// Always return our single test content type.
var contentTypeCacheMock = new Mock<IPublishedContentTypeCache>();
contentTypeCacheMock.Setup(x => x.Get(PublishedItemType.Content, It.IsAny<int>())).Returns(contentType);
contentTypeCacheMock.Setup(x => x.Get(PublishedItemType.Content, It.IsAny<Guid>())).Returns(contentType);
contentTypeCacheMock.Setup(x => x.Get(PublishedItemType.Content, It.IsAny<string>())).Returns(contentType);
IPublishedContentTypeCache contentTypeCache = contentTypeCacheMock.Object;
IPublishedContentFactory publishedContentFactory = new PublishedContentFactory(
elementsCache,
variationContextAccessor,
propertyRenderingContextAccessor,
contentTypeCache);
var publishStatusMock = new Mock<IPublishStatusQueryService>();
publishStatusMock.Setup(x => x.IsDocumentPublished(It.IsAny<Guid>(), It.IsAny<string>())).Returns(true);
publishStatusMock.Setup(x => x.HasPublishedAncestorPath(It.IsAny<Guid>(), It.IsAny<string>())).Returns(true);
publishStatusMock.Setup(x => x.HasPublishedAncestorPath(It.IsAny<Guid>())).Returns(true);
// Repository: never called because we pre-seed the cache, but provide a safe stub.
var repoMock = new Mock<IDatabaseCacheRepository>();
repoMock.Setup(r => r.GetContentSourceAsync(It.IsAny<Guid>(), It.IsAny<bool>()))
.ReturnsAsync((ContentCacheNode?)null);
var previewMock = new Mock<IPreviewService>();
previewMock.Setup(x => x.IsInPreview()).Returns(false);
var idKeyMapMock = new Mock<IIdKeyMap>();
idKeyMapMock.Setup(x => x.GetKeyForId(It.IsAny<int>(), It.IsAny<UmbracoObjectTypes>()))
.Returns(Attempt.Fail<Guid>());
var scopeMock = new Mock<ICoreScope>();
var scopeProviderMock = new Mock<ICoreScopeProvider>();
scopeProviderMock.Setup(x => x.CreateCoreScope(
It.IsAny<IsolationLevel>(),
It.IsAny<RepositoryCacheMode>(),
It.IsAny<IEventDispatcher>(),
It.IsAny<IScopedNotificationPublisher>(),
It.IsAny<bool?>(),
It.IsAny<bool>(),
It.IsAny<bool>()))
.Returns(scopeMock.Object);
var cacheService = new DocumentCacheService(
repoMock.Object,
idKeyMapMock.Object,
scopeProviderMock.Object,
hybridCache,
publishedContentFactory,
Mock.Of<ICacheNodeFactory>(),
Enumerable.Empty<IDocumentSeedKeyProvider>(),
Options.Create(new CacheSettings()),
publishedModelFactory,
previewMock.Object,
publishStatusMock.Object,
NullLogger<DocumentCacheService>.Instance);
// Seed every node directly so reads stay in-memory and never reach the repository stub.
foreach (Guid key in _allKeys)
{
ContentCacheNode node = BuildContentCacheNode(key, contentType.Id, propertyCount);
await hybridCache.SetAsync(key.ToString(), node);
}
var documentCache = new DocumentCache(
cacheService,
contentTypeCache,
navigationService,
Mock.Of<IDocumentUrlService>(),
new Lazy<IPublishedUrlProvider>(() => Mock.Of<IPublishedUrlProvider>()));
PublishedContentCache = documentCache;
NavigationQueryService = navigationService;
StatusFilteringService = new PublishedContentStatusFilteringService(
variationContextAccessor,
publishStatusMock.Object,
previewMock.Object,
documentCache);
Root = (await cacheService.GetByKeyAsync(RootKey, false))!;
}
private static IPublishedContentType BuildTestContentType(
PropertyValueConverterCollection converters,
IPublishedModelFactory modelFactory,
int propertyCount)
{
var jsonSerializer = new SystemTextConfigurationEditorJsonSerializer(new DefaultJsonSerializerEncoderFactory());
var dataType = new DataType(new VoidEditor(Mock.Of<IDataValueEditorFactory>()), jsonSerializer) { Id = 1 };
var dataTypeServiceMock = new Mock<IDataTypeService>();
// PublishedContentTypeFactory.GetDataType calls the synchronous GetAll() overload (the obsolete
// params int[] one), so we must set up that one rather than the new GetAllAsync.
#pragma warning disable CS0618
dataTypeServiceMock.Setup(x => x.GetAll()).Returns(new[] { dataType });
#pragma warning restore CS0618
var factory = new PublishedContentTypeFactory(modelFactory, converters, dataTypeServiceMock.Object);
IEnumerable<IPublishedPropertyType> CreatePropertyTypes(IPublishedContentType contentType)
{
for (var i = 0; i < propertyCount; i++)
{
yield return factory.CreatePropertyType(contentType, $"prop{i}", dataType.Id, ContentVariation.Nothing);
}
}
return new PublishedContentType(
Guid.NewGuid(),
1000,
"benchPage",
PublishedItemType.Content,
Enumerable.Empty<string>(),
CreatePropertyTypes,
ContentVariation.Nothing,
isElement: false);
}
private static DocumentNavigationService BuildNavigationService()
=> new(
Mock.Of<ICoreScopeProvider>(),
Mock.Of<INavigationRepository>(),
Mock.Of<IContentTypeService>());
private static ContentCacheNode BuildContentCacheNode(Guid key, int contentTypeId, int propertyCount)
{
var properties = new Dictionary<string, PropertyData[]>(propertyCount);
for (var i = 0; i < propertyCount; i++)
{
properties[$"prop{i}"] =
[
new PropertyData
{
Value = $"value-{i}",
Culture = string.Empty,
Segment = string.Empty,
},
];
}
var data = new ContentData(
name: $"Node-{key.ToString()[..8]}",
urlSegment: null,
versionId: 1,
versionDate: DateTime.UtcNow,
writerId: -1,
templateId: 0,
published: true,
properties: properties,
cultureInfos: null);
return new ContentCacheNode
{
Id = Math.Abs(key.GetHashCode()),
Key = key,
SortOrder = 0,
CreateDate = DateTime.UtcNow,
CreatorId = -1,
ContentTypeId = contentTypeId,
IsDraft = false,
Data = data,
};
}
}
@@ -0,0 +1,139 @@
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Toolchains.InProcess.Emit;
using Perfolizer.Horology;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Extensions;
using Umbraco.Tests.Benchmarks.Fixtures;
namespace Umbraco.Tests.Benchmarks;
/// <summary>
/// Measures the cost of <c>Children()</c> and <c>Descendants()</c> on <see cref="IPublishedContent"/>
/// against the published-content cache, using a known synthetic tree shape.
/// </summary>
/// <remarks>
/// Run with: <c>dotnet run -c Release --project tests/Umbraco.Tests.Benchmarks -- --filter "*HybridCacheNavigation*"</c>.
/// Uses <see cref="InProcessEmitToolchain"/> so each benchmark runs in the same process via
/// <c>Reflection.Emit</c>, avoiding the per-benchmark MSBuild compile (which otherwise both times
/// out and floods the console with the repo's pre-existing warnings).
/// </remarks>
[Config(typeof(InProcessStableRunConfig))]
public class HybridCacheNavigationBenchmarks
{
/// <summary>
/// Longer iterations than <c>QuickRun</c> so the variance on the multi-thousand-node descendant
/// benchmarks settles enough to support before/after comparisons. <c>InProcessEmitToolchain</c>
/// keeps the run in the same process to avoid the per-benchmark MSBuild step.
/// </summary>
private sealed class InProcessStableRunConfig : ManualConfig
{
public InProcessStableRunConfig()
{
AddJob(Job.Default
.WithLaunchCount(1)
.WithIterationTime(new TimeInterval(500, TimeUnit.Millisecond))
.WithWarmupCount(5)
.WithIterationCount(10)
.WithToolchain(InProcessEmitToolchain.Instance));
AddDiagnoser(MemoryDiagnoser.Default);
}
}
private SyntheticPublishedTreeFixture _fixture = null!;
/// <summary>
/// Approximate total node count: <c>1 + BranchCount + (BranchCount * LeafCount)</c>.
/// The single 50 × 100 = 5,051-node configuration is the case closest to the reported workload;
/// it's enough to see the impact of the targeted improvements without paying for the full matrix
/// each iterate-and-compare cycle. Add more <c>[Params]</c> values when producing the final
/// PR-description numbers if scaling behaviour matters.
/// </summary>
[Params(50)]
public int BranchCount { get; set; }
[Params(100)]
public int LeafCount { get; set; }
[GlobalSetup]
public void Setup()
{
_fixture = new SyntheticPublishedTreeFixture();
_fixture.InitialiseAsync(BranchCount, LeafCount).GetAwaiter().GetResult();
// Warm the converted-content cache so each benchmark measures the steady-state
// hot path rather than first-touch materialisation.
_ = _fixture.Root
.Descendants(_fixture.NavigationQueryService, _fixture.StatusFilteringService)
.Count();
}
[Benchmark]
public int Children_Count()
=> _fixture.Root
.Children(_fixture.NavigationQueryService, _fixture.StatusFilteringService)
.Count();
[Benchmark]
public int Children_ReadOneProperty()
{
var read = 0;
foreach (IPublishedContent child in _fixture.Root.Children(_fixture.NavigationQueryService, _fixture.StatusFilteringService))
{
// Touch one property per child so we exercise the property pipeline once.
_ = child.Value<string>(_fixture.PublishedValueFallback, "prop0");
read++;
}
return read;
}
[Benchmark]
public int Descendants_Count()
=> _fixture.Root
.Descendants(_fixture.NavigationQueryService, _fixture.StatusFilteringService)
.Count();
[Benchmark]
public int Descendants_ReadOneProperty()
{
var read = 0;
foreach (IPublishedContent descendant in _fixture.Root.Descendants(_fixture.NavigationQueryService, _fixture.StatusFilteringService))
{
_ = descendant.Value<string>(_fixture.PublishedValueFallback, "prop0");
read++;
}
return read;
}
/// <summary>
/// Asks for only the first descendant. Reveals whether enumeration short-circuits or
/// materialises the full descendant set before yielding.
/// </summary>
[Benchmark]
public IPublishedContent? Descendants_FirstOrDefault()
=> _fixture.Root
.Descendants(_fixture.NavigationQueryService, _fixture.StatusFilteringService)
.FirstOrDefault();
/// <summary>
/// Recursive traversal pattern that calls Children() twice per node — once for Any() and once
/// for the recursive step.
/// </summary>
[Benchmark]
public int RecursiveTraversal()
=> GetDescendants([_fixture.Root]).Count;
private List<IPublishedContent> GetDescendants(IEnumerable<IPublishedContent> contents)
{
var result = contents.ToList();
result
.Where(x => x.Children(_fixture.NavigationQueryService, _fixture.StatusFilteringService).Any())
.ToList()
.ForEach(x => result.AddRange(GetDescendants(x.Children(_fixture.NavigationQueryService, _fixture.StatusFilteringService))));
return result;
}
}
@@ -0,0 +1,148 @@
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Toolchains.InProcess.Emit;
using Moq;
using Perfolizer.Horology;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
namespace Umbraco.Tests.Benchmarks;
/// <summary>
/// Documents the cache shape under multi-tenant or heterogeneous-content-type workloads:
/// the per-snapshot <c>DescendantsCache</c> is keyed by <c>(parent, contentType?)</c>, so a parent
/// queried with N different <c>Descendants("typeX")</c> aliases accumulates up to N entries. This
/// benchmark sweeps the content-type-diversity parameter so the steady-state allocation and per-
/// query time can be compared as the per-parent cache fan-out grows.
/// </summary>
/// <remarks>
/// Run with: <c>dotnet run -c Release --project tests/Umbraco.Tests.Benchmarks -- --filter "*NavigationDescendantsCacheDiversity*"</c>.
/// The benchmark uses a plain <see cref="DocumentNavigationService"/> rather than the full HybridCache
/// stack — the cache lives on the navigation snapshot itself, so the HybridCache layer is irrelevant
/// to what we're measuring here.
/// </remarks>
[Config(typeof(InProcessStableRunConfig))]
public class NavigationDescendantsCacheDiversityBenchmarks
{
private sealed class InProcessStableRunConfig : ManualConfig
{
public InProcessStableRunConfig()
{
AddJob(Job.Default
.WithLaunchCount(1)
.WithIterationTime(new TimeInterval(500, TimeUnit.Millisecond))
.WithWarmupCount(3)
.WithIterationCount(5)
.WithToolchain(InProcessEmitToolchain.Instance));
AddDiagnoser(MemoryDiagnoser.Default);
}
}
/// <summary>
/// Number of distinct content types spread across the tree.
/// </summary>
[Params(5, 25, 50)]
public int ContentTypeCount { get; set; }
[Params(10)]
public int ParentCount { get; set; }
[Params(20)]
public int ChildrenPerParentPerType { get; set; }
private DocumentNavigationService _service = null!;
private Guid[] _parentKeys = null!;
private string[] _contentTypeAliases = null!;
[GlobalSetup]
public void Setup()
{
_contentTypeAliases = new string[ContentTypeCount];
var contentTypeKeys = new Guid[ContentTypeCount];
var contentTypes = new IContentType[ContentTypeCount];
for (var i = 0; i < ContentTypeCount; i++)
{
_contentTypeAliases[i] = $"type{i}";
contentTypeKeys[i] = Guid.NewGuid();
var ct = new Mock<IContentType>();
ct.SetupGet(x => x.Alias).Returns(_contentTypeAliases[i]);
ct.SetupGet(x => x.Key).Returns(contentTypeKeys[i]);
contentTypes[i] = ct.Object;
}
var contentTypeService = new Mock<IContentTypeService>();
contentTypeService.Setup(s => s.GetAll()).Returns(contentTypes);
_service = new DocumentNavigationService(
Mock.Of<ICoreScopeProvider>(),
Mock.Of<INavigationRepository>(),
contentTypeService.Object);
// Build a flat-ish tree: ParentCount root parents, each with (ContentTypeCount × ChildrenPerParentPerType) children.
// The first content type "owns" each parent so the parents themselves are queryable.
_parentKeys = new Guid[ParentCount];
for (var p = 0; p < ParentCount; p++)
{
Guid parentKey = Guid.NewGuid();
_parentKeys[p] = parentKey;
_service.Add(parentKey, contentTypeKeys[0]);
for (var t = 0; t < ContentTypeCount; t++)
{
for (var c = 0; c < ChildrenPerParentPerType; c++)
{
_service.Add(Guid.NewGuid(), contentTypeKeys[t], parentKey);
}
}
}
}
/// <summary>
/// One full sweep: every parent queried for every content type. After the first iteration the
/// cache is fully primed; subsequent iterations should be cache hits with near-zero allocation.
/// The Δ between iteration 1 and the steady state reveals the per-entry build cost.
/// </summary>
[Benchmark]
public int FullSweep_AllParentsAllTypes()
{
var total = 0;
for (var p = 0; p < ParentCount; p++)
{
for (var t = 0; t < ContentTypeCount; t++)
{
if (_service.TryGetDescendantsKeysOfType(_parentKeys[p], _contentTypeAliases[t], out IEnumerable<Guid> keys))
{
total += keys.Count();
}
}
}
return total;
}
/// <summary>
/// Repeated unfiltered <c>Descendants()</c> calls on a single parent. The cache key is
/// <c>(parent, null)</c> so this is a single entry; the benchmark validates that diverse-type
/// fan-out on neighbouring parents does not pollute or evict this entry.
/// </summary>
[Benchmark]
public int RepeatedUnfilteredOnSingleParent()
{
var total = 0;
for (var i = 0; i < 100; i++)
{
if (_service.TryGetDescendantsKeys(_parentKeys[0], out IEnumerable<Guid> keys))
{
total += keys.Count();
}
}
return total;
}
}
@@ -0,0 +1,257 @@
using System.Collections.Concurrent;
using NUnit.Framework;
using Umbraco.Cms.Core.Models.Navigation;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Models.Navigation;
/// <summary>
/// Tests for <see cref="NavigationNode"/>'s ordered-children cache. Functional behaviour
/// across the full navigation service is covered by the integration suite; this fixture
/// focuses on the lazy-built, mutation-invalidated cache contract.
/// </summary>
[TestFixture]
public class NavigationNodeTests
{
[Test]
public void GetOrderedChildren_NoChildren_ReturnsEmpty()
{
var structure = new ConcurrentDictionary<Guid, NavigationNode>();
NavigationNode node = AddNode(structure, contentTypeKey: Guid.NewGuid());
IReadOnlyList<Guid> children = node.GetOrderedChildren(structure);
Assert.That(children, Is.Empty);
}
[Test]
public void GetOrderedChildren_ReturnsChildrenSortedBySortOrder()
{
var structure = new ConcurrentDictionary<Guid, NavigationNode>();
Guid contentTypeKey = Guid.NewGuid();
NavigationNode parent = AddNode(structure, contentTypeKey);
// Three children added via AddChild — each gets sort order 0, 1, 2 in turn.
Guid first = AddChildOf(structure, parent, contentTypeKey);
Guid second = AddChildOf(structure, parent, contentTypeKey);
Guid third = AddChildOf(structure, parent, contentTypeKey);
IReadOnlyList<Guid> ordered = parent.GetOrderedChildren(structure);
Assert.That(ordered, Is.EqualTo(new[] { first, second, third }));
}
[Test]
public void GetOrderedChildren_RepeatedCalls_ReturnSameInstanceWhenUnchanged()
{
var structure = new ConcurrentDictionary<Guid, NavigationNode>();
Guid contentTypeKey = Guid.NewGuid();
NavigationNode parent = AddNode(structure, contentTypeKey);
AddChildOf(structure, parent, contentTypeKey);
AddChildOf(structure, parent, contentTypeKey);
IReadOnlyList<Guid> first = parent.GetOrderedChildren(structure);
IReadOnlyList<Guid> second = parent.GetOrderedChildren(structure);
// The cache contract: until invalidated, repeated calls return the same array.
Assert.That(second, Is.SameAs(first));
}
[Test]
public void GetOrderedChildren_AddChild_InvalidatesCacheAndIncludesNewChild()
{
var structure = new ConcurrentDictionary<Guid, NavigationNode>();
Guid contentTypeKey = Guid.NewGuid();
NavigationNode parent = AddNode(structure, contentTypeKey);
AddChildOf(structure, parent, contentTypeKey);
AddChildOf(structure, parent, contentTypeKey);
IReadOnlyList<Guid> beforeAdd = parent.GetOrderedChildren(structure);
Assume.That(beforeAdd.Count, Is.EqualTo(2));
Guid added = AddChildOf(structure, parent, contentTypeKey);
IReadOnlyList<Guid> afterAdd = parent.GetOrderedChildren(structure);
Assert.That(afterAdd, Is.Not.SameAs(beforeAdd));
Assert.That(afterAdd.Count, Is.EqualTo(3));
Assert.That(afterAdd, Does.Contain(added));
}
[Test]
public void GetOrderedChildren_RemoveChild_InvalidatesCacheAndExcludesChild()
{
var structure = new ConcurrentDictionary<Guid, NavigationNode>();
Guid contentTypeKey = Guid.NewGuid();
NavigationNode parent = AddNode(structure, contentTypeKey);
Guid childA = AddChildOf(structure, parent, contentTypeKey);
Guid childB = AddChildOf(structure, parent, contentTypeKey);
// Prime the cache.
IReadOnlyList<Guid> primed = parent.GetOrderedChildren(structure);
Assume.That(primed, Is.EqualTo(new[] { childA, childB }));
parent.RemoveChild(structure, childA);
IReadOnlyList<Guid> afterRemove = parent.GetOrderedChildren(structure);
Assert.That(afterRemove, Is.Not.SameAs(primed));
Assert.That(afterRemove, Is.EqualTo(new[] { childB }));
}
[Test]
public void GetOrderedChildren_AfterInvalidate_ReturnsFreshSnapshotReflectingSortOrderChange()
{
var structure = new ConcurrentDictionary<Guid, NavigationNode>();
Guid contentTypeKey = Guid.NewGuid();
NavigationNode parent = AddNode(structure, contentTypeKey);
Guid childA = AddChildOf(structure, parent, contentTypeKey);
Guid childB = AddChildOf(structure, parent, contentTypeKey);
Guid childC = AddChildOf(structure, parent, contentTypeKey);
IReadOnlyList<Guid> initial = parent.GetOrderedChildren(structure);
Assume.That(initial, Is.EqualTo(new[] { childA, childB, childC }));
// Reverse the sort order via UpdateSortOrder, then invalidate (this mirrors what
// ContentNavigationServiceBase.UpdateSortOrder does after mutating a child).
structure[childA].UpdateSortOrder(2);
structure[childB].UpdateSortOrder(1);
structure[childC].UpdateSortOrder(0);
parent.InvalidateOrderedChildren();
IReadOnlyList<Guid> reordered = parent.GetOrderedChildren(structure);
Assert.That(reordered, Is.Not.SameAs(initial));
Assert.That(reordered, Is.EqualTo(new[] { childC, childB, childA }));
}
[Test]
public void GetOrderedChildren_WithoutInvalidation_StillReturnsOldOrderingAfterDirectSortOrderEdit()
{
// Documents the contract: NavigationNode.UpdateSortOrder on a child does NOT
// automatically invalidate the parent's cache (the node has no parent reference).
// Callers that mutate child SortOrder must call InvalidateOrderedChildren on the parent
// — which is what ContentNavigationServiceBase.UpdateSortOrder does.
var structure = new ConcurrentDictionary<Guid, NavigationNode>();
Guid contentTypeKey = Guid.NewGuid();
NavigationNode parent = AddNode(structure, contentTypeKey);
Guid childA = AddChildOf(structure, parent, contentTypeKey);
Guid childB = AddChildOf(structure, parent, contentTypeKey);
IReadOnlyList<Guid> primed = parent.GetOrderedChildren(structure);
Assume.That(primed, Is.EqualTo(new[] { childA, childB }));
// Flip the sort orders without invalidating.
structure[childA].UpdateSortOrder(1);
structure[childB].UpdateSortOrder(0);
IReadOnlyList<Guid> stillCached = parent.GetOrderedChildren(structure);
Assert.That(stillCached, Is.SameAs(primed), "Cache returns the previously-built array until invalidated");
}
[Test]
public void GetOrderedChildren_StaleAfterDirectSortOrderEdit_SelfHealsOnNextAddChild()
{
// Companion to GetOrderedChildren_WithoutInvalidation_StillReturnsOldOrderingAfterDirectSortOrderEdit.
// A third-party caller that mutates SortOrder directly on a NavigationNode (rather than
// going through ContentNavigationServiceBase.UpdateSortOrder) leaves the parent's ordered-
// children cache stale, but any subsequent structural mutation that flows through
// AddChild/RemoveChild on that parent will discard the stale array and rebuild against
// the current SortOrder values. This pins the self-healing recovery path that backs
// the "stale until next snapshot rebuild" comment in the PR https://github.com/umbraco/Umbraco-CMS/pull/22742
var structure = new ConcurrentDictionary<Guid, NavigationNode>();
Guid contentTypeKey = Guid.NewGuid();
NavigationNode parent = AddNode(structure, contentTypeKey);
Guid childA = AddChildOf(structure, parent, contentTypeKey);
Guid childB = AddChildOf(structure, parent, contentTypeKey);
// Prime, then mutate sort order directly (the misuse pattern).
IReadOnlyList<Guid> primed = parent.GetOrderedChildren(structure);
Assume.That(primed, Is.EqualTo(new[] { childA, childB }));
structure[childA].UpdateSortOrder(5);
structure[childB].UpdateSortOrder(1);
IReadOnlyList<Guid> stillStale = parent.GetOrderedChildren(structure);
Assume.That(stillStale, Is.SameAs(primed), "Cache must still be stale before the recovering mutation.");
// Now an unrelated structural change runs through the parent — AddChild on a new sibling.
// Its sort order (3) sits between the re-edited values, so a freshly-built ordering must
// place it between the two existing children. If the rebuild were to honour the stale
// cache, the new child would land at the end instead.
var newSibling = new NavigationNode(Guid.NewGuid(), contentTypeKey, sortOrder: 3);
structure[newSibling.Key] = newSibling;
parent.AddChild(structure, newSibling.Key);
IReadOnlyList<Guid> recovered = parent.GetOrderedChildren(structure);
Assert.Multiple(() =>
{
Assert.That(recovered, Is.Not.SameAs(primed), "Cache must rebuild rather than reuse the stale array.");
Assert.That(recovered, Is.EqualTo(new[] { childB, newSibling.Key, childA }),
"Rebuilt ordering must honour the current SortOrder values, including the directly-edited ones.");
});
}
[Test]
public void GetOrderedChildren_ConcurrentFirstAccess_AllThreadsSeeSameInstance()
{
// The race we're guarding against: multiple threads reach BuildOrderedChildren before
// any has finished. Without the lock + double-check they could each store a different
// array; the contract requires every reader observes the same canonical instance.
const int threadCount = 32;
var structure = new ConcurrentDictionary<Guid, NavigationNode>();
Guid contentTypeKey = Guid.NewGuid();
NavigationNode parent = AddNode(structure, contentTypeKey);
for (var i = 0; i < 50; i++)
{
AddChildOf(structure, parent, contentTypeKey);
}
var observed = new IReadOnlyList<Guid>[threadCount];
var startGate = new ManualResetEventSlim(false);
var threads = new Thread[threadCount];
for (var t = 0; t < threadCount; t++)
{
var localT = t;
threads[t] = new Thread(() =>
{
startGate.Wait();
observed[localT] = parent.GetOrderedChildren(structure);
});
threads[t].Start();
}
startGate.Set();
foreach (Thread thread in threads)
{
thread.Join();
}
IReadOnlyList<Guid> reference = observed[0];
for (var i = 1; i < threadCount; i++)
{
Assert.That(observed[i], Is.SameAs(reference));
}
}
private static NavigationNode AddNode(
ConcurrentDictionary<Guid, NavigationNode> structure,
Guid contentTypeKey)
{
var node = new NavigationNode(Guid.NewGuid(), contentTypeKey);
structure[node.Key] = node;
return node;
}
private static Guid AddChildOf(
ConcurrentDictionary<Guid, NavigationNode> structure,
NavigationNode parent,
Guid contentTypeKey)
{
NavigationNode child = AddNode(structure, contentTypeKey);
parent.AddChild(structure, child.Key);
return child.Key;
}
}
@@ -0,0 +1,400 @@
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services.Navigation;
/// <summary>
/// Tests for the per-snapshot descendants cache on <see cref="ContentNavigationServiceBase{TContentType, TContentTypeService}"/>.
/// Functional behaviour of the public API (Add, Move, Sort, Remove, etc.) is exhaustively
/// covered by the integration suite under <c>DocumentNavigationServiceTests</c>; this fixture
/// focuses on the cache contract introduced by §2.5: cache-hit identity, mutation
/// invalidation, exclusion of the content-type-filtered path from the cache, and concurrent
/// first-access thread safety.
/// </summary>
[TestFixture]
public class ContentNavigationDescendantsCacheTests
{
private static DocumentNavigationService CreateService() =>
new(
Mock.Of<ICoreScopeProvider>(),
Mock.Of<INavigationRepository>(),
Mock.Of<IContentTypeService>());
/// <summary>
/// Builds a service whose <c>IContentTypeService</c> resolves the supplied alias↔key
/// pairs, so <c>TryGetDescendantsKeysOfType(parent, alias, ...)</c> returns true rather
/// than failing at the alias-resolution step.
/// </summary>
private static DocumentNavigationService CreateServiceWithContentTypes(
params (string Alias, Guid Key)[] aliasKeyPairs)
{
var contentTypes = aliasKeyPairs.Select(p =>
{
var ct = new Mock<IContentType>();
ct.SetupGet(x => x.Alias).Returns(p.Alias);
ct.SetupGet(x => x.Key).Returns(p.Key);
return ct.Object;
}).ToArray();
var contentTypeService = new Mock<IContentTypeService>();
contentTypeService.Setup(s => s.GetAll()).Returns(contentTypes);
return new DocumentNavigationService(
Mock.Of<ICoreScopeProvider>(),
Mock.Of<INavigationRepository>(),
contentTypeService.Object);
}
[Test]
public void TryGetDescendantsKeys_RepeatedCalls_ReturnSameInstanceWhenUnchanged()
{
DocumentNavigationService service = CreateService();
Guid root = Guid.NewGuid();
Guid contentType = Guid.NewGuid();
service.Add(root, contentType);
service.Add(Guid.NewGuid(), contentType, root);
service.Add(Guid.NewGuid(), contentType, root);
Assume.That(service.TryGetDescendantsKeys(root, out IEnumerable<Guid> first), Is.True);
Assume.That(service.TryGetDescendantsKeys(root, out IEnumerable<Guid> second), Is.True);
// The cache contract: until invalidated, repeated calls return the same array reference.
Assert.That(second, Is.SameAs(first));
}
[Test]
public void TryGetDescendantsKeys_AfterAdd_RebuildsAndIncludesNewNode()
{
DocumentNavigationService service = CreateService();
Guid root = Guid.NewGuid();
Guid contentType = Guid.NewGuid();
service.Add(root, contentType);
service.Add(Guid.NewGuid(), contentType, root);
Assume.That(service.TryGetDescendantsKeys(root, out IEnumerable<Guid> primed), Is.True);
Assume.That(primed.Count(), Is.EqualTo(1));
Guid added = Guid.NewGuid();
service.Add(added, contentType, root);
Assert.That(service.TryGetDescendantsKeys(root, out IEnumerable<Guid> afterAdd), Is.True);
Assert.That(afterAdd, Is.Not.SameAs(primed));
Assert.That(afterAdd, Does.Contain(added));
}
[Test]
public void TryGetDescendantsKeys_AfterMove_RebuildsAndReflectsNewParentage()
{
DocumentNavigationService service = CreateService();
Guid contentType = Guid.NewGuid();
Guid rootA = Guid.NewGuid();
Guid rootB = Guid.NewGuid();
Guid moveable = Guid.NewGuid();
service.Add(rootA, contentType);
service.Add(rootB, contentType);
service.Add(moveable, contentType, rootA);
Assume.That(service.TryGetDescendantsKeys(rootA, out IEnumerable<Guid> rootADescPrimed), Is.True);
Assume.That(rootADescPrimed, Does.Contain(moveable));
Assume.That(service.TryGetDescendantsKeys(rootB, out IEnumerable<Guid> rootBDescPrimed), Is.True);
Assume.That(rootBDescPrimed, Is.Empty);
service.Move(moveable, rootB);
Assert.That(service.TryGetDescendantsKeys(rootA, out IEnumerable<Guid> rootAAfter), Is.True);
Assert.That(service.TryGetDescendantsKeys(rootB, out IEnumerable<Guid> rootBAfter), Is.True);
Assert.That(rootAAfter, Does.Not.Contain(moveable));
Assert.That(rootBAfter, Does.Contain(moveable));
}
[Test]
public void TryGetDescendantsKeys_AfterMoveToBin_RebuildsAndOmitsMovedNode()
{
DocumentNavigationService service = CreateService();
Guid contentType = Guid.NewGuid();
Guid root = Guid.NewGuid();
Guid trashed = Guid.NewGuid();
service.Add(root, contentType);
service.Add(trashed, contentType, root);
Assume.That(service.TryGetDescendantsKeys(root, out IEnumerable<Guid> primed), Is.True);
Assume.That(primed, Does.Contain(trashed));
service.MoveToBin(trashed);
Assert.That(service.TryGetDescendantsKeys(root, out IEnumerable<Guid> after), Is.True);
Assert.That(after, Does.Not.Contain(trashed));
}
[Test]
public void TryGetDescendantsKeysInBin_AfterRemoveFromBin_RebuildsBinDescendantsCache()
{
// RemoveFromBin invalidates the recycle-bin snapshot. Primed bin entries for an unrelated
// trashed subtree must therefore be discarded (whole-snapshot invalidation, not surgical).
DocumentNavigationService service = CreateService();
Guid contentType = Guid.NewGuid();
Guid trashedRootA = Guid.NewGuid();
Guid trashedChildA = Guid.NewGuid();
service.Add(trashedRootA, contentType);
service.Add(trashedChildA, contentType, trashedRootA);
service.MoveToBin(trashedRootA);
Guid trashedRootB = Guid.NewGuid();
service.Add(trashedRootB, contentType);
service.MoveToBin(trashedRootB);
Assume.That(service.TryGetDescendantsKeysInBin(trashedRootA, out IEnumerable<Guid> primedBin), Is.True);
Assume.That(primedBin, Does.Contain(trashedChildA));
service.RemoveFromBin(trashedRootB);
Assert.That(service.TryGetDescendantsKeysInBin(trashedRootA, out IEnumerable<Guid> afterRemove), Is.True);
Assert.That(afterRemove, Is.Not.SameAs(primedBin));
Assert.That(afterRemove, Does.Contain(trashedChildA));
}
[Test]
public void TryGetDescendantsKeys_AfterRestoreFromBin_RebuildsMainDescendantsCache()
{
// RestoreFromBin invalidates the main snapshot. Primed main entries for the restore
// target must therefore be rebuilt to include the restored subtree.
DocumentNavigationService service = CreateService();
Guid contentType = Guid.NewGuid();
Guid mainRoot = Guid.NewGuid();
Guid mainChild = Guid.NewGuid();
service.Add(mainRoot, contentType);
service.Add(mainChild, contentType, mainRoot);
Guid trashedRoot = Guid.NewGuid();
Guid trashedChild = Guid.NewGuid();
service.Add(trashedRoot, contentType);
service.Add(trashedChild, contentType, trashedRoot);
service.MoveToBin(trashedRoot);
Assume.That(service.TryGetDescendantsKeys(mainRoot, out IEnumerable<Guid> primedMain), Is.True);
Assume.That(primedMain.ToArray(), Is.EqualTo(new[] { mainChild }));
service.RestoreFromBin(trashedRoot, mainRoot);
Assert.That(service.TryGetDescendantsKeys(mainRoot, out IEnumerable<Guid> afterRestore), Is.True);
Assert.That(afterRestore, Is.Not.SameAs(primedMain));
Assert.That(afterRestore, Does.Contain(trashedRoot));
Assert.That(afterRestore, Does.Contain(trashedChild));
Assert.That(afterRestore, Does.Contain(mainChild));
}
[Test]
public void TryGetDescendantsKeysInBin_AfterRestoreFromBin_RebuildsBinDescendantsCache()
{
// RestoreFromBin invalidates the recycle-bin snapshot too. Primed bin entries for an
// unrelated trashed subtree must be discarded even though the restore did not touch them.
DocumentNavigationService service = CreateService();
Guid contentType = Guid.NewGuid();
Guid mainRoot = Guid.NewGuid();
service.Add(mainRoot, contentType);
Guid trashedRootA = Guid.NewGuid();
Guid trashedChildA = Guid.NewGuid();
service.Add(trashedRootA, contentType);
service.Add(trashedChildA, contentType, trashedRootA);
service.MoveToBin(trashedRootA);
Guid trashedRootB = Guid.NewGuid();
service.Add(trashedRootB, contentType);
service.MoveToBin(trashedRootB);
Assume.That(service.TryGetDescendantsKeysInBin(trashedRootA, out IEnumerable<Guid> primedBin), Is.True);
Assume.That(primedBin, Does.Contain(trashedChildA));
service.RestoreFromBin(trashedRootB, mainRoot);
Assert.That(service.TryGetDescendantsKeysInBin(trashedRootA, out IEnumerable<Guid> afterRestore), Is.True);
Assert.That(afterRestore, Is.Not.SameAs(primedBin));
Assert.That(afterRestore, Does.Contain(trashedChildA));
}
[Test]
public void TryGetDescendantsKeys_AfterUpdateSortOrder_RebuildsAndReflectsNewOrder()
{
DocumentNavigationService service = CreateService();
Guid contentType = Guid.NewGuid();
Guid root = Guid.NewGuid();
Guid first = Guid.NewGuid();
Guid second = Guid.NewGuid();
Guid third = Guid.NewGuid();
service.Add(root, contentType);
service.Add(first, contentType, root);
service.Add(second, contentType, root);
service.Add(third, contentType, root);
Assume.That(service.TryGetDescendantsKeys(root, out IEnumerable<Guid> primed), Is.True);
Assume.That(primed.ToArray(), Is.EqualTo(new[] { first, second, third }));
// Reverse the order.
service.UpdateSortOrder(first, 2);
service.UpdateSortOrder(second, 1);
service.UpdateSortOrder(third, 0);
Assert.That(service.TryGetDescendantsKeys(root, out IEnumerable<Guid> after), Is.True);
Assert.That(after.ToArray(), Is.EqualTo(new[] { third, second, first }));
}
[Test]
public void TryGetDescendantsKeysOfType_DoesNotPollute_TryGetDescendantsKeys()
{
Guid sameTypeAlias = Guid.NewGuid();
Guid otherTypeAlias = Guid.NewGuid();
DocumentNavigationService service = CreateServiceWithContentTypes(
("sameType", sameTypeAlias),
("otherType", otherTypeAlias));
Guid root = Guid.NewGuid();
Guid sameType = Guid.NewGuid();
Guid differentType = Guid.NewGuid();
service.Add(root, sameTypeAlias);
service.Add(sameType, sameTypeAlias, root);
service.Add(differentType, otherTypeAlias, root);
// OfType result is cached against (parent, contentType) — not (parent, null) — so the
// unfiltered cache stays clean and a subsequent TryGetDescendantsKeys returns the full
// descendant set, not the filtered subset.
Assume.That(service.TryGetDescendantsKeysOfType(root, "sameType", out IEnumerable<Guid> filtered), Is.True);
Assume.That(filtered, Is.EquivalentTo(new[] { sameType }));
Assert.That(service.TryGetDescendantsKeys(root, out IEnumerable<Guid> unfiltered), Is.True);
Assert.That(unfiltered, Is.EquivalentTo(new[] { sameType, differentType }));
}
[Test]
public void TryGetDescendantsKeysOfType_RepeatedCalls_ReturnSameInstanceWhenUnchanged()
{
Guid contentTypeAlias = Guid.NewGuid();
DocumentNavigationService service = CreateServiceWithContentTypes(("blogPost", contentTypeAlias));
Guid root = Guid.NewGuid();
service.Add(root, contentTypeAlias);
service.Add(Guid.NewGuid(), contentTypeAlias, root);
service.Add(Guid.NewGuid(), contentTypeAlias, root);
Assume.That(service.TryGetDescendantsKeysOfType(root, "blogPost", out IEnumerable<Guid> first), Is.True);
Assume.That(service.TryGetDescendantsKeysOfType(root, "blogPost", out IEnumerable<Guid> second), Is.True);
// Cache hit on the OfType path returns the same canonical Guid[] instance.
Assert.That(second, Is.SameAs(first));
}
[Test]
public void TryGetDescendantsKeysOfType_DifferentTypes_AreCachedSeparately()
{
Guid typeAKey = Guid.NewGuid();
Guid typeBKey = Guid.NewGuid();
DocumentNavigationService service = CreateServiceWithContentTypes(
("typeA", typeAKey),
("typeB", typeBKey));
Guid root = Guid.NewGuid();
Guid childA = Guid.NewGuid();
Guid childB = Guid.NewGuid();
service.Add(root, typeAKey);
service.Add(childA, typeAKey, root);
service.Add(childB, typeBKey, root);
Assert.That(service.TryGetDescendantsKeysOfType(root, "typeA", out IEnumerable<Guid> aResult), Is.True);
Assert.That(service.TryGetDescendantsKeysOfType(root, "typeB", out IEnumerable<Guid> bResult), Is.True);
Assert.That(aResult, Is.EquivalentTo(new[] { childA }));
Assert.That(bResult, Is.EquivalentTo(new[] { childB }));
Assert.That(aResult, Is.Not.SameAs(bResult));
// Unfiltered call should still see all descendants regardless of which type-filtered
// entries are already in the cache.
Assert.That(service.TryGetDescendantsKeys(root, out IEnumerable<Guid> unfiltered), Is.True);
Assert.That(unfiltered, Is.EquivalentTo(new[] { childA, childB }));
}
[Test]
public void TryGetDescendantsKeysOfType_AfterAdd_RebuildsAndIncludesMatchingNewNode()
{
Guid contentTypeAlias = Guid.NewGuid();
DocumentNavigationService service = CreateServiceWithContentTypes(("blogPost", contentTypeAlias));
Guid root = Guid.NewGuid();
service.Add(root, contentTypeAlias);
service.Add(Guid.NewGuid(), contentTypeAlias, root);
Assume.That(service.TryGetDescendantsKeysOfType(root, "blogPost", out IEnumerable<Guid> primed), Is.True);
Assume.That(primed.Count(), Is.EqualTo(1));
Guid added = Guid.NewGuid();
service.Add(added, contentTypeAlias, root);
Assert.That(service.TryGetDescendantsKeysOfType(root, "blogPost", out IEnumerable<Guid> afterAdd), Is.True);
Assert.That(afterAdd, Is.Not.SameAs(primed));
Assert.That(afterAdd, Does.Contain(added));
}
[Test]
public void TryGetDescendantsKeys_ConcurrentFirstAccess_AllThreadsSeeSameInstance()
{
// The race we're guarding against: multiple threads enter the static helper before any
// writes to DescendantsCache. The outcome should be that whoever-writes-last wins, and
// every reader observes the same canonical Guid[] reference on subsequent calls.
const int threadCount = 16;
DocumentNavigationService service = CreateService();
Guid contentType = Guid.NewGuid();
Guid root = Guid.NewGuid();
service.Add(root, contentType);
for (var i = 0; i < 50; i++)
{
service.Add(Guid.NewGuid(), contentType, root);
}
var observed = new IEnumerable<Guid>[threadCount];
var startGate = new ManualResetEventSlim(false);
var threads = new Thread[threadCount];
for (var t = 0; t < threadCount; t++)
{
var localT = t;
threads[t] = new Thread(() =>
{
startGate.Wait();
service.TryGetDescendantsKeys(root, out IEnumerable<Guid> result);
observed[localT] = result;
});
threads[t].Start();
}
startGate.Set();
foreach (Thread thread in threads)
{
thread.Join();
}
// After the dust settles every thread should see the canonical cached array.
// Any thread that won the race installed its result; all subsequent reads return it.
// We allow up to one outlier per thread (the loser wrote its own array and then the
// winner overwrote — that loser's observed reference is not the canonical one), but
// a fresh call after the threads finish must hit the cache and return the canonical.
service.TryGetDescendantsKeys(root, out IEnumerable<Guid> canonical);
IEnumerable<Guid> canonicalCached = canonical;
// Every observation should equal the canonical cached result by content.
foreach (IEnumerable<Guid> obs in observed)
{
Assert.That(obs, Is.EqualTo(canonicalCached));
}
// And a second canonical call must reuse the same instance.
service.TryGetDescendantsKeys(root, out IEnumerable<Guid> canonicalAgain);
Assert.That(canonicalAgain, Is.SameAs(canonicalCached));
}
}
@@ -0,0 +1,86 @@
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Models.Navigation;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services.Navigation;
/// <summary>
/// Tests for the per-<c>NavigationNode</c> ordered-children cache (§2.4) as exercised
/// through the service-level mutation methods on <see cref="ContentNavigationServiceBase{TContentType, TContentTypeService}"/>.
/// <see cref="NavigationNode"/>-level behaviour is covered by <c>NavigationNodeTests</c>;
/// the per-snapshot descendants cache (§2.5) is covered by <c>ContentNavigationDescendantsCacheTests</c>.
/// This fixture documents that service-level <c>Move</c>, <c>Add</c>, and <c>UpdateSortOrder</c>
/// correctly invalidate the per-parent ordered-children cache on every affected parent.
/// </summary>
[TestFixture]
public class ContentNavigationOrderedChildrenCacheTests
{
private static DocumentNavigationService CreateService() =>
new(
Mock.Of<ICoreScopeProvider>(),
Mock.Of<INavigationRepository>(),
Mock.Of<IContentTypeService>());
[Test]
public void TryGetChildrenKeys_AfterMove_InvalidatesSourceAndTargetParentOrderedChildrenCache()
{
// Move() drives RemoveChild on the source parent and AddChild on the target parent;
// both calls auto-invalidate the per-parent ordered-children cache. This test targets that
// behaviour at the service-level entry point so a future refactor of Move() that bypasses
// AddChild/RemoveChild would fail loudly here rather than silently leaving stale order.
DocumentNavigationService service = CreateService();
Guid contentType = Guid.NewGuid();
Guid sourceParent = Guid.NewGuid();
Guid targetParent = Guid.NewGuid();
Guid moveable = Guid.NewGuid();
Guid targetSibling = Guid.NewGuid();
service.Add(sourceParent, contentType);
service.Add(targetParent, contentType);
service.Add(moveable, contentType, sourceParent);
service.Add(targetSibling, contentType, targetParent);
// Prime both parents' ordered-children caches.
Assume.That(service.TryGetChildrenKeys(sourceParent, out IEnumerable<Guid> sourcePrimed), Is.True);
Assume.That(sourcePrimed, Is.EqualTo(new[] { moveable }));
Assume.That(service.TryGetChildrenKeys(targetParent, out IEnumerable<Guid> targetPrimed), Is.True);
Assume.That(targetPrimed, Is.EqualTo(new[] { targetSibling }));
service.Move(moveable, targetParent);
Assert.That(service.TryGetChildrenKeys(sourceParent, out IEnumerable<Guid> sourceAfter), Is.True);
Assert.That(service.TryGetChildrenKeys(targetParent, out IEnumerable<Guid> targetAfter), Is.True);
Assert.Multiple(() =>
{
Assert.That(sourceAfter, Is.Empty, "Source parent ordered-children cache must drop the moved child.");
Assert.That(targetAfter, Does.Contain(moveable), "Target parent ordered-children cache must include the moved child.");
Assert.That(targetAfter, Does.Contain(targetSibling), "Target parent must still see its pre-existing children.");
});
}
[Test]
public void TryGetChildrenKeys_AfterMoveToRoot_InvalidatesSourceParentOrderedChildrenCache()
{
// Move() with null target moves to root, so only the source parent's ordered-children
// cache needs invalidation. Targets the asymmetric path: root has no NavigationNode, so
// there's no "target parent" cache to worry about.
DocumentNavigationService service = CreateService();
Guid contentType = Guid.NewGuid();
Guid sourceParent = Guid.NewGuid();
Guid moveable = Guid.NewGuid();
service.Add(sourceParent, contentType);
service.Add(moveable, contentType, sourceParent);
Assume.That(service.TryGetChildrenKeys(sourceParent, out IEnumerable<Guid> sourcePrimed), Is.True);
Assume.That(sourcePrimed, Is.EqualTo(new[] { moveable }));
service.Move(moveable, targetParentKey: null);
Assert.That(service.TryGetChildrenKeys(sourceParent, out IEnumerable<Guid> sourceAfter), Is.True);
Assert.That(sourceAfter, Is.Empty);
}
}
@@ -11,7 +11,7 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services.PublishStatus;
[TestFixture]
public partial class PublishedContentStatusFilteringServiceTests
public class PublishedContentStatusFilteringServiceTests
{
[Test]
public void FilterAvailable_Invariant_ForNonPreview_YieldsPublishedItems()
@@ -303,6 +303,110 @@ public partial class PublishedContentStatusFilteringServiceTests
}
}
[Test]
public void FilterAvailable_IsLazy_TakeOnlyFetchesRequestedItemsFromCache()
{
var (sut, items, cacheMock, _) = SetupCounting(forPreview: true);
IPublishedContent[] taken = sut.FilterAvailable(items.Keys, null).Take(3).ToArray();
Assert.AreEqual(3, taken.Length);
cacheMock.Verify(c => c.GetById(true, It.IsAny<Guid>()), Times.Exactly(3));
}
[Test]
public void FilterAvailable_IsLazy_FirstOrDefaultOnlyFetchesOneItemFromCache()
{
var (sut, items, cacheMock, _) = SetupCounting(forPreview: true);
IPublishedContent? first = sut.FilterAvailable(items.Keys, null).FirstOrDefault();
Assert.IsNotNull(first);
cacheMock.Verify(c => c.GetById(true, It.IsAny<Guid>()), Times.Once);
}
[Test]
public void FilterAvailable_IsLazy_NonPreviewTakeShortCircuitsPublishStatusQueries()
{
var (sut, items, cacheMock, statusMock) = SetupCounting(forPreview: false);
IPublishedContent[] taken = sut.FilterAvailable(items.Keys, null).Take(3).ToArray();
Assert.AreEqual(3, taken.Length);
// GetById is reached only for keys that pass the publish-status filter, so exactly 3 cache lookups.
cacheMock.Verify(c => c.GetById(false, It.IsAny<Guid>()), Times.Exactly(3));
// Publish status must short-circuit before the full candidate set is enumerated.
statusMock.Verify(
s => s.IsDocumentPublished(It.IsAny<Guid>(), It.IsAny<string>()),
Times.AtMost(items.Count - 1));
}
[Test]
public void FilterAvailable_IsLazy_FullEnumerationFetchesAllItemsFromCache()
{
var (sut, items, cacheMock, _) = SetupCounting(forPreview: true);
IPublishedContent[] all = sut.FilterAvailable(items.Keys, null).ToArray();
Assert.AreEqual(items.Count, all.Length);
cacheMock.Verify(c => c.GetById(true, It.IsAny<Guid>()), Times.Exactly(items.Count));
}
// sets up invariant data with mocks exposed so tests can verify per-call counts.
// - 10 invariant documents with IDs 0 through 9
// - even IDs are published, odd are not (relevant only for non-preview)
private (
PublishedContentStatusFilteringService Service,
Dictionary<Guid, IPublishedContent> Items,
Mock<IPublishedContentCache> CacheMock,
Mock<IPublishStatusQueryService> StatusMock)
SetupCounting(bool forPreview)
{
var contentType = new Mock<IPublishedContentType>();
contentType.SetupGet(c => c.Variations).Returns(ContentVariation.Nothing);
var items = new Dictionary<Guid, IPublishedContent>();
for (var i = 0; i < 10; i++)
{
var content = new Mock<IPublishedContent>();
var key = Guid.NewGuid();
content.SetupGet(c => c.Key).Returns(key);
content.SetupGet(c => c.ContentType).Returns(contentType.Object);
content.SetupGet(c => c.Cultures).Returns(new Dictionary<string, PublishedCultureInfo>());
content.SetupGet(c => c.Id).Returns(i);
items[key] = content.Object;
}
var cacheMock = new Mock<IPublishedContentCache>();
cacheMock
.Setup(c => c.GetById(forPreview, It.IsAny<Guid>()))
.Returns((bool _, Guid key) => items.TryGetValue(key, out IPublishedContent? item) ? item : null);
var statusMock = new Mock<IPublishStatusQueryService>();
statusMock
.Setup(s => s.IsDocumentPublished(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns((Guid key, string _) => items.TryGetValue(key, out IPublishedContent? item) && item.Id % 2 == 0);
statusMock
.Setup(s => s.HasPublishedAncestorPath(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns(true);
var previewService = new Mock<IPreviewService>();
previewService.Setup(p => p.IsInPreview()).Returns(forPreview);
var variationContextAccessor = new Mock<IVariationContextAccessor>();
variationContextAccessor.SetupGet(v => v.VariationContext).Returns(new VariationContext(null));
var service = new PublishedContentStatusFilteringService(
variationContextAccessor.Object,
statusMock.Object,
previewService.Object,
cacheMock.Object);
return (service, items, cacheMock, statusMock);
}
// sets up invariant test data:
// - 10 documents with IDs 0 through 9
// - even IDs (0, 2, ...) are published, odd are unpublished
@@ -0,0 +1,69 @@
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Services.Navigation;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services.PublishStatus;
[TestFixture]
public class PublishedMediaStatusFilteringServiceTests
{
[Test]
public void FilterAvailable_IsLazy_TakeOnlyFetchesRequestedItemsFromCache()
{
var (sut, items, cacheMock) = SetupCounting();
IPublishedContent[] taken = sut.FilterAvailable(items.Keys, null).Take(3).ToArray();
Assert.AreEqual(3, taken.Length);
cacheMock.Verify(c => c.GetById(It.IsAny<Guid>()), Times.Exactly(3));
}
[Test]
public void FilterAvailable_IsLazy_FirstOrDefaultOnlyFetchesOneItemFromCache()
{
var (sut, items, cacheMock) = SetupCounting();
IPublishedContent? first = sut.FilterAvailable(items.Keys, null).FirstOrDefault();
Assert.IsNotNull(first);
cacheMock.Verify(c => c.GetById(It.IsAny<Guid>()), Times.Once);
}
[Test]
public void FilterAvailable_IsLazy_FullEnumerationFetchesAllItemsFromCache()
{
var (sut, items, cacheMock) = SetupCounting();
IPublishedContent[] all = sut.FilterAvailable(items.Keys, null).ToArray();
Assert.AreEqual(items.Count, all.Length);
cacheMock.Verify(c => c.GetById(It.IsAny<Guid>()), Times.Exactly(items.Count));
}
private (
PublishedMediaStatusFilteringService Service,
Dictionary<Guid, IPublishedContent> Items,
Mock<IPublishedMediaCache> CacheMock)
SetupCounting()
{
var items = new Dictionary<Guid, IPublishedContent>();
for (var i = 0; i < 10; i++)
{
var content = new Mock<IPublishedContent>();
var key = Guid.NewGuid();
content.SetupGet(c => c.Key).Returns(key);
content.SetupGet(c => c.Id).Returns(i);
items[key] = content.Object;
}
var cacheMock = new Mock<IPublishedMediaCache>();
cacheMock
.Setup(c => c.GetById(It.IsAny<Guid>()))
.Returns((Guid key) => items.TryGetValue(key, out IPublishedContent? item) ? item : null);
var service = new PublishedMediaStatusFilteringService(cacheMock.Object);
return (service, items, cacheMock);
}
}
@@ -0,0 +1,89 @@
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
using Umbraco.Cms.Infrastructure.HybridCache;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.PublishedCache.HybridCache;
/// <summary>
/// Tests that <see cref="DocumentCache.GetById(bool, Guid)"/> consults
/// <see cref="IDocumentCacheService.TryGetCached"/> first and skips the async fallback
/// entirely when the L0 cache has the requested item.
/// </summary>
/// <remarks>
/// The async path (<c>GetByKeyAsync</c>) is the slow case — distributed cache + database +
/// factory work. On a warm site we want to confirm the per-key sync calls inside
/// <c>FilterAvailable</c>'s lazy chain take the fast path and never spin up an async state
/// machine. This fixture asserts that contract by mocking the service and verifying call
/// counts.
/// </remarks>
[TestFixture]
public class DocumentCacheSyncFastPathTests
{
[Test]
public void GetById_HitsL0ViaTryGetCached_ReturnsCachedAndSkipsAsync()
{
IPublishedContent expected = Mock.Of<IPublishedContent>();
var cacheService = new Mock<IDocumentCacheService>();
cacheService
.Setup(s => s.TryGetCached(It.IsAny<Guid>(), It.IsAny<bool>(), out It.Ref<IPublishedContent?>.IsAny))
.Returns(new TryGetCachedDelegate((Guid _, bool _, out IPublishedContent? content) =>
{
content = expected;
return true;
}));
DocumentCache cache = CreateCache(cacheService);
IPublishedContent? actual = cache.GetById(preview: false, contentId: Guid.NewGuid());
Assert.That(actual, Is.SameAs(expected));
cacheService.Verify(
s => s.GetByKeyAsync(It.IsAny<Guid>(), It.IsAny<bool?>()),
Times.Never,
"Async path should not run when TryGetCached hits");
}
[Test]
public void GetById_MissesL0_FallsThroughToAsyncPath()
{
IPublishedContent expected = Mock.Of<IPublishedContent>();
var cacheService = new Mock<IDocumentCacheService>();
cacheService
.Setup(s => s.TryGetCached(It.IsAny<Guid>(), It.IsAny<bool>(), out It.Ref<IPublishedContent?>.IsAny))
.Returns(new TryGetCachedDelegate((Guid _, bool _, out IPublishedContent? content) =>
{
content = null;
return false;
}));
cacheService
.Setup(s => s.GetByKeyAsync(It.IsAny<Guid>(), It.IsAny<bool?>()))
.ReturnsAsync(expected);
DocumentCache cache = CreateCache(cacheService);
IPublishedContent? actual = cache.GetById(preview: false, contentId: Guid.NewGuid());
Assert.That(actual, Is.SameAs(expected));
cacheService.Verify(
s => s.GetByKeyAsync(It.IsAny<Guid>(), It.IsAny<bool?>()),
Times.Once,
"Async path runs exactly once on TryGetCached miss");
}
private static DocumentCache CreateCache(Mock<IDocumentCacheService> cacheService)
=> new(
cacheService.Object,
Mock.Of<IPublishedContentTypeCache>(),
Mock.Of<IDocumentNavigationQueryService>(),
Mock.Of<IDocumentUrlService>(),
new Lazy<IPublishedUrlProvider>(() => Mock.Of<IPublishedUrlProvider>()));
// Moq cannot bind directly to ref / out parameters in the lambda overload, so we
// declare a delegate that matches the TryGetCached signature and pass it explicitly.
private delegate bool TryGetCachedDelegate(Guid key, bool preview, out IPublishedContent? content);
}
@@ -0,0 +1,195 @@
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.HybridCache;
using Umbraco.Cms.Infrastructure.Serialization;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.PublishedCache.HybridCache;
/// <summary>
/// Tests for <see cref="PublishedContent"/>, focused on the lazy thread-safe property
/// initialization.
/// </summary>
/// <remarks>
/// Functional correctness of <c>Properties</c> / <c>GetProperty</c> is already covered by integration tests
/// through <c>PublishedContentFactory</c>; this fixture verifies the lazy behaviour and thread-safety contract
/// introduced by the move from eager construction to <c>Interlocked.CompareExchange</c>-guarded lazy build.
/// </remarks>
[TestFixture]
public class PublishedContentTests
{
[Test]
public void Properties_PropertyCountMatchesContentType()
{
PublishedContent content = CreatePublishedContent(propertyCount: 5);
Assert.That(content.Properties.Count(), Is.EqualTo(5));
}
[Test]
public void Properties_AliasesMatchContentTypePropertyTypes()
{
PublishedContent content = CreatePublishedContent(propertyCount: 3);
var aliases = content.Properties.Select(p => p.Alias).ToArray();
Assert.That(aliases, Is.EqualTo(new[] { "prop0", "prop1", "prop2" }));
}
[Test]
public void GetProperty_KnownAlias_ReturnsPropertyWithSameAlias()
{
PublishedContent content = CreatePublishedContent(propertyCount: 3);
IPublishedProperty? property = content.GetProperty("prop1");
Assert.That(property, Is.Not.Null);
Assert.That(property!.Alias, Is.EqualTo("prop1"));
}
[Test]
public void GetProperty_UnknownAlias_ReturnsNull()
{
PublishedContent content = CreatePublishedContent(propertyCount: 3);
Assert.That(content.GetProperty("nonexistent"), Is.Null);
}
[Test]
public void GetProperty_RepeatedCalls_ReturnSamePropertyInstance()
{
PublishedContent content = CreatePublishedContent(propertyCount: 3);
IPublishedProperty? first = content.GetProperty("prop0");
IPublishedProperty? second = content.GetProperty("prop0");
// The lazy-init contract: once the property array is built, every caller sees the
// same canonical PublishedProperty instance.
Assert.That(second, Is.SameAs(first));
}
[Test]
public void Properties_ConcurrentFirstAccess_AllThreadsSeeSameInstances()
{
// The race we're guarding against: multiple threads enter EnsureProperties() before
// any has finished BuildProperties(). Without Interlocked.CompareExchange they could
// each store a different array, and a property looked up via thread A could be a
// different instance than the same property looked up via thread B — value-cache
// state on the property would diverge as a result.
const int threadCount = 32;
PublishedContent content = CreatePublishedContent(propertyCount: 5);
var observed = new IPublishedProperty[threadCount][];
var startGate = new ManualResetEventSlim(false);
var threads = new Thread[threadCount];
for (var t = 0; t < threadCount; t++)
{
var localT = t;
threads[t] = new Thread(() =>
{
startGate.Wait();
observed[localT] = content.Properties.ToArray();
});
threads[t].Start();
}
startGate.Set();
foreach (Thread thread in threads)
{
thread.Join();
}
IPublishedProperty[] reference = observed[0];
for (var i = 1; i < threadCount; i++)
{
Assert.That(observed[i].Length, Is.EqualTo(reference.Length));
for (var j = 0; j < reference.Length; j++)
{
// Reference equality: every thread observes the same canonical PublishedProperty
// instances, never a duplicate from a CompareExchange loser.
Assert.That(observed[i][j], Is.SameAs(reference[j]));
}
}
}
private static PublishedContent CreatePublishedContent(int propertyCount)
{
IPublishedModelFactory modelFactory = new NoopPublishedModelFactory();
var converters = new PropertyValueConverterCollection(() => Enumerable.Empty<IPropertyValueConverter>());
var jsonSerializer = new SystemTextConfigurationEditorJsonSerializer(new DefaultJsonSerializerEncoderFactory());
var dataType = new DataType(new VoidEditor(Mock.Of<IDataValueEditorFactory>()), jsonSerializer) { Id = 1 };
var dataTypeServiceMock = new Mock<IDataTypeService>();
// PublishedContentTypeFactory.GetDataType uses the synchronous GetAll() overload
// (the obsolete params int[] one), so we must set up that one rather than GetAllAsync.
#pragma warning disable CS0618
dataTypeServiceMock.Setup(x => x.GetAll()).Returns(new[] { dataType });
#pragma warning restore CS0618
var typeFactory = new PublishedContentTypeFactory(modelFactory, converters, dataTypeServiceMock.Object);
IEnumerable<IPublishedPropertyType> CreatePropertyTypes(IPublishedContentType ct)
{
for (var i = 0; i < propertyCount; i++)
{
yield return typeFactory.CreatePropertyType(ct, $"prop{i}", dataType.Id, ContentVariation.Nothing);
}
}
IPublishedContentType contentType = new PublishedContentType(
Guid.NewGuid(),
1000,
"test",
PublishedItemType.Content,
Enumerable.Empty<string>(),
CreatePropertyTypes,
ContentVariation.Nothing);
var properties = new Dictionary<string, PropertyData[]>();
for (var i = 0; i < propertyCount; i++)
{
properties[$"prop{i}"] =
[
new PropertyData
{
Culture = string.Empty,
Segment = string.Empty,
Value = $"value-{i}",
},
];
}
var contentData = new ContentData(
"Test",
null,
1,
DateTime.UtcNow,
-1,
0,
true,
properties,
null);
var contentNode = new ContentNode(
1,
Guid.NewGuid(),
0,
DateTime.UtcNow,
-1,
contentType,
draftData: null,
publishedData: contentData);
return new PublishedContent(
contentNode,
preview: false,
new ElementsDictionaryAppCache(),
new ThreadCultureVariationContextAccessor(),
Mock.Of<IPropertyRenderingContextAccessor>());
}
}