Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/Umbraco.Core/PublishedCache/IDocumentCacheService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,34 @@
/// <returns>The published content, or <c>null</c> if not found.</returns>
Task<IPublishedContent?> GetByIdAsync(int id, bool? preview = null);

/// <summary>
/// Gets multiple published content items by their unique keys, fetching any not already cached
/// from the database in a single batched query rather than one at a time.
/// </summary>
/// <param name="keys">The unique keys of the content to retrieve.</param>
/// <param name="preview">Optional value indicating whether to include unpublished content. If <c>null</c>, uses the default preview setting.</param>
/// <returns>The published content items that exist, in the same order as <paramref name="keys"/> (missing items omitted).</returns>
/// <remarks>
/// Used to materialise sets of keys (e.g. children/descendants) without the per-item database
/// round trip and scope of repeated <see cref="GetByKeyAsync"/> calls when the cache is cold.
/// The default implementation falls back to per-key retrieval so existing implementations keep working.
/// </remarks>
// TODO (V19): Remove the default implementation and reference to it in the remarks.

Check warning on line 43 in src/Umbraco.Core/PublishedCache/IDocumentCacheService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this 'TODO' comment.

See more on https://sonarcloud.io/project/issues?id=umbraco_Umbraco-CMS&issues=AZ9RwAaUWw4wwGCX9iTe&open=AZ9RwAaUWw4wwGCX9iTe&pullRequest=23358
async Task<IReadOnlyList<IPublishedContent>> GetByKeysAsync(IReadOnlyCollection<Guid> keys, bool? preview = null)
{
var result = new List<IPublishedContent>(keys.Count);
foreach (Guid key in keys)
{
IPublishedContent? content = await GetByKeyAsync(key, preview);
if (content is not null)
{
result.Add(content);
}
}

return result;
}

/// <summary>
/// Attempts to retrieve a content item from the in-memory converted-content cache without
/// touching the distributed cache or the database.
Expand Down
27 changes: 27 additions & 0 deletions src/Umbraco.Core/PublishedCache/IMediaCacheService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,33 @@
/// <returns>The published media content, or <c>null</c> if not found.</returns>
Task<IPublishedContent?> GetByIdAsync(int id);

/// <summary>
/// Gets multiple published media items by their unique keys, fetching any not already cached
/// from the database in a single batched query rather than one at a time.
/// </summary>
/// <param name="keys">The unique keys of the media to retrieve.</param>
/// <returns>The published media items that exist, in the same order as <paramref name="keys"/> (missing items omitted).</returns>
/// <remarks>
/// Used to materialise sets of keys (e.g. children/descendants) without the per-item database
/// round trip and scope of repeated <see cref="GetByKeyAsync"/> calls when the cache is cold.
/// The default implementation falls back to per-key retrieval so existing implementations keep working.
/// </remarks>
// TODO (V19): Remove the default implementation and reference to it in the remarks.

Check warning on line 40 in src/Umbraco.Core/PublishedCache/IMediaCacheService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this 'TODO' comment.

See more on https://sonarcloud.io/project/issues?id=umbraco_Umbraco-CMS&issues=AZ9RwAalWw4wwGCX9iTf&open=AZ9RwAalWw4wwGCX9iTf&pullRequest=23358
async Task<IReadOnlyList<IPublishedContent>> GetByKeysAsync(IReadOnlyCollection<Guid> keys)
{
var result = new List<IPublishedContent>(keys.Count);
foreach (Guid key in keys)
{
IPublishedContent? content = await GetByKeyAsync(key);
if (content is not null)
{
result.Add(content);
}
}

return result;
}

/// <summary>
/// Attempts to retrieve a media item from the in-memory converted-content cache without
/// touching the distributed cache or the database.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
using Umbraco.Cms.Core.Models.PublishedContent;

namespace Umbraco.Cms.Core.Services.Navigation;

/// <summary>
/// Attempts to serve a published content/media item from the synchronous in-memory (L0) cache.
/// </summary>
/// <param name="key">The unique key of the item.</param>
/// <param name="content">The cached item when a hit was made; otherwise <c>null</c>.</param>
/// <returns><c>true</c> if served from the in-memory cache; otherwise <c>false</c>.</returns>
internal delegate bool TryGetCachedDelegate(Guid key, out IPublishedContent? content);

/// <summary>
/// Materialises a set of keys that missed the in-memory cache, batching any database read behind
/// a single query rather than one round trip per key.
/// </summary>
/// <param name="keys">The keys to materialise (already known to have missed L0).</param>
/// <returns>The items that exist, in the same order as <paramref name="keys"/> (missing items omitted).</returns>
internal delegate IReadOnlyList<IPublishedContent> MaterialiseMissesDelegate(IReadOnlyList<Guid> keys);

/// <summary>
/// Lazily materialises a sequence of content/media keys into <see cref="IPublishedContent"/>, pulling
/// keys in growing chunks so that short-circuiting consumers stay cheap while a full enumeration of a
/// cold set collapses its database access into a handful of batched reads.
/// </summary>
/// <remarks>
/// <para>
/// For each chunk a synchronous L0 pass (<see cref="TryGetCachedDelegate"/>) runs first; a chunk whose
/// items are all cached is yielded without any asynchronous or batched work — identical to the per-key
/// warm path. Only when a chunk contains L0 misses is <see cref="MaterialiseMissesDelegate"/> invoked
/// once for those misses (which does the L1/L2 probe and the single batched database read).
/// </para>
/// <para>
/// Chunk size starts at 1 and doubles up to <see cref="MaxChunkSize"/>. So a <c>FirstOrDefault()</c>
/// materialises a single item, a full enumeration of N items uses O(log N + N / cap) chunks, and cold
/// over-fetch on a predicate short-circuit is bounded to roughly twice what the consumer draws.
/// </para>
/// </remarks>
internal static class ChunkedPublishedContentEnumerator
{
private const int MaxChunkSize = 256;

/// <summary>
/// Lazily materialises <paramref name="keys"/> into <see cref="IPublishedContent"/> in growing chunks,
/// serving in-memory (L0) hits synchronously and batching the database read for the rest.
/// </summary>
/// <param name="keys">The keys to materialise, in the order they should be yielded.</param>
/// <param name="tryGetCached">The synchronous L0 probe.</param>
/// <param name="materialiseMisses">The batched materialiser for keys that missed L0.</param>
/// <param name="predicate">An optional post-materialisation filter (e.g. a culture check); <c>null</c> to include all.</param>
/// <returns>The resolved items, in input order, with missing and filtered-out items omitted.</returns>
public static IEnumerable<IPublishedContent> Enumerate(
IEnumerable<Guid> keys,
TryGetCachedDelegate tryGetCached,
MaterialiseMissesDelegate materialiseMisses,
Func<IPublishedContent, bool>? predicate)
{
var chunkSize = 1;
var buffer = new List<Guid>(MaxChunkSize);

using IEnumerator<Guid> enumerator = keys.GetEnumerator();

while (FillChunk(enumerator, chunkSize, buffer))
{
foreach (IPublishedContent item in ResolveChunk(buffer, tryGetCached, materialiseMisses))

Check warning on line 65 in src/Umbraco.Core/Services/PublishStatus/ChunkedPublishedContentEnumerator.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Loops should be simplified using the "Where" LINQ method

See more on https://sonarcloud.io/project/issues?id=umbraco_Umbraco-CMS&issues=AZ9SEyc3Df33n6NsZhZx&open=AZ9SEyc3Df33n6NsZhZx&pullRequest=23358
{
if (predicate is null || predicate(item))
{
yield return item;
}
}

// A short read means the source is exhausted, so there is no further chunk to grow into.
if (buffer.Count < chunkSize)
{
yield break;
}

chunkSize = Math.Min(chunkSize * 2, MaxChunkSize);
}
}

/// <summary>
/// Refills the reusable buffer with up to <paramref name="chunkSize"/> keys from the source.
/// </summary>
/// <returns><c>true</c> if the buffer holds at least one key; <c>false</c> once the source is exhausted.</returns>
private static bool FillChunk(IEnumerator<Guid> enumerator, int chunkSize, List<Guid> buffer)
{
buffer.Clear();
while (buffer.Count < chunkSize && enumerator.MoveNext())
{
buffer.Add(enumerator.Current);
}

return buffer.Count > 0;
}

/// <summary>
/// Resolves one chunk to its items in buffer order: L0 hits are served directly and the rest are
/// materialised in a single batched call. An all-hit chunk never invokes the batched materialiser.
/// </summary>
private static List<IPublishedContent> ResolveChunk(
List<Guid> chunk,
TryGetCachedDelegate tryGetCached,
MaterialiseMissesDelegate materialiseMisses)
{
var slots = new IPublishedContent?[chunk.Count];
List<Guid>? misses = null;
for (var i = 0; i < chunk.Count; i++)
{
if (tryGetCached(chunk[i], out IPublishedContent? cached) && cached is not null)
{
slots[i] = cached;
}
else
{
(misses ??= []).Add(chunk[i]);
}
}

if (misses is not null)
{
PlaceMisses(chunk, slots, materialiseMisses(misses));
}

var resolved = new List<IPublishedContent>(chunk.Count);
foreach (IPublishedContent? item in slots)

Check warning on line 127 in src/Umbraco.Core/Services/PublishStatus/ChunkedPublishedContentEnumerator.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Loops should be simplified using the "Where" LINQ method

See more on https://sonarcloud.io/project/issues?id=umbraco_Umbraco-CMS&issues=AZ9SEyc4Df33n6NsZhZy&open=AZ9SEyc4Df33n6NsZhZy&pullRequest=23358
{
if (item is not null)
{
resolved.Add(item);
}
}

return resolved;
}

Check warning on line 136 in src/Umbraco.Core/Services/PublishStatus/ChunkedPublishedContentEnumerator.cs

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (v17/dev)

❌ New issue: Bumpy Road Ahead

ResolveChunk has 2 blocks with nested conditional logic. Any nesting of 2 or deeper is considered. Threshold is 2 blocks per function The Bumpy Road code smell is a function that contains multiple chunks of nested conditional logic. The deeper the nesting and the more bumps, the lower the code health.

/// <summary>
/// Slots the batch-materialised items back into their input positions, keyed by content key.
/// </summary>
private static void PlaceMisses(List<Guid> chunk, IPublishedContent?[] slots, IReadOnlyList<IPublishedContent> fetched)
{
if (fetched.Count == 0)
{
return;
}

var byKey = new Dictionary<Guid, IPublishedContent>(fetched.Count);
foreach (IPublishedContent item in fetched)
{
byKey[item.Key] = item;
}

for (var i = 0; i < chunk.Count; i++)
{
if (slots[i] is null && byKey.TryGetValue(chunk[i], out IPublishedContent? item))
{
slots[i] = item;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
private readonly IPublishStatusQueryService _publishStatusQueryService;
private readonly IPreviewService _previewService;
private readonly IPublishedContentCache _publishedContentCache;
private readonly IDocumentCacheService _documentCacheService;

/// <summary>
/// Initializes a new instance of the <see cref="PublishedContentStatusFilteringService"/> class.
Expand All @@ -26,16 +27,19 @@
/// <param name="publishStatusQueryService">The service for querying document publish status.</param>
/// <param name="previewService">The service for determining if the current request is in preview mode.</param>
/// <param name="publishedContentCache">The published content cache for retrieving content items.</param>
/// <param name="documentCacheService">The document cache service used to materialise candidate keys in batches.</param>
public PublishedContentStatusFilteringService(
IVariationContextAccessor variationContextAccessor,
IPublishStatusQueryService publishStatusQueryService,
IPreviewService previewService,
IPublishedContentCache publishedContentCache)
IPublishedContentCache publishedContentCache,
IDocumentCacheService documentCacheService)
{
_variationContextAccessor = variationContextAccessor;
_publishStatusQueryService = publishStatusQueryService;
_previewService = previewService;
_publishedContentCache = publishedContentCache;
_documentCacheService = documentCacheService;
}

/// <inheritdoc />
Expand All @@ -50,16 +54,26 @@
}

var preview = _previewService.IsInPreview();
candidateKeys = preview

// Kept lazy so the publish-status filter is only evaluated for keys actually drawn — preserving
// the short-circuit for .FirstOrDefault() / .Take(n).
IEnumerable<Guid> keys = preview
? candidateKeysAsArray
: candidateKeysAsArray.Where(key =>
_publishStatusQueryService.IsDocumentPublished(key, culture)
&& _publishStatusQueryService.HasPublishedAncestorPath(key, culture));

// 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);
// Materialise in growing chunks: an all-L0-hit chunk stays fully synchronous (no async, no
// batch), while a cold set collapses its database access into batched reads. Returned lazily
// so short-circuiting consumers still exit early; callers that enumerate more than once should
// buffer the result themselves (.ToList() / .ToArray()).
return ChunkedPublishedContentEnumerator.Enumerate(
keys,
(Guid key, out IPublishedContent? content) => _documentCacheService.TryGetCached(key, preview, out content),
misses => _documentCacheService.GetByKeysAsync(misses, preview).GetAwaiter().GetResult(),
content => culture == Constants.System.InvariantCulture
|| content.ContentType.VariesByCulture() is false
|| content.Cultures.ContainsKey(culture));

Check warning on line 76 in src/Umbraco.Core/Services/PublishStatus/PublishedContentStatusFilteringService.cs

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (v17/dev)

❌ New issue: Complex Method

FilterAvailable has a cyclomatic complexity of 9, threshold = 9 This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.
}

/// <inheritdoc />
Expand All @@ -68,19 +82,4 @@
var preview = _previewService.IsInPreview();
return candidateKeys.Select(key => _publishedContentCache.GetById(preview, key)).WhereNotNull();
}

/// <summary>
/// Filters content items to include only those that are invariant, have the requested culture, or when all cultures are requested.
/// </summary>
/// <param name="keys">The content keys to filter.</param>
/// <param name="culture">The requested culture.</param>
/// <param name="preview">Whether the request is in preview mode.</param>
/// <returns>A collection of <see cref="IPublishedContent"/> items that match the culture criteria.</returns>
private IEnumerable<IPublishedContent> WhereIsInvariantOrHasCultureOrRequestedAllCultures(IEnumerable<Guid> keys, string culture, bool preview)
=> keys
.Select(key => _publishedContentCache.GetById(preview, key))
.WhereNotNull()
.Where(content => culture == Constants.System.InvariantCulture
|| content.ContentType.VariesByCulture() is false
|| content.Cultures.ContainsKey(culture));
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,32 @@ namespace Umbraco.Cms.Core.Services.Navigation;
internal sealed class PublishedMediaStatusFilteringService : IPublishedMediaStatusFilteringService
{
private readonly IPublishedMediaCache _publishedMediaCache;
private readonly IMediaCacheService _mediaCacheService;

/// <summary>
/// Initializes a new instance of the <see cref="PublishedMediaStatusFilteringService"/> class.
/// </summary>
/// <param name="publishedMediaCache">The published media cache for retrieving media items.</param>
public PublishedMediaStatusFilteringService(IPublishedMediaCache publishedMediaCache)
=> _publishedMediaCache = publishedMediaCache;
/// <param name="mediaCacheService">The media cache service used to materialise candidate keys in batches.</param>
public PublishedMediaStatusFilteringService(IPublishedMediaCache publishedMediaCache, IMediaCacheService mediaCacheService)
{
_publishedMediaCache = publishedMediaCache;
_mediaCacheService = mediaCacheService;
}

/// <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()).
/// Materialised in growing chunks: an all-L0-hit chunk stays fully synchronous, while a cold set
/// collapses its database access into batched reads. 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();
=> ChunkedPublishedContentEnumerator.Enumerate(
candidateKeys,
_mediaCacheService.TryGetCached,
misses => _mediaCacheService.GetByKeysAsync(misses).GetAwaiter().GetResult(),
predicate: null);

/// <inheritdoc />
public IEnumerable<IPublishedContent> Unfiltered(IEnumerable<Guid> candidateKeys)
Expand Down
Loading
Loading