Skip to content

Commit 37b239b

Browse files
AndyButlandZeegaan
authored andcommitted
Performance: Reduce number of database calls in save and publish operations (#20485)
* Added request caching to media picker media retrieval, to improve performance in save operations. * WIP: Update or insert in bulk when updating property data. * Add tests verifying UpdateBatch. * Fixed issue with UpdateBatch and SQL Server. * Removed stopwatch. * Fix test on SQLite (failing on SQLServer). * Added temporary test for direct call to NPoco UpdateBatch. * Fixed test on SQLServer. * Add integration test verifying the same property data is persisted as before the performance refactor. * Log expected warning in DocumentUrlService as debug. (cherry picked from commit 12adfd5)
1 parent ea44850 commit 37b239b

File tree

6 files changed

+142
-10
lines changed

6 files changed

+142
-10
lines changed

src/Umbraco.Core/Services/DocumentUrlService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,8 @@ private void HandleCaching(IScopeContext scopeContext, IContent document, string
432432

433433
if (draftUrlSegments.Any() is false)
434434
{
435-
_logger.LogWarning("No draft URL segments found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}");
435+
// Log at debug level because this is expected when a document is not published in a given language.
436+
_logger.LogDebug("No draft URL segments found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}");
436437
}
437438
else
438439
{

src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,25 +1155,38 @@ protected void ReplacePropertyValues(TEntity entity, int versionId, int publishe
11551155

11561156
IEnumerable<PropertyDataDto> propertyDataDtos = PropertyFactory.BuildDtos(entity.ContentType.Variations, entity.VersionId, publishedVersionId, entity.Properties, LanguageRepository, out edited, out editedCultures);
11571157

1158+
var toUpdate = new List<PropertyDataDto>();
1159+
var toInsert = new List<PropertyDataDto>();
11581160
foreach (PropertyDataDto propertyDataDto in propertyDataDtos)
11591161
{
11601162
// Check if this already exists and update, else insert a new one
11611163
if (propertyTypeToPropertyData.TryGetValue((propertyDataDto.PropertyTypeId, propertyDataDto.VersionId, propertyDataDto.LanguageId, propertyDataDto.Segment), out PropertyDataDto? propData))
11621164
{
11631165
propertyDataDto.Id = propData.Id;
1164-
Database.Update(propertyDataDto);
1166+
toUpdate.Add(propertyDataDto);
11651167
}
11661168
else
11671169
{
1168-
// TODO: we can speed this up: Use BulkInsert and then do one SELECT to re-retrieve the property data inserted with assigned IDs.
1169-
// This is a perfect thing to benchmark with Benchmark.NET to compare perf between Nuget releases.
1170-
Database.Insert(propertyDataDto);
1170+
toInsert.Add(propertyDataDto);
11711171
}
11721172

11731173
// track which ones have been processed
11741174
existingPropDataIds.Remove(propertyDataDto.Id);
11751175
}
11761176

1177+
if (toUpdate.Count > 0)
1178+
{
1179+
var updateBatch = toUpdate
1180+
.Select(x => UpdateBatch.For(x))
1181+
.ToList();
1182+
Database.UpdateBatch(updateBatch, new BatchOptions { BatchSize = 100 });
1183+
}
1184+
1185+
if (toInsert.Count > 0)
1186+
{
1187+
Database.InsertBulk(toInsert);
1188+
}
1189+
11771190
// For any remaining that haven't been processed they need to be deleted
11781191
if (existingPropDataIds.Count > 0)
11791192
{

src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,16 @@ protected override IDataValueEditor CreateValueEditor() =>
5454
/// </summary>
5555
internal sealed class MediaPicker3PropertyValueEditor : DataValueEditor, IDataValueReference
5656
{
57+
private const string MediaCacheKeyFormat = nameof(MediaPicker3PropertyValueEditor) + "_Media_{0}";
58+
5759
private readonly IDataTypeConfigurationCache _dataTypeReadCache;
5860
private readonly IJsonSerializer _jsonSerializer;
5961
private readonly IMediaImportService _mediaImportService;
6062
private readonly IMediaService _mediaService;
6163
private readonly ITemporaryFileService _temporaryFileService;
6264
private readonly IScopeProvider _scopeProvider;
6365
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
66+
private readonly AppCaches _appCaches;
6467

6568
/// <summary>
6669
/// Initializes a new instance of the <see cref="MediaPicker3PropertyValueEditor"/> class.
@@ -93,6 +96,8 @@ public MediaPicker3PropertyValueEditor(
9396
_scopeProvider = scopeProvider;
9497
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
9598
_dataTypeReadCache = dataTypeReadCache;
99+
_appCaches = appCaches;
100+
96101
var validators = new TypedJsonValidatorRunner<List<MediaWithCropsDto>, MediaPicker3Configuration>(
97102
jsonSerializer,
98103
new MinMaxValidator(localizedTextService),
@@ -203,21 +208,39 @@ private List<MediaWithCropsDto> UpdateMediaTypeAliases(List<MediaWithCropsDto> m
203208

204209
foreach (MediaWithCropsDto mediaWithCropsDto in mediaWithCropsDtos)
205210
{
206-
IMedia? media = _mediaService.GetById(mediaWithCropsDto.MediaKey);
211+
IMedia? media = GetMediaById(mediaWithCropsDto.MediaKey);
207212
mediaWithCropsDto.MediaTypeAlias = media?.ContentType.Alias ?? unknownMediaType;
208213
}
209214

210215
return mediaWithCropsDtos.Where(m => m.MediaTypeAlias != unknownMediaType).ToList();
211216
}
212217

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+
213236
private List<MediaWithCropsDto> HandleTemporaryMediaUploads(List<MediaWithCropsDto> mediaWithCropsDtos, MediaPicker3Configuration configuration)
214237
{
215238
var invalidDtos = new List<MediaWithCropsDto>();
216239

217240
foreach (MediaWithCropsDto mediaWithCropsDto in mediaWithCropsDtos)
218241
{
219242
// if the media already exist, don't bother with it
220-
if (_mediaService.GetById(mediaWithCropsDto.MediaKey) != null)
243+
if (GetMediaById(mediaWithCropsDto.MediaKey) != null)
221244
{
222245
continue;
223246
}

tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentPublishingServiceTests.Publish.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Umbraco.Cms.Core.Models.ContentPublishing;
44
using Umbraco.Cms.Core.Services;
55
using Umbraco.Cms.Core.Services.OperationStatus;
6+
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
67
using Umbraco.Cms.Tests.Integration.Testing;
78

89
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services;
@@ -388,4 +389,31 @@ public async Task Republishing_Single_Culture_Does_Not_Change_Publish_Or_Update_
388389
?? throw new InvalidOperationException("Expected a publish date for EN");
389390
Assert.Greater(lastPublishDateEn, firstPublishDateEn);
390391
}
392+
393+
[Test]
394+
public async Task Publishing_Single_Culture_Persists_Expected_Property_Data()
395+
{
396+
var (langEn, langDa, langBe, contentType) = await SetupVariantDoctypeAsync();
397+
var content = await CreateVariantContentAsync(langEn, langDa, langBe, contentType);
398+
399+
using (var scope = ScopeProvider.CreateScope())
400+
{
401+
var dtos = scope.Database.Fetch<PropertyDataDto>();
402+
Assert.AreEqual(18, dtos.Count); // 3 properties * 3 cultures * 2 (published + edited).
403+
scope.Complete();
404+
}
405+
406+
var publishAttempt = await ContentPublishingService.PublishAsync(
407+
content.Key,
408+
[new() { Culture = langEn.IsoCode }],
409+
Constants.Security.SuperUserKey);
410+
Assert.IsTrue(publishAttempt.Success);
411+
412+
using (var scope = ScopeProvider.CreateScope())
413+
{
414+
var dtos = scope.Database.Fetch<PropertyDataDto>();
415+
Assert.AreEqual(19, dtos.Count); // + 3 for published populated title property, - 2 for existing published properties of other cultures.
416+
scope.Complete();
417+
}
418+
}
391419
}

tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/NPocoTests/NPocoFetchTests.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
// Copyright (c) Umbraco.
22
// See LICENSE for more details.
33

4-
using System.Collections.Generic;
5-
using System.Linq;
64
using NPoco;
75
using NUnit.Framework;
86
using Umbraco.Cms.Tests.Common.Testing;
97
using Umbraco.Cms.Tests.Integration.Testing;
10-
using Umbraco.Extensions;
118

129
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Persistence.NPocoTests;
1310

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) Umbraco.
2+
// See LICENSE for more details.
3+
4+
using NPoco;
5+
using NUnit.Framework;
6+
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
7+
using Umbraco.Cms.Tests.Common.Testing;
8+
using Umbraco.Cms.Tests.Integration.Testing;
9+
10+
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Persistence.NPocoTests;
11+
12+
[TestFixture]
13+
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)]
14+
internal sealed class NPocoUpdateBatchTests : UmbracoIntegrationTest
15+
{
16+
[Test]
17+
public void Can_Update_Batch()
18+
{
19+
// Arrange
20+
var servers = new List<ServerRegistrationDto>();
21+
for (var i = 0; i < 3; i++)
22+
{
23+
servers.Add(new ServerRegistrationDto
24+
{
25+
Id = i + 1,
26+
ServerAddress = "address" + i,
27+
ServerIdentity = "computer" + i,
28+
DateRegistered = DateTime.Now,
29+
IsActive = false,
30+
DateAccessed = DateTime.Now,
31+
});
32+
}
33+
34+
using (var scope = ScopeProvider.CreateScope())
35+
{
36+
scope.Database.BulkInsertRecords(servers);
37+
scope.Complete();
38+
}
39+
40+
// Act
41+
for (var i = 0; i < 3; i++)
42+
{
43+
servers[i].ServerAddress = "newaddress" + i;
44+
servers[i].IsActive = true;
45+
}
46+
47+
using (var scope = ScopeProvider.CreateScope())
48+
{
49+
var updateBatch = servers
50+
.Select(x => UpdateBatch.For(x))
51+
.ToList();
52+
var updated = scope.Database.UpdateBatch(updateBatch, new BatchOptions { BatchSize = 100 });
53+
Assert.AreEqual(3, updated);
54+
scope.Complete();
55+
}
56+
57+
// Assert
58+
using (var scope = ScopeProvider.CreateScope())
59+
{
60+
var dtos = scope.Database.Fetch<ServerRegistrationDto>();
61+
Assert.AreEqual(3, dtos.Count);
62+
for (var i = 0; i < 3; i++)
63+
{
64+
Assert.AreEqual(servers[i].ServerAddress, dtos[i].ServerAddress);
65+
Assert.AreEqual(servers[i].ServerIdentity, dtos[i].ServerIdentity);
66+
Assert.AreEqual(servers[i].IsActive, dtos[i].IsActive);
67+
}
68+
}
69+
}
70+
}

0 commit comments

Comments
 (0)