Skip to content

Commit 96ecef0

Browse files
AndyButlandCopilotkjac
committed
Performance: Request cache referenced entities when saving documents with block editors (#20590)
* Added request cache to content and media lookups in mult URL picker. * Allow property editors to cache referenced entities from block data. * Update src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs Co-authored-by: Copilot <[email protected]> * Add obsoletions. * Minor spellcheck * Ensure request cache is available before relying on it. --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: kjac <[email protected]>
1 parent 5e87dea commit 96ecef0

File tree

7 files changed

+368
-41
lines changed

7 files changed

+368
-41
lines changed

src/Umbraco.Core/PropertyEditors/DataValueEditor.cs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
using System.Runtime.Serialization;
44
using System.Xml.Linq;
55
using Microsoft.Extensions.Logging;
6+
using Umbraco.Cms.Core.Cache;
67
using Umbraco.Cms.Core.IO;
78
using Umbraco.Cms.Core.Models;
89
using Umbraco.Cms.Core.Models.Editors;
910
using Umbraco.Cms.Core.Models.Validation;
1011
using Umbraco.Cms.Core.PropertyEditors.Validators;
1112
using Umbraco.Cms.Core.Serialization;
13+
using Umbraco.Cms.Core.Services;
1214
using Umbraco.Cms.Core.Strings;
1315
using Umbraco.Extensions;
1416

@@ -20,6 +22,9 @@ namespace Umbraco.Cms.Core.PropertyEditors;
2022
[DataContract]
2123
public class DataValueEditor : IDataValueEditor
2224
{
25+
private const string ContentCacheKeyFormat = nameof(DataValueEditor) + "_Content_{0}";
26+
private const string MediaCacheKeyFormat = nameof(DataValueEditor) + "_Media_{0}";
27+
2328
private readonly IJsonSerializer? _jsonSerializer;
2429
private readonly IShortStringHelper _shortStringHelper;
2530

@@ -415,4 +420,155 @@ public virtual string ConvertDbToString(IPropertyType propertyType, object? valu
415420

416421
return value.TryConvertTo(valueType);
417422
}
423+
424+
/// <summary>
425+
/// Retrieves a <see cref="IContent"/> instance by its unique identifier, using the provided request cache to avoid redundant
426+
/// lookups within the same request.
427+
/// </summary>
428+
/// <remarks>
429+
/// This method caches content lookups for the duration of the current request to improve performance when the same content
430+
/// item may be accessed multiple times. This is particularly useful in scenarios involving multiple languages or blocks.
431+
/// </remarks>
432+
/// <param name="key">The unique identifier of the content item to retrieve.</param>
433+
/// <param name="requestCache">The request-scoped cache used to store and retrieve content items for the duration of the current request.</param>
434+
/// <param name="contentService">The content service used to fetch the content item if it is not found in the cache.</param>
435+
/// <returns>The <see cref="IContent"/> instance corresponding to the specified key, or null if no such content item exists.</returns>
436+
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
437+
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
438+
"Scheduled for removal in Umbraco 19.")]
439+
protected static IContent? GetAndCacheContentById(Guid key, IRequestCache requestCache, IContentService contentService)
440+
{
441+
if (requestCache.IsAvailable is false)
442+
{
443+
return contentService.GetById(key);
444+
}
445+
446+
var cacheKey = string.Format(ContentCacheKeyFormat, key);
447+
IContent? content = requestCache.GetCacheItem<IContent?>(cacheKey);
448+
if (content is null)
449+
{
450+
content = contentService.GetById(key);
451+
if (content is not null)
452+
{
453+
requestCache.Set(cacheKey, content);
454+
}
455+
}
456+
457+
return content;
458+
}
459+
460+
/// <summary>
461+
/// Adds the specified <see cref="IContent"/> item to the request cache using its unique key.
462+
/// </summary>
463+
/// <param name="content">The content item to cache.</param>
464+
/// <param name="requestCache">The request cache in which to store the content item.</param>
465+
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
466+
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
467+
"Scheduled for removal in Umbraco 19.")]
468+
protected static void CacheContentById(IContent content, IRequestCache requestCache)
469+
{
470+
if (requestCache.IsAvailable is false)
471+
{
472+
return;
473+
}
474+
475+
var cacheKey = string.Format(ContentCacheKeyFormat, content.Key);
476+
requestCache.Set(cacheKey, content);
477+
}
478+
479+
/// <summary>
480+
/// Retrieves a <see cref="IMedia"/> instance by its unique identifier, using the provided request cache to avoid redundant
481+
/// lookups within the same request.
482+
/// </summary>
483+
/// <remarks>
484+
/// This method caches media lookups for the duration of the current request to improve performance when the same media
485+
/// item may be accessed multiple times. This is particularly useful in scenarios involving multiple languages or blocks.
486+
/// </remarks>
487+
/// <param name="key">The unique identifier of the media item to retrieve.</param>
488+
/// <param name="requestCache">The request-scoped cache used to store and retrieve media items for the duration of the current request.</param>
489+
/// <param name="mediaService">The media service used to fetch the media item if it is not found in the cache.</param>
490+
/// <returns>The <see cref="IMedia"/> instance corresponding to the specified key, or null if no such media item exists.</returns>
491+
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
492+
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
493+
"Scheduled for removal in Umbraco 19.")]
494+
protected static IMedia? GetAndCacheMediaById(Guid key, IRequestCache requestCache, IMediaService mediaService)
495+
{
496+
if (requestCache.IsAvailable is false)
497+
{
498+
return mediaService.GetById(key);
499+
}
500+
501+
var cacheKey = string.Format(MediaCacheKeyFormat, key);
502+
IMedia? media = requestCache.GetCacheItem<IMedia?>(cacheKey);
503+
504+
if (media is null)
505+
{
506+
media = mediaService.GetById(key);
507+
if (media is not null)
508+
{
509+
requestCache.Set(cacheKey, media);
510+
}
511+
}
512+
513+
return media;
514+
}
515+
516+
/// <summary>
517+
/// Adds the specified <see cref="IMedia"/> item to the request cache using its unique key.
518+
/// </summary>
519+
/// <param name="media">The media item to cache.</param>
520+
/// <param name="requestCache">The request cache in which to store the media item.</param>
521+
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
522+
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
523+
"Scheduled for removal in Umbraco 19.")]
524+
protected static void CacheMediaById(IMedia media, IRequestCache requestCache)
525+
{
526+
if (requestCache.IsAvailable is false)
527+
{
528+
return;
529+
}
530+
531+
var cacheKey = string.Format(MediaCacheKeyFormat, media.Key);
532+
requestCache.Set(cacheKey, media);
533+
}
534+
535+
/// <summary>
536+
/// Determines whether the content item identified by the specified key is present in the request cache.
537+
/// </summary>
538+
/// <param name="key">The unique identifier for the content item to check for in the cache.</param>
539+
/// <param name="requestCache">The request cache in which to look for the content item.</param>
540+
/// <returns>true if the content item is already cached in the request cache; otherwise, false.</returns>
541+
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
542+
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
543+
"Scheduled for removal in Umbraco 19.")]
544+
protected static bool IsContentAlreadyCached(Guid key, IRequestCache requestCache)
545+
{
546+
if (requestCache.IsAvailable is false)
547+
{
548+
return false;
549+
}
550+
551+
var cacheKey = string.Format(ContentCacheKeyFormat, key);
552+
return requestCache.GetCacheItem<IContent?>(cacheKey) is not null;
553+
}
554+
555+
/// <summary>
556+
/// Determines whether the media item identified by the specified key is present in the request cache.
557+
/// </summary>
558+
/// <param name="key">The unique identifier for the media item to check for in the cache.</param>
559+
/// <param name="requestCache">The request cache in which to look for the media item.</param>
560+
/// <returns>true if the media item is already cached in the request cache; otherwise, false.</returns>
561+
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
562+
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
563+
"Scheduled for removal in Umbraco 19.")]
564+
protected static bool IsMediaAlreadyCached(Guid key, IRequestCache requestCache)
565+
{
566+
if (requestCache.IsAvailable is false)
567+
{
568+
return false;
569+
}
570+
571+
var cacheKey = string.Format(MediaCacheKeyFormat, key);
572+
return requestCache.GetCacheItem<IMedia?>(cacheKey) is not null;
573+
}
418574
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace Umbraco.Cms.Core.PropertyEditors;
2+
3+
/// <summary>
4+
/// Optionally implemented by property editors, this defines a contract for caching entities that are referenced in block values.
5+
/// </summary>
6+
[Obsolete("This interface is available for support of request caching retrieved entities in property value editors that implement it. " +
7+
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
8+
"Scheduled for removal in Umbraco 19.")]
9+
public interface ICacheReferencedEntities
10+
{
11+
/// <summary>
12+
/// Caches the entities referenced by the provided block data values.
13+
/// </summary>
14+
/// <param name="values">An enumerable collection of block values that may contain the entities to be cached.</param>
15+
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
16+
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
17+
"Scheduled for removal in Umbraco 19.")]
18+
void CacheReferencedEntities(IEnumerable<object> values);
19+
}

src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ public override object ToEditor(IProperty property, string? culture = null, stri
135135
BlockEditorData<TValue, TLayout>? currentBlockEditorData = SafeParseBlockEditorData(currentValue);
136136
BlockEditorData<TValue, TLayout>? blockEditorData = SafeParseBlockEditorData(editorValue.Value);
137137

138+
CacheReferencedEntities(blockEditorData);
139+
138140
// We can skip MapBlockValueFromEditor if both editorValue and currentValue values are empty.
139141
if (IsBlockEditorDataEmpty(currentBlockEditorData) && IsBlockEditorDataEmpty(blockEditorData))
140142
{

src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,45 @@ protected BlockValuePropertyValueEditorBase(
4343
_languageService = languageService;
4444
}
4545

46+
/// <summary>
47+
/// Caches referenced entities for all property values with supporting property editors within the specified block editor data
48+
/// optimising subsequent retrieval of entities when parsing and converting property values.
49+
/// </summary>
50+
/// <remarks>
51+
/// This method iterates through all property values associated with data editors in the provided
52+
/// block editor data and invokes caching for referenced entities where supported by the property editor.
53+
/// </remarks>
54+
/// <param name="blockEditorData">The block editor data containing content and settings property values to analyze for referenced entities.</param>
55+
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
56+
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
57+
"Scheduled for removal in Umbraco 19.")]
58+
protected void CacheReferencedEntities(BlockEditorData<TValue, TLayout>? blockEditorData)
59+
{
60+
// Group property values by their associated data editor alias.
61+
IEnumerable<IGrouping<string, BlockPropertyValue>> valuesByDataEditors = (blockEditorData?.BlockValue.ContentData ?? []).Union(blockEditorData?.BlockValue.SettingsData ?? [])
62+
.SelectMany(x => x.Values)
63+
.Where(x => x.EditorAlias is not null && x.Value is not null)
64+
.GroupBy(x => x.EditorAlias!);
65+
66+
// Iterate through each group and cache referenced entities if supported by the data editor.
67+
foreach (IGrouping<string, BlockPropertyValue> valueByDataEditor in valuesByDataEditors)
68+
{
69+
IDataEditor? dataEditor = _propertyEditors[valueByDataEditor.Key];
70+
if (dataEditor is null)
71+
{
72+
continue;
73+
}
74+
75+
IDataValueEditor valueEditor = dataEditor.GetValueEditor();
76+
77+
if (valueEditor is ICacheReferencedEntities valueEditorWithPrecaching)
78+
{
79+
valueEditorWithPrecaching.CacheReferencedEntities(valueByDataEditor.Select(x => x.Value!));
80+
}
81+
}
82+
}
83+
84+
4685
/// <inheritdoc />
4786
public abstract IEnumerable<UmbracoEntityReference> GetReferences(object? value);
4887

src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs

Lines changed: 50 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,8 @@ protected override IDataValueEditor CreateValueEditor() =>
5252
/// <summary>
5353
/// Defines the value editor for the media picker property editor.
5454
/// </summary>
55-
internal sealed class MediaPicker3PropertyValueEditor : DataValueEditor, IDataValueReference
55+
internal sealed class MediaPicker3PropertyValueEditor : DataValueEditor, IDataValueReference, ICacheReferencedEntities
5656
{
57-
private const string MediaCacheKeyFormat = nameof(MediaPicker3PropertyValueEditor) + "_Media_{0}";
58-
5957
private readonly IDataTypeConfigurationCache _dataTypeReadCache;
6058
private readonly IJsonSerializer _jsonSerializer;
6159
private readonly IMediaImportService _mediaImportService;
@@ -107,6 +105,27 @@ public MediaPicker3PropertyValueEditor(
107105
Validators.Add(validators);
108106
}
109107

108+
/// <inheritdoc/>
109+
public void CacheReferencedEntities(IEnumerable<object> values)
110+
{
111+
var mediaKeys = values
112+
.SelectMany(value => Deserialize(_jsonSerializer, value))
113+
.Select(dto => dto.MediaKey)
114+
.Distinct()
115+
.Where(x => IsMediaAlreadyCached(x, _appCaches.RequestCache) is false)
116+
.ToList();
117+
if (mediaKeys.Count == 0)
118+
{
119+
return;
120+
}
121+
122+
IEnumerable<IMedia> mediaItems = _mediaService.GetByIds(mediaKeys);
123+
foreach (IMedia media in mediaItems)
124+
{
125+
CacheMediaById(media, _appCaches.RequestCache);
126+
}
127+
}
128+
110129
/// <inheritdoc/>
111130
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
112131
{
@@ -208,39 +227,21 @@ private List<MediaWithCropsDto> UpdateMediaTypeAliases(List<MediaWithCropsDto> m
208227

209228
foreach (MediaWithCropsDto mediaWithCropsDto in mediaWithCropsDtos)
210229
{
211-
IMedia? media = GetMediaById(mediaWithCropsDto.MediaKey);
230+
IMedia? media = GetAndCacheMediaById(mediaWithCropsDto.MediaKey, _appCaches.RequestCache, _mediaService);
212231
mediaWithCropsDto.MediaTypeAlias = media?.ContentType.Alias ?? unknownMediaType;
213232
}
214233

215234
return mediaWithCropsDtos.Where(m => m.MediaTypeAlias != unknownMediaType).ToList();
216235
}
217236

218-
private IMedia? GetMediaById(Guid key)
219-
{
220-
// Cache media lookups in case the same media is handled multiple times across a save operation,
221-
// which is possible, particularly if we have multiple languages and blocks.
222-
var cacheKey = string.Format(MediaCacheKeyFormat, key);
223-
IMedia? media = _appCaches.RequestCache.GetCacheItem<IMedia?>(cacheKey);
224-
if (media is null)
225-
{
226-
media = _mediaService.GetById(key);
227-
if (media is not null)
228-
{
229-
_appCaches.RequestCache.Set(cacheKey, media);
230-
}
231-
}
232-
233-
return media;
234-
}
235-
236237
private List<MediaWithCropsDto> HandleTemporaryMediaUploads(List<MediaWithCropsDto> mediaWithCropsDtos, MediaPicker3Configuration configuration)
237238
{
238239
var invalidDtos = new List<MediaWithCropsDto>();
239240

240241
foreach (MediaWithCropsDto mediaWithCropsDto in mediaWithCropsDtos)
241242
{
242243
// if the media already exist, don't bother with it
243-
if (GetMediaById(mediaWithCropsDto.MediaKey) != null)
244+
if (GetAndCacheMediaById(mediaWithCropsDto.MediaKey, _appCaches.RequestCache, _mediaService) != null)
244245
{
245246
continue;
246247
}
@@ -480,18 +481,7 @@ public IEnumerable<ValidationResult> Validate(
480481

481482
foreach (var typeAlias in distinctTypeAliases)
482483
{
483-
// Cache media type lookups since the same media type is likely to be used multiple times in validation,
484-
// particularly if we have multiple languages and blocks.
485-
var cacheKey = string.Format(MediaTypeCacheKeyFormat, typeAlias);
486-
string? typeKey = _appCaches.RequestCache.GetCacheItem<string?>(cacheKey);
487-
if (typeKey is null)
488-
{
489-
typeKey = _mediaTypeService.Get(typeAlias)?.Key.ToString();
490-
if (typeKey is not null)
491-
{
492-
_appCaches.RequestCache.Set(cacheKey, typeKey);
493-
}
494-
}
484+
string? typeKey = GetMediaTypeKey(typeAlias);
495485

496486
if (typeKey is null || allowedTypes.Contains(typeKey) is false)
497487
{
@@ -506,6 +496,31 @@ public IEnumerable<ValidationResult> Validate(
506496

507497
return [];
508498
}
499+
500+
private string? GetMediaTypeKey(string typeAlias)
501+
{
502+
// Cache media type lookups since the same media type is likely to be used multiple times in validation,
503+
// particularly if we have multiple languages and blocks.
504+
string? GetMediaTypeKeyFromService(string typeAlias) => _mediaTypeService.Get(typeAlias)?.Key.ToString();
505+
506+
if (_appCaches.RequestCache.IsAvailable is false)
507+
{
508+
return GetMediaTypeKeyFromService(typeAlias);
509+
}
510+
511+
var cacheKey = string.Format(MediaTypeCacheKeyFormat, typeAlias);
512+
string? typeKey = _appCaches.RequestCache.GetCacheItem<string?>(cacheKey);
513+
if (typeKey is null)
514+
{
515+
typeKey = GetMediaTypeKeyFromService(typeAlias);
516+
if (typeKey is not null)
517+
{
518+
_appCaches.RequestCache.Set(cacheKey, typeKey);
519+
}
520+
}
521+
522+
return typeKey;
523+
}
509524
}
510525

511526
/// <summary>

0 commit comments

Comments
 (0)