Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65e7a5bb6 | ||
|
|
b57783de9d | ||
|
|
e0a5682c11 | ||
|
|
ef14310c5c | ||
|
|
51b190c1ea | ||
|
|
d2ec817c1b | ||
|
|
dc95a56290 | ||
|
|
afe90992f2 | ||
|
|
790d6a4b8d | ||
|
|
c94b346d1e | ||
|
|
f9d480f882 |
@@ -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;
|
||||
}
|
||||
}
|
||||
+400
@@ -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));
|
||||
}
|
||||
}
|
||||
+86
@@ -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);
|
||||
}
|
||||
}
|
||||
+105
-1
@@ -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
|
||||
|
||||
+69
@@ -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);
|
||||
}
|
||||
}
|
||||
+89
@@ -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);
|
||||
}
|
||||
+195
@@ -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>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user