Compare commits

...
21 changed files with 813 additions and 27 deletions
@@ -0,0 +1,43 @@
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Reports the approximate size of an in-memory cache, for diagnostics and observability.
/// </summary>
/// <remarks>
/// Implemented by in-memory caches whose footprint scales with the size of the content tree, so their
/// retained entry count can be logged (e.g. by a periodic diagnostics job) during full-tree operations
/// such as reindexing or crawling the published site.
/// <para>
/// The reported value is an approximate <em>entry count</em>, not a byte measurement: per-entry size
/// varies widely, so the count is intended as a <em>trend</em> signal (a count that grows during a
/// tree-walk and never falls indicates unbounded retention) and for <em>attribution</em> (which cache is
/// largest when the process heap grows), rather than as an absolute memory figure. Absolute bytes are
/// obtained from process-level totals (managed heap / working set) and a GC dump. The count is read
/// without locking.
/// </para>
/// </remarks>
public interface IMemoryCacheSizeReporter
{
/// <summary>
/// Gets a human-readable name identifying the cache in diagnostic output.
/// </summary>
string CacheName { get; }
/// <summary>
/// Gets the approximate number of entries currently retained in the cache.
/// </summary>
/// <returns>The approximate entry count.</returns>
long GetApproximateCount();
/// <summary>
/// Gets an approximate retained size of the cache in bytes, or <c>null</c> when the cache cannot be
/// cheaply sized.
/// </summary>
/// <remarks>
/// Where provided, this is a coarse estimate (underlying content / structural size, not a precise
/// managed-heap measurement) for the same trend/attribution purpose as the entry count. Absolute bytes
/// come from a GC dump.
/// </remarks>
/// <returns>The approximate size in bytes, or <c>null</c> if not available.</returns>
long? GetApproximateBytes() => null;
}
@@ -0,0 +1,39 @@
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Estimates the total size of a large collection by sizing a bounded sample and extrapolating across the
/// full count, so a per-tick size diagnostic does not pay an O(n) cost on very large caches.
/// </summary>
internal static class SampledSizeEstimator
{
/// <summary>
/// Sizes up to <paramref name="maxSample" /> items from <paramref name="items" /> and scales the sampled
/// average across <paramref name="count" />.
/// </summary>
/// <typeparam name="T">The item type.</typeparam>
/// <param name="count">The total number of items in the collection.</param>
/// <param name="items">The items to sample (enumerated lazily; only the first <paramref name="maxSample" /> are read).</param>
/// <param name="sizeOf">Returns the approximate size, in bytes, of a single item.</param>
/// <param name="maxSample">The maximum number of items to size before extrapolating.</param>
/// <returns>The extrapolated approximate total size in bytes.</returns>
public static long Estimate<T>(int count, IEnumerable<T> items, Func<T, long> sizeOf, int maxSample = 1000)
{
if (count == 0)
{
return 0;
}
long sampled = 0;
long sampledBytes = 0;
foreach (T item in items)
{
sampledBytes += sizeOf(item);
if (++sampled >= maxSample)
{
break;
}
}
return sampled == 0 ? 0 : count * (sampledBytes / sampled);
}
}
@@ -377,9 +377,11 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<DocumentNavigationService, DocumentNavigationService>();
Services.AddUnique<IDocumentNavigationQueryService>(x => x.GetRequiredService<DocumentNavigationService>());
Services.AddUnique<IDocumentNavigationManagementService>(x => x.GetRequiredService<DocumentNavigationService>());
Services.AddSingleton<IMemoryCacheSizeReporter>(x => x.GetRequiredService<DocumentNavigationService>());
Services.AddUnique<MediaNavigationService, MediaNavigationService>();
Services.AddUnique<IMediaNavigationQueryService>(x => x.GetRequiredService<MediaNavigationService>());
Services.AddUnique<IMediaNavigationManagementService>(x => x.GetRequiredService<MediaNavigationService>());
Services.AddSingleton<IMemoryCacheSizeReporter>(x => x.GetRequiredService<MediaNavigationService>());
Services.AddUnique<PublishStatusService, PublishStatusService>();
Services.AddUnique<IPublishStatusManagementService>(x => x.GetRequiredService<PublishStatusService>());
@@ -453,7 +455,9 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<IElementSwitchValidator, ElementSwitchValidator>();
// Routing
Services.AddUnique<IDocumentUrlService, DocumentUrlService>();
Services.AddUnique<DocumentUrlService, DocumentUrlService>();
Services.AddUnique<IDocumentUrlService>(x => x.GetRequiredService<DocumentUrlService>());
Services.AddSingleton<IMemoryCacheSizeReporter>(x => x.GetRequiredService<DocumentUrlService>());
Services.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, DocumentUrlServiceInitializerNotificationHandler>();
Services.AddUnique<IDocumentUrlAliasService, DocumentUrlAliasService>();
Services.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, DocumentUrlAliasServiceInitializerNotificationHandler>();
@@ -5,6 +5,7 @@ using System.Runtime.CompilerServices;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
@@ -22,7 +23,7 @@ namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Implements <see href="IDocumentUrlService" /> operations for handling document URLs.
/// </summary>
public class DocumentUrlService : IDocumentUrlService
public class DocumentUrlService : IDocumentUrlService, IMemoryCacheSizeReporter
{
/// <summary>
/// Represents the key used to identify the URL generation rebuild operation.
@@ -53,6 +54,33 @@ public class DocumentUrlService : IDocumentUrlService
/// <inheritdoc/>
public bool IsInitialized { get; private set; }
/// <inheritdoc />
public string CacheName => "Document URL segments";
/// <inheritdoc />
public long GetApproximateCount() => _documentUrlCache.Count;
/// <inheritdoc />
// The dictionary is enumerated directly (not via .Values, which snapshot-copies the whole collection).
public long? GetApproximateBytes()
=> SampledSizeEstimator.Estimate(_documentUrlCache.Count, _documentUrlCache, static kvp => EstimateUrlSegmentCacheBytes(kvp.Value));
private static long EstimateUrlSegmentCacheBytes(UrlSegmentCache entry)
{
// UrlCacheKey (struct: Guid + nullable int + bool) + dictionary bucket + the cache object header.
long bytes = 64 + (entry.PrimarySegment.Length * 2L);
if (entry.AlternateSegments is not null)
{
bytes += 24; // array header
foreach (var segment in entry.AlternateSegments)
{
bytes += 16 + ((segment?.Length ?? 0) * 2L);
}
}
return bytes;
}
/// <summary>
/// Struct-based cache key for memory-efficient URL segment caching.
/// Uses LanguageId instead of culture string to reduce memory footprint.
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Navigation;
using Umbraco.Cms.Core.Persistence.Repositories;
@@ -76,6 +77,27 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
private NavigationSnapshot _navigation = new(new(), []);
private NavigationSnapshot _recycleBinNavigation = new(new(), []);
/// <summary>
/// Gets the approximate number of nodes currently held in memory across the active navigation
/// structure and the recycle bin structure, for diagnostics. Each snapshot reference is read once,
/// so the count is consistent per structure even if a rebuild swaps a snapshot concurrently.
/// </summary>
private protected long GetNavigationNodeCount()
=> _navigation.Structure.Count + _recycleBinNavigation.Structure.Count;
/// <summary>
/// Gets an approximate retained size, in bytes, of the navigation structures (active tree plus
/// recycle bin), for diagnostics. Sampled and structural — a coarse estimate, not a heap measurement.
/// </summary>
private protected long GetNavigationApproximateBytes()
=> EstimateStructureBytes(_navigation.Structure) + EstimateStructureBytes(_recycleBinNavigation.Structure);
// The dictionary is enumerated directly (not via .Values, which snapshot-copies the whole collection).
// Per-node estimate: fixed fields (key, content-type key, parent, sort order, lock) + dictionary bucket,
// plus an allowance per child key (held in the child set and the cached ordered array).
private static long EstimateStructureBytes(ConcurrentDictionary<Guid, NavigationNode> structure)
=> SampledSizeEstimator.Estimate(structure.Count, structure, static kvp => 120 + (40L * kvp.Value.Children.Count));
/// <summary>
/// Initializes a new instance of the <see cref="ContentNavigationServiceBase{TContentType, TContentTypeService}"/> class.
/// </summary>
@@ -1,3 +1,4 @@
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
@@ -13,8 +14,17 @@ namespace Umbraco.Cms.Core.Services.Navigation;
/// and implements both <see cref="IDocumentNavigationQueryService"/> and <see cref="IDocumentNavigationManagementService"/>
/// to provide a complete set of navigation operations for document content.
/// </remarks>
internal sealed class DocumentNavigationService : ContentNavigationServiceBase<IContentType, IContentTypeService>, IDocumentNavigationQueryService, IDocumentNavigationManagementService
internal sealed class DocumentNavigationService : ContentNavigationServiceBase<IContentType, IContentTypeService>, IDocumentNavigationQueryService, IDocumentNavigationManagementService, IMemoryCacheSizeReporter
{
/// <inheritdoc />
public string CacheName => "Document navigation";
/// <inheritdoc />
public long GetApproximateCount() => GetNavigationNodeCount();
/// <inheritdoc />
public long? GetApproximateBytes() => GetNavigationApproximateBytes();
/// <summary>
/// Initializes a new instance of the <see cref="DocumentNavigationService"/> class.
/// </summary>
@@ -1,3 +1,4 @@
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
@@ -13,8 +14,17 @@ namespace Umbraco.Cms.Core.Services.Navigation;
/// and implements both <see cref="IMediaNavigationQueryService"/> and <see cref="IMediaNavigationManagementService"/>
/// to provide a complete set of navigation operations for media content.
/// </remarks>
internal sealed class MediaNavigationService : ContentNavigationServiceBase<IMediaType, IMediaTypeService>, IMediaNavigationQueryService, IMediaNavigationManagementService
internal sealed class MediaNavigationService : ContentNavigationServiceBase<IMediaType, IMediaTypeService>, IMediaNavigationQueryService, IMediaNavigationManagementService, IMemoryCacheSizeReporter
{
/// <inheritdoc />
public string CacheName => "Media navigation";
/// <inheritdoc />
public long GetApproximateCount() => GetNavigationNodeCount();
/// <inheritdoc />
public long? GetApproximateBytes() => GetNavigationApproximateBytes();
/// <summary>
/// Initializes a new instance of the <see cref="MediaNavigationService"/> class.
/// </summary>
@@ -0,0 +1,88 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
/// <summary>
/// Periodically logs, at debug level, the approximate entry count of each in-memory cache that
/// scales with the size of the content tree, together with process-level memory totals.
/// </summary>
/// <remarks>
/// Intended as observability for memory usage during full-tree operations (reindexing, crawling the
/// published site). The per-cache counts are a trend/attribution signal — a count that climbs and
/// never falls indicates unbounded retention; the managed-heap and working-set totals give the
/// absolute memory picture. Runs on all servers because memory is per-process, and does nothing unless
/// debug logging is enabled for this job.
/// </remarks>
public class MemoryCacheSizeReportingJob : RecurringBackgroundJobBase
{
private readonly IEnumerable<IMemoryCacheSizeReporter> _reporters;
private readonly ILogger<MemoryCacheSizeReportingJob> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MemoryCacheSizeReportingJob" /> class.
/// </summary>
/// <param name="reporters">The in-memory caches that report their size.</param>
/// <param name="logger">The typed logger.</param>
public MemoryCacheSizeReportingJob(
IEnumerable<IMemoryCacheSizeReporter> reporters,
ILogger<MemoryCacheSizeReportingJob> logger)
: base(TimeSpan.FromMinutes(1))
{
_reporters = reporters;
_logger = logger;
}
/// <summary>
/// Gets the server roles on which this job runs.
/// </summary>
/// <remarks>Runs on all servers, because the reported memory is per-process.</remarks>
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
/// <inheritdoc />
public override Task RunJobAsync(CancellationToken cancellationToken)
{
// Reporting is debug-only; skip the work entirely when debug logging is not enabled.
if (_logger.IsEnabled(LogLevel.Debug) is false)
{
return Task.CompletedTask;
}
foreach (IMemoryCacheSizeReporter reporter in _reporters)
{
cancellationToken.ThrowIfCancellationRequested();
long? approximateBytes = reporter.GetApproximateBytes();
if (approximateBytes is null)
{
_logger.LogDebug(
"In-memory cache size: {CacheName} = {EntryCount} entries (bytes: n/a — use a GC dump)",
reporter.CacheName,
reporter.GetApproximateCount());
}
else
{
_logger.LogDebug(
"In-memory cache size: {CacheName} = {EntryCount} entries (~{ApproximateBytes} bytes)",
reporter.CacheName,
reporter.GetApproximateCount(),
approximateBytes.Value);
}
}
// The reporters above cover the L0 converted-content caches and the baseline structures. The
// HybridCache L1 (Microsoft's in-process tier of ContentCacheNode entries, behind L0) does not
// expose an entry count; capture it from a GC dump when a finer breakdown is needed. The process
// totals below give the overall picture.
_logger.LogDebug(
"Process memory: managed heap {ManagedHeapBytes} bytes, working set {WorkingSetBytes} bytes",
GC.GetTotalMemory(forceFullCollection: false),
Environment.WorkingSet);
return Task.CompletedTask;
}
}
@@ -25,6 +25,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddRecurringBackgroundJob<InstructionProcessJob>();
builder.Services.AddRecurringBackgroundJob<TouchServerJob>();
builder.Services.AddRecurringBackgroundJob<ReportSiteJob>();
builder.Services.AddRecurringBackgroundJob<MemoryCacheSizeReportingJob>();
builder.Services.AddSingleton<IDistributedBackgroundJob, WebhookFiring>();
builder.Services.AddSingleton<IDistributedBackgroundJob, ContentVersionCleanupJob>();
@@ -273,9 +273,41 @@ HybridCache API is experimental (suppressed with `#pragma warning disable EXTEXP
Before returning cached content, verifies ancestor path is published via `_publishStatusQueryService.HasPublishedAncestorPath()`. Returns null if parent unpublished.
### In-Memory Content Cache (DocumentCacheService.cs line 39)
### In-Memory Content Cache (the L0 converted-content cache)
Secondary `ConcurrentDictionary<string, IPublishedContent>` caches converted objects, since `ContentCacheNode` to `IPublishedContent` conversion is expensive.
The converted `IPublishedContent` objects are cached in `ConvertedPublishedContentCache<TKey>`
(`Services/ConvertedPublishedContentCache.cs`), used by `DocumentCacheService` (`<string>`) and
`MediaCacheService` (`<Guid>`), since `ContentCacheNode``IPublishedContent` conversion is expensive.
This is the single insert/remove/clear path for the L0 cache (the seam a later bounded/eviction-aware
implementation slots into), and it tracks both the entry count and an approximate retained byte total.
The cache is currently **unbounded** — only evicted on content change / explicit clear, so walking the
whole published tree (Delivery API crawl, sitemap, warm-up) retains the whole tree's converted form.
Bounding it with a scan-resistant policy is tracked separately; the observability below quantifies it.
### Memory observability
The in-memory structures whose footprint scales with the size of the content tree implement
`IMemoryCacheSizeReporter` (`Umbraco.Cms.Core.Cache`), exposing an approximate retained **entry count** and
(where cheaply derivable) an approximate **byte** estimate:
| Reporter (`CacheName`) | Structure | Byte estimate |
|------------------------|-----------|---------------|
| `Published content (converted, L0)` | `DocumentCacheService` L0 cache | running total of per-entry node-size estimates |
| `Published media (converted, L0)` | `MediaCacheService` L0 cache | running total of per-entry node-size estimates |
| `Document URL segments` | `DocumentUrlService._documentUrlCache` (≈ documents × cultures × draft/published) | sampled structural estimate |
| `Document navigation` / `Media navigation` | the in-memory navigation trees (active + recycle bin) | sampled structural estimate |
`MemoryCacheSizeReportingJob` (a recurring job, all server roles, 1-minute period) logs each count and byte
estimate plus `GC.GetTotalMemory` and `Environment.WorkingSet` **at `Debug` level** — enable `Debug` for
`Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.MemoryCacheSizeReportingJob` to capture, e.g. during a
reindex or crawl. Counts/bytes are a **trend/attribution** signal (a value that climbs and never falls
indicates unbounded retention). The byte figures are coarse approximations, **not** a heap measurement: the
L0 estimate is an *underlying-content lower bound* (`ContentCacheNodeSizeEstimator` sums the source node's
stored content without decompressing or walking the converted graph, so it omits the property-editor-driven
conversion blow-up); true per-object bytes come from a GC dump. Note the tiers: **L0** is the
converted-`IPublishedContent` cache reported above; **L1** is Microsoft HybridCache's in-process tier of
`ContentCacheNode` entries (behind L0); **L2** is the optional distributed tier. The HybridCache **L1** has
no exposed count/size — measure it from the GC dump until a sized backing cache is wired up (PR 3).
### Known Technical Debt
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Notifications;
@@ -42,8 +43,12 @@ public static class UmbracoBuilderExtensions
builder.Services.AddSingleton<IDomainCache, DomainCache>();
builder.Services.AddSingleton<IElementsCache, ElementsDictionaryAppCache>();
builder.Services.AddSingleton<IPublishedContentTypeCache, PublishedContentTypeCache>();
builder.Services.AddSingleton<IDocumentCacheService, DocumentCacheService>();
builder.Services.AddSingleton<IMediaCacheService, MediaCacheService>();
builder.Services.AddSingleton<DocumentCacheService>();
builder.Services.AddSingleton<IDocumentCacheService>(s => s.GetRequiredService<DocumentCacheService>());
builder.Services.AddSingleton<MediaCacheService>();
builder.Services.AddSingleton<IMediaCacheService>(s => s.GetRequiredService<MediaCacheService>());
builder.Services.AddSingleton<IMemoryCacheSizeReporter>(s => s.GetRequiredService<DocumentCacheService>());
builder.Services.AddSingleton<IMemoryCacheSizeReporter>(s => s.GetRequiredService<MediaCacheService>());
builder.Services.AddSingleton<IMemberCacheService, MemberCacheService>();
builder.Services.AddSingleton<IDomainCacheService, DomainCacheService>();
builder.Services.AddSingleton<IPublishedContentFactory, PublishedContentFactory>();
@@ -59,6 +59,26 @@ internal struct LazyCompressedString
public static implicit operator string(LazyCompressedString l) => l.ToString();
/// <summary>
/// Returns an approximate byte count for this value without decompressing it: the compressed byte
/// length while still compressed, otherwise the approximate UTF-16 byte size (character count × 2) of
/// the already-decompressed string. Never triggers decompression and never throws — intended for cheap
/// size diagnostics. Returning UTF-16 bytes here keeps the decompressed estimate consistent with how
/// plain strings are sized elsewhere.
/// </summary>
public int GetApproximateByteCount()
{
lock (_locker)
{
if (_bytes is not null)
{
return _bytes.Length;
}
return _str is null ? 0 : _str.Length * 2;
}
}
public byte[] GetBytes()
{
if (_bytes == null)
@@ -0,0 +1,59 @@
using Umbraco.Cms.Infrastructure.HybridCache.Serialization;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
/// <summary>
/// Produces a cheap, approximate byte size for a <see cref="ContentCacheNode" />, used to track the
/// retained size of the L0 converted-content cache.
/// </summary>
/// <remarks>
/// The estimate sums the node's stored string/property content without decompressing
/// <see cref="LazyCompressedString" /> values or walking the converted object graph, so it is safe to
/// call on the cache-insert path. It is an <em>underlying-content</em> figure and a lower bound on the
/// true managed heap cost — it deliberately ignores the (highly property-editor-dependent) blow-up from
/// converting stored values into their typed model. The true heap figure comes from a GC dump.
/// </remarks>
internal static class ContentCacheNodeSizeEstimator
{
// Rough allowances for object headers and dictionary/array bookkeeping (x64).
private const int BaseOverheadBytes = 64;
private const int PerPropertyOverheadBytes = 24;
public static long EstimateBytes(ContentCacheNode node)
{
long bytes = BaseOverheadBytes;
ContentData? data = node.Data;
if (data is null)
{
return bytes;
}
bytes += EstimateStringBytes(data.Name) + EstimateStringBytes(data.UrlSegment);
foreach (KeyValuePair<string, PropertyData[]> property in data.Properties)
{
bytes += EstimateStringBytes(property.Key);
foreach (PropertyData propertyData in property.Value)
{
bytes += PerPropertyOverheadBytes
+ EstimateStringBytes(propertyData.Culture)
+ EstimateStringBytes(propertyData.Segment)
+ EstimateValueBytes(propertyData.Value);
}
}
return bytes;
}
private static long EstimateStringBytes(string? value) => value is null ? 0 : value.Length * 2L;
private static long EstimateValueBytes(object? value) => value switch
{
null => 0,
LazyCompressedString lazyCompressedString => lazyCompressedString.GetApproximateByteCount(),
string stringValue => stringValue.Length * 2L,
byte[] bytes => bytes.Length,
_ => 8,
};
}
@@ -0,0 +1,108 @@
using System.Collections.Concurrent;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
/// <summary>
/// Encapsulates the in-process (L0) cache of converted <see cref="IPublishedContent" /> behind a single
/// insert/remove/clear path, tracking both the entry count and an approximate retained byte total.
/// </summary>
/// <remarks>
/// Routing every mutation through this one type keeps the byte total consistent by construction —
/// there is exactly one place that adds on insert and subtracts on remove/clear. The byte total is an
/// <em>approximation</em> (the per-entry size is supplied by the caller and the running total is updated
/// without locking the whole structure), suitable for diagnostics, not exact accounting.
/// This is also the seam the later bounded/eviction-aware implementation slots into.
/// </remarks>
/// <typeparam name="TKey">The cache key type (string for documents, Guid for media).</typeparam>
internal sealed class ConvertedPublishedContentCache<TKey>
where TKey : notnull
{
private readonly ConcurrentDictionary<TKey, CacheEntry> _cache = new();
private long _approximateSizeInBytes;
/// <summary>
/// Gets the number of entries currently held.
/// </summary>
public long Count => _cache.Count;
/// <summary>
/// Gets the approximate retained size, in bytes, of the cached entries.
/// </summary>
public long ApproximateSizeInBytes => Interlocked.Read(ref _approximateSizeInBytes);
/// <summary>
/// Attempts to get a cached converted content item.
/// </summary>
public bool TryGet(TKey key, out IPublishedContent? content)
{
if (_cache.TryGetValue(key, out CacheEntry entry))
{
content = entry.Content;
return true;
}
content = null;
return false;
}
/// <summary>
/// Adds or replaces a cached converted content item, adjusting the running byte total by the supplied
/// per-entry size estimate.
/// </summary>
public void Set(TKey key, IPublishedContent content, long approximateSizeInBytes)
{
var entry = new CacheEntry(content, approximateSizeInBytes);
// Compute the delta against any existing entry so overwrites don't inflate the total. A concurrent
// Set/Remove for the same key can make this off by one entry's size; acceptable for a diagnostic
// counter, and Clear() re-establishes the baseline.
long delta = approximateSizeInBytes;
if (_cache.TryGetValue(key, out CacheEntry existing))
{
delta -= existing.Size;
}
_cache[key] = entry;
Interlocked.Add(ref _approximateSizeInBytes, delta);
}
/// <summary>
/// Removes a cached entry, subtracting its size from the running total.
/// </summary>
public bool Remove(TKey key)
{
if (_cache.TryRemove(key, out CacheEntry entry))
{
Interlocked.Add(ref _approximateSizeInBytes, -entry.Size);
return true;
}
return false;
}
/// <summary>
/// Removes every entry whose content matches the predicate, subtracting their sizes from the total.
/// </summary>
public void RemoveWhere(Func<IPublishedContent, bool> predicate)
{
foreach (KeyValuePair<TKey, CacheEntry> kvp in _cache)
{
if (predicate(kvp.Value.Content) && _cache.TryRemove(kvp.Key, out CacheEntry removed))
{
Interlocked.Add(ref _approximateSizeInBytes, -removed.Size);
}
}
}
/// <summary>
/// Removes all entries and resets the running byte total.
/// </summary>
public void Clear()
{
_cache.Clear();
Interlocked.Exchange(ref _approximateSizeInBytes, 0);
}
private readonly record struct CacheEntry(IPublishedContent Content, long Size);
}
@@ -1,11 +1,11 @@
#if DEBUG
using System.Diagnostics;
#endif
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
@@ -20,7 +20,7 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
internal sealed class DocumentCacheService : IDocumentCacheService
internal sealed class DocumentCacheService : IDocumentCacheService, IMemoryCacheSizeReporter
{
private readonly IDatabaseCacheRepository _databaseCacheRepository;
private readonly IIdKeyMap _idKeyMap;
@@ -36,7 +36,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
private readonly ILogger<DocumentCacheService> _logger;
private HashSet<Guid>? _seedKeys;
private readonly ConcurrentDictionary<string, IPublishedContent> _publishedContentCache = [];
private readonly ConvertedPublishedContentCache<string> _publishedContentCache = new();
private HashSet<Guid> SeedKeys
{
@@ -86,6 +86,15 @@ internal sealed class DocumentCacheService : IDocumentCacheService
_logger = logger;
}
/// <inheritdoc />
public string CacheName => "Published content (converted, L0)";
/// <inheritdoc />
public long GetApproximateCount() => _publishedContentCache.Count;
/// <inheritdoc />
public long? GetApproximateBytes() => _publishedContentCache.ApproximateSizeInBytes;
public async Task<IPublishedContent?> GetByKeyAsync(Guid key, bool? preview = null)
{
bool calculatedPreview = preview ?? GetPreview();
@@ -110,7 +119,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
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))
if (preview is false && _publishedContentCache.TryGet(GetCacheKey(key, preview), out content))
{
return true;
}
@@ -123,7 +132,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
{
var cacheKey = GetCacheKey(key, preview);
if (preview is false && _publishedContentCache.TryGetValue(cacheKey, out IPublishedContent? cached))
if (preview is false && _publishedContentCache.TryGet(cacheKey, out IPublishedContent? cached))
{
return cached;
}
@@ -155,7 +164,10 @@ internal sealed class DocumentCacheService : IDocumentCacheService
IPublishedContent? result = _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview).CreateModel(_publishedModelFactory);
if (result is not null)
{
_publishedContentCache[cacheKey] = result;
// The size estimate runs unconditionally (not only when reporting is enabled): it is cheap
// (O(properties), no IO/decompression) and only on the cache-miss path, and keeping the running
// total always-current means it is accurate the moment debug reporting is switched on.
_publishedContentCache.Set(cacheKey, result, ContentCacheNodeSizeEstimator.EstimateBytes(contentCacheNode));
}
return result;
@@ -226,7 +238,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
{
var cacheKey = GetCacheKey(publishedNode.Key, false);
await _hybridCache.SetAsync(cacheKey, publishedNode, GetEntryOptions(publishedNode.Key, false), GenerateTags(publishedNode));
_publishedContentCache.Remove(cacheKey, out _);
_publishedContentCache.Remove(cacheKey);
}
else
{
@@ -428,14 +440,14 @@ internal sealed class DocumentCacheService : IDocumentCacheService
public void ClearConvertedContentCache(IReadOnlyCollection<int> contentTypeIds)
{
var ids = contentTypeIds as int[] ?? contentTypeIds.ToArray();
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
_publishedContentCache.RemoveWhere(content => ids.Contains(content.ContentType.Id));
}
private async Task ClearPublishedCacheAsync(Guid key)
{
var cacheKey = GetCacheKey(key, false);
await _hybridCache.RemoveAsync(cacheKey);
_publishedContentCache.Remove(cacheKey, out _);
_publishedContentCache.Remove(cacheKey);
}
private static string ContentTypeIdTag(int contentTypeId)
@@ -1,11 +1,11 @@
#if DEBUG
using System.Diagnostics;
#endif
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
@@ -19,7 +19,7 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
internal sealed class MediaCacheService : IMediaCacheService
internal sealed class MediaCacheService : IMediaCacheService, IMemoryCacheSizeReporter
{
private readonly IDatabaseCacheRepository _databaseCacheRepository;
private readonly IIdKeyMap _idKeyMap;
@@ -32,7 +32,7 @@ internal sealed class MediaCacheService : IMediaCacheService
private readonly ILogger<MediaCacheService> _logger;
private readonly CacheSettings _cacheSettings;
private readonly ConcurrentDictionary<Guid, IPublishedContent> _publishedContentCache = [];
private readonly ConvertedPublishedContentCache<Guid> _publishedContentCache = new();
private HashSet<Guid>? _seedKeys;
private HashSet<Guid> SeedKeys
@@ -79,6 +79,15 @@ internal sealed class MediaCacheService : IMediaCacheService
_logger = logger;
}
/// <inheritdoc />
public string CacheName => "Published media (converted, L0)";
/// <inheritdoc />
public long GetApproximateCount() => _publishedContentCache.Count;
/// <inheritdoc />
public long? GetApproximateBytes() => _publishedContentCache.ApproximateSizeInBytes;
public async Task<IPublishedContent?> GetByKeyAsync(Guid key)
{
Attempt<int> idAttempt = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Media);
@@ -106,7 +115,7 @@ internal sealed class MediaCacheService : IMediaCacheService
public bool TryGetCached(Guid key, out IPublishedContent? content)
{
// Mirror the L0 (published content cache) fast path in GetNodeAsync.
if (_publishedContentCache.TryGetValue(key, out content))
if (_publishedContentCache.TryGet(key, out content))
{
return true;
}
@@ -117,7 +126,7 @@ internal sealed class MediaCacheService : IMediaCacheService
private async Task<IPublishedContent?> GetNodeAsync(Guid key)
{
if (_publishedContentCache.TryGetValue(key, out IPublishedContent? cached))
if (_publishedContentCache.TryGet(key, out IPublishedContent? cached))
{
return cached;
}
@@ -146,7 +155,10 @@ internal sealed class MediaCacheService : IMediaCacheService
IPublishedContent? result = _publishedContentFactory.ToIPublishedMedia(contentCacheNode).CreateModel(_publishedModelFactory);
if (result is not null)
{
_publishedContentCache[key] = result;
// The size estimate runs unconditionally (not only when reporting is enabled): it is cheap
// (O(properties), no IO/decompression) and only on the cache-miss path, and keeping the running
// total always-current means it is accurate the moment debug reporting is switched on.
_publishedContentCache.Set(key, result, ContentCacheNodeSizeEstimator.EstimateBytes(contentCacheNode));
}
return result;
@@ -185,7 +197,7 @@ internal sealed class MediaCacheService : IMediaCacheService
var cacheNode = _cacheNodeFactory.ToContentCacheNode(media);
await _databaseCacheRepository.RefreshMediaAsync(cacheNode);
_publishedContentCache.Remove(media.Key, out _);
_publishedContentCache.Remove(media.Key);
scope.Complete();
}
@@ -262,7 +274,7 @@ internal sealed class MediaCacheService : IMediaCacheService
if (publishedNode is not null)
{
await _hybridCache.SetAsync(GetCacheKey(publishedNode.Key), publishedNode, GetEntryOptions(publishedNode.Key));
_publishedContentCache.Remove(key, out _);
_publishedContentCache.Remove(key);
}
else
{
@@ -300,7 +312,7 @@ internal sealed class MediaCacheService : IMediaCacheService
public void ClearConvertedContentCache(IReadOnlyCollection<int> mediaTypeIds)
{
var ids = mediaTypeIds as int[] ?? mediaTypeIds.ToArray();
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
_publishedContentCache.RemoveWhere(content => ids.Contains(content.ContentType.Id));
}
public void Rebuild(IReadOnlyCollection<int> contentTypeIds)
@@ -356,7 +368,7 @@ internal sealed class MediaCacheService : IMediaCacheService
private async Task ClearPublishedCacheAsync(Guid key)
{
await _hybridCache.RemoveAsync(GetCacheKey(key));
_publishedContentCache.Remove(key, out _);
_publishedContentCache.Remove(key);
}
private static string MediaTypeIdTag(int mediaTypeId)
@@ -0,0 +1,46 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using NUnit.Framework;
using Umbraco.Cms.Core.Cache;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Cache;
[TestFixture]
public class SampledSizeEstimatorTests
{
[Test]
public void Can_Return_Zero_For_Empty_Collection()
=> Assert.That(SampledSizeEstimator.Estimate(0, Array.Empty<int>(), _ => 10), Is.EqualTo(0));
[Test]
public void Can_Sum_Sizes_When_Count_Within_Sample()
=> Assert.That(SampledSizeEstimator.Estimate(3, new[] { 1, 2, 3 }, _ => 10), Is.EqualTo(30));
[Test]
public void Can_Extrapolate_From_Sample_When_Count_Exceeds_Sample()
{
// 10 items each sized 5, but only 2 are sampled → average 5 → 10 * 5 = 50.
long result = SampledSizeEstimator.Estimate(10, Enumerable.Repeat(0, 10), _ => 5L, maxSample: 2);
Assert.That(result, Is.EqualTo(50));
}
[Test]
public void Can_Stop_Sizing_At_Sample_Cap()
{
var calls = 0;
SampledSizeEstimator.Estimate(
100,
Enumerable.Range(0, 100),
_ =>
{
calls++;
return 1;
},
maxSample: 5);
Assert.That(calls, Is.EqualTo(5));
}
}
@@ -0,0 +1,58 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs;
[TestFixture]
public class MemoryCacheSizeReportingJobTests
{
[Test]
public async Task Can_Report_Each_Cache_When_Debug_Enabled()
{
Mock<IMemoryCacheSizeReporter> reporterA = CreateReporter("A", 1);
Mock<IMemoryCacheSizeReporter> reporterB = CreateReporter("B", 2);
MemoryCacheSizeReportingJob sut = CreateJob(debugEnabled: true, reporterA.Object, reporterB.Object);
await sut.RunJobAsync(CancellationToken.None);
reporterA.Verify(x => x.GetApproximateCount(), Times.Once);
reporterA.Verify(x => x.GetApproximateBytes(), Times.Once);
reporterB.Verify(x => x.GetApproximateCount(), Times.Once);
reporterB.Verify(x => x.GetApproximateBytes(), Times.Once);
}
[Test]
public async Task Cannot_Report_When_Debug_Disabled()
{
Mock<IMemoryCacheSizeReporter> reporter = CreateReporter("A", 1);
MemoryCacheSizeReportingJob sut = CreateJob(debugEnabled: false, reporter.Object);
await sut.RunJobAsync(CancellationToken.None);
reporter.Verify(x => x.GetApproximateCount(), Times.Never);
reporter.Verify(x => x.GetApproximateBytes(), Times.Never);
}
private static Mock<IMemoryCacheSizeReporter> CreateReporter(string name, long count)
{
var reporter = new Mock<IMemoryCacheSizeReporter>();
reporter.SetupGet(x => x.CacheName).Returns(name);
reporter.Setup(x => x.GetApproximateCount()).Returns(count);
return reporter;
}
private static MemoryCacheSizeReportingJob CreateJob(bool debugEnabled, params IMemoryCacheSizeReporter[] reporters)
{
var logger = new Mock<ILogger<MemoryCacheSizeReportingJob>>();
logger.Setup(x => x.IsEnabled(LogLevel.Debug)).Returns(debugEnabled);
return new MemoryCacheSizeReportingJob(reporters, logger.Object);
}
}
@@ -0,0 +1,57 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.Text;
using K4os.Compression.LZ4;
using NUnit.Framework;
using Umbraco.Cms.Infrastructure.HybridCache;
using Umbraco.Cms.Infrastructure.HybridCache.Serialization;
using Umbraco.Cms.Infrastructure.HybridCache.Services;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HybridCache;
[TestFixture]
public class ContentCacheNodeSizeEstimatorTests
{
[Test]
public void Can_Estimate_Base_Overhead_For_Node_Without_Data()
{
var node = new ContentCacheNode { Id = 1, Key = Guid.Empty, Data = null };
Assert.That(ContentCacheNodeSizeEstimator.EstimateBytes(node), Is.GreaterThan(0));
}
[Test]
public void Can_Estimate_Larger_Size_For_More_Content()
{
ContentCacheNode small = Node(("title", "a"));
ContentCacheNode large = Node(("title", "a very much longer title value"), ("body", "additional content"));
Assert.That(
ContentCacheNodeSizeEstimator.EstimateBytes(large),
Is.GreaterThan(ContentCacheNodeSizeEstimator.EstimateBytes(small)));
}
[Test]
public void Can_Estimate_LazyCompressedString_Property_Without_Throwing()
{
var compressed = new LazyCompressedString(LZ4Pickler.Pickle(Encoding.UTF8.GetBytes("a compressed value")));
ContentCacheNode node = Node(("body", compressed));
long bytes = 0;
Assert.DoesNotThrow(() => bytes = ContentCacheNodeSizeEstimator.EstimateBytes(node));
Assert.That(bytes, Is.GreaterThan(0));
}
private static ContentCacheNode Node(params (string Alias, object? Value)[] properties)
{
var propertyData = new Dictionary<string, PropertyData[]>();
foreach ((string alias, object? value) in properties)
{
propertyData[alias] = [new PropertyData { Culture = string.Empty, Segment = string.Empty, Value = value }];
}
var data = new ContentData("Test", "test", 1, new DateTime(2024, 1, 1), 0, null, true, propertyData, null);
return new ContentCacheNode { Id = 1, Key = Guid.Empty, Data = data };
}
}
@@ -0,0 +1,94 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Infrastructure.HybridCache.Services;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HybridCache;
[TestFixture]
public class ConvertedPublishedContentCacheTests
{
[Test]
public void Can_Track_Count_And_Bytes_On_Set_And_Remove()
{
var cache = new ConvertedPublishedContentCache<string>();
cache.Set("a", Content(), 100);
cache.Set("b", Content(), 50);
Assert.Multiple(() =>
{
Assert.That(cache.Count, Is.EqualTo(2));
Assert.That(cache.ApproximateSizeInBytes, Is.EqualTo(150));
});
cache.Remove("a");
Assert.Multiple(() =>
{
Assert.That(cache.Count, Is.EqualTo(1));
Assert.That(cache.ApproximateSizeInBytes, Is.EqualTo(50));
});
}
[Test]
public void Can_Adjust_Bytes_When_Overwriting_Existing_Key()
{
var cache = new ConvertedPublishedContentCache<string>();
cache.Set("a", Content(), 100);
cache.Set("a", Content(), 30);
Assert.Multiple(() =>
{
Assert.That(cache.Count, Is.EqualTo(1));
Assert.That(cache.ApproximateSizeInBytes, Is.EqualTo(30));
});
}
[Test]
public void Can_Reset_Bytes_On_Clear()
{
var cache = new ConvertedPublishedContentCache<string>();
cache.Set("a", Content(), 100);
cache.Clear();
Assert.Multiple(() =>
{
Assert.That(cache.Count, Is.EqualTo(0));
Assert.That(cache.ApproximateSizeInBytes, Is.EqualTo(0));
});
}
[Test]
public void Can_Remove_Matching_Entries_With_RemoveWhere()
{
var cache = new ConvertedPublishedContentCache<string>();
cache.Set("a", ContentOfType(1), 100);
cache.Set("b", ContentOfType(2), 40);
cache.RemoveWhere(content => content.ContentType.Id == 1);
Assert.Multiple(() =>
{
Assert.That(cache.Count, Is.EqualTo(1));
Assert.That(cache.ApproximateSizeInBytes, Is.EqualTo(40));
});
}
private static IPublishedContent Content() => new Mock<IPublishedContent>().Object;
private static IPublishedContent ContentOfType(int contentTypeId)
{
var contentType = new Mock<IPublishedContentType>();
contentType.SetupGet(x => x.Id).Returns(contentTypeId);
var content = new Mock<IPublishedContent>();
content.SetupGet(x => x.ContentType).Returns(contentType.Object);
return content.Object;
}
}
@@ -0,0 +1,38 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.Text;
using K4os.Compression.LZ4;
using NUnit.Framework;
using Umbraco.Cms.Infrastructure.HybridCache.Serialization;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HybridCache;
[TestFixture]
public class LazyCompressedStringTests
{
[Test]
public void Can_Report_Compressed_Byte_Length_Without_Decompressing()
{
var bytes = new byte[] { 1, 2, 3, 4, 5 };
var sut = new LazyCompressedString(bytes);
Assert.That(sut.GetApproximateByteCount(), Is.EqualTo(bytes.Length));
// The value must still be compressed afterwards — GetBytes throws once decompressed, so this
// guards against GetApproximateByteCount being "simplified" into forcing a decompression.
Assert.DoesNotThrow(() => sut.GetBytes());
}
[Test]
public void Can_Report_Utf16_Byte_Size_After_Decompression()
{
const string value = "hello world";
var sut = new LazyCompressedString(LZ4Pickler.Pickle(Encoding.UTF8.GetBytes(value)));
sut.DecompressString();
// UTF-16 byte size (chars × 2), consistent with how plain strings are sized in the size estimator.
Assert.That(sut.GetApproximateByteCount(), Is.EqualTo(value.Length * 2));
}
}