Files
Umbraco-CMS/src/Umbraco.Core/Services/ContentVersionService.cs
Bjarke Berg ea33b73daa Added scope.complete in edge cases. (#15207)
(cherry picked from commit 28e8ac11f0)
2023-11-15 09:46:24 +01:00

218 lines
9.0 KiB
C#

using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Extensions;
// ReSharper disable once CheckNamespace
namespace Umbraco.Cms.Core.Services;
internal class ContentVersionService : IContentVersionService
{
private readonly IAuditRepository _auditRepository;
private readonly IContentVersionCleanupPolicy _contentVersionCleanupPolicy;
private readonly IDocumentVersionRepository _documentVersionRepository;
private readonly IEventMessagesFactory _eventMessagesFactory;
private readonly ILanguageRepository _languageRepository;
private readonly ILogger<ContentVersionService> _logger;
private readonly ICoreScopeProvider _scopeProvider;
public ContentVersionService(
ILogger<ContentVersionService> logger,
IDocumentVersionRepository documentVersionRepository,
IContentVersionCleanupPolicy contentVersionCleanupPolicy,
ICoreScopeProvider scopeProvider,
IEventMessagesFactory eventMessagesFactory,
IAuditRepository auditRepository,
ILanguageRepository languageRepository)
{
_logger = logger;
_documentVersionRepository = documentVersionRepository;
_contentVersionCleanupPolicy = contentVersionCleanupPolicy;
_scopeProvider = scopeProvider;
_eventMessagesFactory = eventMessagesFactory;
_auditRepository = auditRepository;
_languageRepository = languageRepository;
}
/// <inheritdoc />
public IReadOnlyCollection<ContentVersionMeta> PerformContentVersionCleanup(DateTime asAtDate) =>
// Media - ignored
// Members - ignored
CleanupDocumentVersions(asAtDate);
/// <inheritdoc />
public IEnumerable<ContentVersionMeta>? GetPagedContentVersions(int contentId, long pageIndex, int pageSize, out long totalRecords, string? culture = null)
{
if (pageIndex < 0)
{
throw new ArgumentOutOfRangeException(nameof(pageIndex));
}
if (pageSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(pageSize));
}
using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true))
{
var languageId = _languageRepository.GetIdByIsoCode(culture, true);
scope.ReadLock(Constants.Locks.ContentTree);
return _documentVersionRepository.GetPagedItemsByContentId(contentId, pageIndex, pageSize, out totalRecords, languageId);
}
}
/// <inheritdoc />
public void SetPreventCleanup(int versionId, bool preventCleanup, int userId = Constants.Security.SuperUserId)
{
using (ICoreScope scope = _scopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
_documentVersionRepository.SetPreventCleanup(versionId, preventCleanup);
ContentVersionMeta? version = _documentVersionRepository.Get(versionId);
if (version is null)
{
scope.Complete();
return;
}
AuditType auditType = preventCleanup
? AuditType.ContentVersionPreventCleanup
: AuditType.ContentVersionEnableCleanup;
var message = $"set preventCleanup = '{preventCleanup}' for version '{versionId}'";
Audit(auditType, userId, version.ContentId, message, $"{version.VersionDate}");
scope.Complete();
}
}
private IReadOnlyCollection<ContentVersionMeta> CleanupDocumentVersions(DateTime asAtDate)
{
List<ContentVersionMeta> versionsToDelete;
/* Why so many scopes?
*
* We could just work out the set to delete at SQL infra level which was the original plan, however we agreed that really we should fire
* ContentService.DeletingVersions so people can hook & cancel if required.
*
* On first time run of cleanup on a site with a lot of history there may be a lot of historic ContentVersions to remove e.g. 200K for our.umbraco.com.
* If we weren't supporting SQL CE we could do TVP, or use temp tables to bulk delete with joins to our list of version ids to nuke.
* (much nicer, we can kill 100k in sub second time-frames).
*
* However we are supporting SQL CE, so the easiest thing to do is use the Umbraco InGroupsOf helper to create a query with 2K args of version
* ids to delete at a time.
*
* This is already done at the repository level, however if we only had a single scope at service level we're still locking
* the ContentVersions table (and other related tables) for a couple of minutes which makes the back office unusable.
*
* As a quick fix, we can also use InGroupsOf at service level, create a scope per group to give other connections a chance
* to grab the locks and execute their queries.
*
* This makes the back office a tiny bit sluggish during first run but it is usable for loading tree and publishing content.
*
* There are optimizations we can do, we could add a bulk delete for SqlServerSyntaxProvider which differs in implementation
* and fallback to this naive approach only for SQL CE, however we agreed it is not worth the effort as this is a one time pain,
* subsequent runs shouldn't have huge numbers of versions to cleanup.
*
* tl;dr lots of scopes to enable other connections to use the DB whilst we work.
*/
using (ICoreScope scope = _scopeProvider.CreateCoreScope())
{
IReadOnlyCollection<ContentVersionMeta>? allHistoricVersions =
_documentVersionRepository.GetDocumentVersionsEligibleForCleanup();
if (allHistoricVersions is null)
{
scope.Complete();
return Array.Empty<ContentVersionMeta>();
}
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Discovered {count} candidate(s) for ContentVersion cleanup", allHistoricVersions.Count);
}
versionsToDelete = new List<ContentVersionMeta>(allHistoricVersions.Count);
IEnumerable<ContentVersionMeta> filteredContentVersions =
_contentVersionCleanupPolicy.Apply(asAtDate, allHistoricVersions);
foreach (ContentVersionMeta version in filteredContentVersions)
{
EventMessages messages = _eventMessagesFactory.Get();
if (scope.Notifications.PublishCancelable(
new ContentDeletingVersionsNotification(version.ContentId, messages, version.VersionId)))
{
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Delete cancelled for ContentVersion [{versionId}]", version.VersionId);
}
continue;
}
versionsToDelete.Add(version);
}
scope.Complete();
}
if (!versionsToDelete.Any())
{
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("No remaining ContentVersions for cleanup");
}
return Array.Empty<ContentVersionMeta>();
}
_logger.LogDebug("Removing {count} ContentVersion(s)", versionsToDelete.Count);
foreach (IEnumerable<ContentVersionMeta> group in versionsToDelete.InGroupsOf(Constants.Sql.MaxParameterCount))
{
using (ICoreScope scope = _scopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
var groupEnumerated = group.ToList();
_documentVersionRepository.DeleteVersions(groupEnumerated.Select(x => x.VersionId));
foreach (ContentVersionMeta version in groupEnumerated)
{
EventMessages messages = _eventMessagesFactory.Get();
scope.Notifications.Publish(
new ContentDeletedVersionsNotification(version.ContentId, messages, version.VersionId));
}
scope.Complete();
}
}
using (ICoreScope scope = _scopeProvider.CreateCoreScope())
{
Audit(AuditType.Delete, Constants.Security.SuperUserId, -1, $"Removed {versionsToDelete.Count} ContentVersion(s) according to cleanup policy");
scope.Complete();
}
return versionsToDelete;
}
private void Audit(AuditType type, int userId, int objectId, string? message = null, string? parameters = null)
{
var entry = new AuditItem(
objectId,
type,
userId,
UmbracoObjectTypes.Document.GetName(),
message,
parameters);
_auditRepository.Save(entry);
}
}