From bba089c24c1bd26a5769f84ed4646f3a1c08e438 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Mon, 18 Oct 2021 21:56:18 +0100 Subject: [PATCH] Implemented ContentVersionCleanup scheduled task. Note: adding ref to Microsoft.NET.Test.Sdk fixes AutoFixture AutoDataAttribute (and sub classes) --- .../CompositionExtensions/Repositories.cs | 1 + .../CompositionExtensions/Services.cs | 7 +- .../UmbracoSettings/ContentElement.cs | 6 +- ...sionCleanupPolicyGlobalSettingsElement.cs} | 2 +- .../UmbracoSettings/IContentSection.cs | 2 +- ...tentVersionCleanupPolicyGlobalSettings.cs} | 2 +- .../ContentVersionCleanupPolicySettings.cs | 13 + .../Models/HistoricContentVersionMeta.cs | 24 ++ .../Dtos/ContentVersionCleanupPolicyDto.cs | 3 +- .../IDocumentVersionRepository.cs | 23 ++ .../Implement/DocumentVersionRepository.cs | 94 +++++++ .../Services/IContentVersionCleanupPolicy.cs | 17 ++ .../Services/IContentVersionCleanupService.cs | 14 + .../Services/Implement/ContentService.cs | 113 +++++++- .../DefaultContentVersionCleanupPolicy.cs | 90 ++++++ src/Umbraco.Core/Umbraco.Core.csproj | 11 +- .../UmbracoSettings/umbracoSettings.config | 2 + ...mentVersionRepository_Tests_Integration.cs | 125 +++++++++ .../ContentVersionCleanup_Tests_UnitTests.cs | 127 +++++++++ ...VersionCleanupService_Tests_Integration.cs | 106 +++++++ ...ntVersionCleanupService_Tests_UnitTests.cs | 264 ++++++++++++++++++ ...entVersionCleanupPolicy_Tests_UnitTests.cs | 237 ++++++++++++++++ .../TestHelpers/Entities/MockedContent.cs | 10 +- .../Entities/MockedContentTypes.cs | 1 + .../Testing/AutoMoqDataAttribute.cs | 13 + src/Umbraco.Tests/Umbraco.Tests.csproj | 15 +- .../config/umbracoSettings.Release.config | 2 +- .../Scheduling/ContentVersionCleanup.cs | 79 ++++++ .../Scheduling/SchedulerComponent.cs | 37 ++- src/Umbraco.Web/Umbraco.Web.csproj | 1 + 30 files changed, 1416 insertions(+), 25 deletions(-) rename src/Umbraco.Core/Configuration/UmbracoSettings/{ContentVersionCleanupPolicyElement.cs => ContentVersionCleanupPolicyGlobalSettingsElement.cs} (80%) rename src/Umbraco.Core/Configuration/UmbracoSettings/{IContentVersionCleanupPolicySettings.cs => IContentVersionCleanupPolicyGlobalSettings.cs} (76%) create mode 100644 src/Umbraco.Core/Models/ContentVersionCleanupPolicySettings.cs create mode 100644 src/Umbraco.Core/Models/HistoricContentVersionMeta.cs create mode 100644 src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs create mode 100644 src/Umbraco.Core/Persistence/Repositories/Implement/DocumentVersionRepository.cs create mode 100644 src/Umbraco.Core/Services/IContentVersionCleanupPolicy.cs create mode 100644 src/Umbraco.Core/Services/IContentVersionCleanupService.cs create mode 100644 src/Umbraco.Core/Services/Implement/DefaultContentVersionCleanupPolicy.cs create mode 100644 src/Umbraco.Tests/Persistence/Repositories/DocumentVersionRepository_Tests_Integration.cs create mode 100644 src/Umbraco.Tests/Scheduling/ContentVersionCleanup_Tests_UnitTests.cs create mode 100644 src/Umbraco.Tests/Services/ContentVersionCleanupService_Tests_Integration.cs create mode 100644 src/Umbraco.Tests/Services/ContentVersionCleanupService_Tests_UnitTests.cs create mode 100644 src/Umbraco.Tests/Services/DefaultContentVersionCleanupPolicy_Tests_UnitTests.cs create mode 100644 src/Umbraco.Tests/Testing/AutoMoqDataAttribute.cs create mode 100644 src/Umbraco.Web/Scheduling/ContentVersionCleanup.cs diff --git a/src/Umbraco.Core/Composing/CompositionExtensions/Repositories.cs b/src/Umbraco.Core/Composing/CompositionExtensions/Repositories.cs index 00b29dd97f..77549ed48c 100644 --- a/src/Umbraco.Core/Composing/CompositionExtensions/Repositories.cs +++ b/src/Umbraco.Core/Composing/CompositionExtensions/Repositories.cs @@ -49,6 +49,7 @@ namespace Umbraco.Core.Composing.CompositionExtensions composition.RegisterUnique(); composition.RegisterUnique(); composition.RegisterUnique(); + composition.RegisterUnique(); return composition; } diff --git a/src/Umbraco.Core/Composing/CompositionExtensions/Services.cs b/src/Umbraco.Core/Composing/CompositionExtensions/Services.cs index d97845928d..56a9b2f8ae 100644 --- a/src/Umbraco.Core/Composing/CompositionExtensions/Services.cs +++ b/src/Umbraco.Core/Composing/CompositionExtensions/Services.cs @@ -31,7 +31,12 @@ namespace Umbraco.Core.Composing.CompositionExtensions composition.RegisterUnique(); composition.RegisterUnique(); composition.RegisterUnique(); - composition.RegisterUnique(); + + composition.RegisterUnique(); + composition.RegisterUnique(factory => factory.GetInstance()); + composition.RegisterUnique(factory => factory.GetInstance()); + composition.RegisterUnique(); + composition.RegisterUnique(); composition.RegisterUnique(); composition.RegisterUnique(); diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/ContentElement.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/ContentElement.cs index 12cc890c2b..fba46c077e 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/ContentElement.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/ContentElement.cs @@ -22,8 +22,8 @@ namespace Umbraco.Core.Configuration.UmbracoSettings [ConfigurationProperty("notifications", IsRequired = true)] internal NotificationsElement Notifications => (NotificationsElement) base["notifications"]; - [ConfigurationProperty("contentVersionCleanupPolicy", IsRequired = false)] - internal ContentVersionCleanupPolicyElement ContentVersionCleanupPolicy => (ContentVersionCleanupPolicyElement) this["contentVersionCleanupPolicy"]; + [ConfigurationProperty("contentVersionCleanupPolicyGlobalSettings", IsRequired = false)] + internal ContentVersionCleanupPolicyGlobalSettingsElement ContentVersionCleanupPolicyGlobalSettingsElement => (ContentVersionCleanupPolicyGlobalSettingsElement) this["contentVersionCleanupPolicyGlobalSettings"]; [ConfigurationProperty("PreviewBadge")] internal InnerTextConfigurationElement PreviewBadge => GetOptionalTextElement("PreviewBadge", DefaultPreviewBadge); @@ -64,7 +64,7 @@ namespace Umbraco.Core.Configuration.UmbracoSettings IEnumerable IContentSection.AllowedUploadFiles => AllowedUploadFiles; - IContentVersionCleanupPolicySettings IContentSection.ContentVersionCleanupPolicySettings => ContentVersionCleanupPolicy; + IContentVersionCleanupPolicyGlobalSettings IContentSection.ContentVersionCleanupPolicyGlobalSettings => ContentVersionCleanupPolicyGlobalSettingsElement; bool IContentSection.ShowDeprecatedPropertyEditors => ShowDeprecatedPropertyEditors; diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/ContentVersionCleanupPolicyElement.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/ContentVersionCleanupPolicyGlobalSettingsElement.cs similarity index 80% rename from src/Umbraco.Core/Configuration/UmbracoSettings/ContentVersionCleanupPolicyElement.cs rename to src/Umbraco.Core/Configuration/UmbracoSettings/ContentVersionCleanupPolicyGlobalSettingsElement.cs index a736ccfd34..ad32594e39 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/ContentVersionCleanupPolicyElement.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/ContentVersionCleanupPolicyGlobalSettingsElement.cs @@ -2,7 +2,7 @@ namespace Umbraco.Core.Configuration.UmbracoSettings { - internal class ContentVersionCleanupPolicyElement : UmbracoConfigurationElement, IContentVersionCleanupPolicySettings + internal class ContentVersionCleanupPolicyGlobalSettingsElement : UmbracoConfigurationElement, IContentVersionCleanupPolicyGlobalSettings { [ConfigurationProperty("enable", DefaultValue = false)] public bool EnableCleanup => (bool)this["enable"]; diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/IContentSection.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/IContentSection.cs index 97893e0a91..d8ef2bb943 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/IContentSection.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/IContentSection.cs @@ -25,7 +25,7 @@ namespace Umbraco.Core.Configuration.UmbracoSettings IEnumerable AllowedUploadFiles { get; } - IContentVersionCleanupPolicySettings ContentVersionCleanupPolicySettings { get; } + IContentVersionCleanupPolicyGlobalSettings ContentVersionCleanupPolicyGlobalSettings { get; } /// /// Gets a value indicating whether to show deprecated property editors in diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/IContentVersionCleanupPolicySettings.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/IContentVersionCleanupPolicyGlobalSettings.cs similarity index 76% rename from src/Umbraco.Core/Configuration/UmbracoSettings/IContentVersionCleanupPolicySettings.cs rename to src/Umbraco.Core/Configuration/UmbracoSettings/IContentVersionCleanupPolicyGlobalSettings.cs index 66e9e17d97..20198d995d 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/IContentVersionCleanupPolicySettings.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/IContentVersionCleanupPolicyGlobalSettings.cs @@ -1,6 +1,6 @@ namespace Umbraco.Core.Configuration.UmbracoSettings { - public interface IContentVersionCleanupPolicySettings + public interface IContentVersionCleanupPolicyGlobalSettings { bool EnableCleanup { get; } int KeepAllVersionsNewerThanDays { get; } diff --git a/src/Umbraco.Core/Models/ContentVersionCleanupPolicySettings.cs b/src/Umbraco.Core/Models/ContentVersionCleanupPolicySettings.cs new file mode 100644 index 0000000000..90ff0572a0 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentVersionCleanupPolicySettings.cs @@ -0,0 +1,13 @@ +using System; + +namespace Umbraco.Core.Models +{ + public class ContentVersionCleanupPolicySettings + { + public int ContentTypeId { get; set; } + public int? KeepAllVersionsNewerThanDays { get; set; } + public int? KeepLatestVersionPerDayForDays { get; set; } + public bool PreventCleanup { get; set; } + public DateTime Updated { get; set; } + } +} diff --git a/src/Umbraco.Core/Models/HistoricContentVersionMeta.cs b/src/Umbraco.Core/Models/HistoricContentVersionMeta.cs new file mode 100644 index 0000000000..7eb3293711 --- /dev/null +++ b/src/Umbraco.Core/Models/HistoricContentVersionMeta.cs @@ -0,0 +1,24 @@ +using System; + +namespace Umbraco.Core.Models +{ + public class HistoricContentVersionMeta + { + public int ContentId { get; } + public int ContentTypeId { get; } + public int VersionId { get; } + public DateTime VersionDate { get; } + + public HistoricContentVersionMeta() { } + + public HistoricContentVersionMeta(int contentId, int contentTypeId, int versionId, DateTime versionDate) + { + ContentId = contentId; + ContentTypeId = contentTypeId; + VersionId = versionId; + VersionDate = versionDate; + } + + public override string ToString() => $"HistoricContentVersionMeta(versionId: {VersionId}, versionDate: {VersionDate:s}"; + } +} diff --git a/src/Umbraco.Core/Persistence/Dtos/ContentVersionCleanupPolicyDto.cs b/src/Umbraco.Core/Persistence/Dtos/ContentVersionCleanupPolicyDto.cs index 9b016138ff..abe7432775 100644 --- a/src/Umbraco.Core/Persistence/Dtos/ContentVersionCleanupPolicyDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/ContentVersionCleanupPolicyDto.cs @@ -6,14 +6,13 @@ using Umbraco.Core.Persistence.DatabaseAnnotations; namespace Umbraco.Core.Persistence.Dtos { [TableName(TableName)] - [PrimaryKey("contentTypeId")] + [PrimaryKey("contentTypeId", AutoIncrement = false)] [ExplicitColumns] internal class ContentVersionCleanupPolicyDto { public const string TableName = Constants.DatabaseSchema.Tables.ContentVersionCleanupPolicy; [Column("contentTypeId")] - [PrimaryKeyColumn(AutoIncrement = false)] [ForeignKey(typeof(ContentTypeDto), Column = "nodeId", OnDelete = Rule.Cascade)] public int ContentTypeId { get; set; } diff --git a/src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs new file mode 100644 index 0000000000..a299713985 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using Umbraco.Core.Models; + +namespace Umbraco.Core.Persistence.Repositories +{ + public interface IDocumentVersionRepository : IRepository + { + /// + /// Gets a list of all historic content versions. + /// + public IReadOnlyCollection GetDocumentVersionsEligibleForCleanup(); + + /// + /// Gets cleanup policy override settings per content type. + /// + public IReadOnlyCollection GetCleanupPolicies(); + + /// + /// Deletes multiple content versions by ID. + /// + void DeleteVersions(IEnumerable versionIds); + } +} diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentVersionRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentVersionRepository.cs new file mode 100644 index 0000000000..761b83924f --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentVersionRepository.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.Dtos; +using Umbraco.Core.Scoping; + +namespace Umbraco.Core.Persistence.Repositories.Implement +{ + internal class DocumentVersionRepository : IDocumentVersionRepository + { + private readonly IScopeAccessor _scopeAccessor; + + public DocumentVersionRepository(IScopeAccessor scopeAccessor) + { + _scopeAccessor = scopeAccessor ?? throw new ArgumentNullException(nameof(scopeAccessor)); + } + + /// + /// + /// Never includes current draft version.
+ /// Never includes current published version.
+ /// Never includes versions marked as "preventCleanup".
+ ///
+ public IReadOnlyCollection GetDocumentVersionsEligibleForCleanup() + { + var query = _scopeAccessor.AmbientScope.SqlContext.Sql(); + + query.Select(@"umbracoDocument.nodeId as contentId, + umbracoContent.contentTypeId as contentTypeId, + umbracoContentVersion.id as versionId, + umbracoContentVersion.versionDate as versionDate") + .From() + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .InnerJoin() + .On(left => left.Id, right => right.Id) + .Where(x => !x.Current) // Never delete current draft version + .Where(x => !x.PreventCleanup) // Never delete "pinned" versions + .Where(x => !x.Published); // Never delete published version + + return _scopeAccessor.AmbientScope.Database.Fetch(query); + } + + /// + public IReadOnlyCollection GetCleanupPolicies() + { + var query = _scopeAccessor.AmbientScope.SqlContext.Sql(); + + query.Select() + .From(); + + return _scopeAccessor.AmbientScope.Database.Fetch(query); + } + + /// + /// + /// Deletes in batches of + /// + public void DeleteVersions(IEnumerable versionIds) + { + foreach (var group in versionIds.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + var groupedVersionIds = group.ToList(); + + // Note: We had discussed doing this in a single SQL Command. + // If you can work out how to make that work with SQL CE, let me know! + // Can use test PerformContentVersionCleanup_WithNoKeepPeriods_DeletesEverythingExceptActive to try things out. + + var query = _scopeAccessor.AmbientScope.SqlContext.Sql() + .Delete() + .WhereIn(x => x.VersionId, groupedVersionIds); + _scopeAccessor.AmbientScope.Database.Execute(query); + + query = _scopeAccessor.AmbientScope.SqlContext.Sql() + .Delete() + .WhereIn(x => x.VersionId, groupedVersionIds); + _scopeAccessor.AmbientScope.Database.Execute(query); + + query = _scopeAccessor.AmbientScope.SqlContext.Sql() + .Delete() + .WhereIn(x => x.Id, groupedVersionIds); + _scopeAccessor.AmbientScope.Database.Execute(query); + + query = _scopeAccessor.AmbientScope.SqlContext.Sql() + .Delete() + .WhereIn(x => x.Id, groupedVersionIds); + _scopeAccessor.AmbientScope.Database.Execute(query); + } + } + } +} diff --git a/src/Umbraco.Core/Services/IContentVersionCleanupPolicy.cs b/src/Umbraco.Core/Services/IContentVersionCleanupPolicy.cs new file mode 100644 index 0000000000..9241481816 --- /dev/null +++ b/src/Umbraco.Core/Services/IContentVersionCleanupPolicy.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using Umbraco.Core.Models; + +namespace Umbraco.Core.Services +{ + /// + /// Used to filter historic content versions for cleanup. + /// + public interface IContentVersionCleanupPolicy + { + /// + /// Filters a set of candidates historic content versions for cleanup according to policy settings. + /// + IEnumerable Apply(DateTime asAtDate, IEnumerable items); + } +} diff --git a/src/Umbraco.Core/Services/IContentVersionCleanupService.cs b/src/Umbraco.Core/Services/IContentVersionCleanupService.cs new file mode 100644 index 0000000000..8b7e826f55 --- /dev/null +++ b/src/Umbraco.Core/Services/IContentVersionCleanupService.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using Umbraco.Core.Models; + +namespace Umbraco.Core.Services +{ + public interface IContentVersionCleanupService + { + /// + /// Removes historic content versions according to a policy. + /// + IReadOnlyCollection PerformContentVersionCleanup(DateTime asAtDate); + } +} diff --git a/src/Umbraco.Core/Services/Implement/ContentService.cs b/src/Umbraco.Core/Services/Implement/ContentService.cs index b50200c842..5f6782e0b4 100644 --- a/src/Umbraco.Core/Services/Implement/ContentService.cs +++ b/src/Umbraco.Core/Services/Implement/ContentService.cs @@ -19,7 +19,7 @@ namespace Umbraco.Core.Services.Implement /// /// Implements the content service. /// - public class ContentService : RepositoryService, IContentService + public class ContentService : RepositoryService, IContentService, IContentVersionCleanupService { private readonly IDocumentRepository _documentRepository; private readonly IEntityRepository _entityRepository; @@ -1438,7 +1438,7 @@ namespace Umbraco.Core.Services.Implement var result = CommitDocumentChangesInternal(scope, d, saveEventArgs, allLangs.Value, d.WriterId); if (result.Success == false) - Logger.Error(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); + Logger.Error(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); results.Add(result); } @@ -2452,7 +2452,7 @@ namespace Umbraco.Core.Services.Implement if (report.FixedIssues.Count > 0) { //The event args needs a content item so we'll make a fake one with enough properties to not cause a null ref - var root = new Content("root", -1, new ContentType(-1)) {Id = -1, Key = Guid.Empty}; + var root = new Content("root", -1, new ContentType(-1)) { Id = -1, Key = Guid.Empty }; scope.Events.Dispatch(TreeChanged, this, new TreeChange.EventArgs(new TreeChange(root, TreeChangeTypes.RefreshAll))); } @@ -3201,7 +3201,7 @@ namespace Umbraco.Core.Services.Implement if (rollbackSaveResult.Success == false) { //Log the error/warning - Logger.Error("User '{UserId}' was unable to rollback content '{ContentId}' to version '{VersionId}'", userId, id, versionId); + Logger.Error("User '{UserId}' was unable to rollback content '{ContentId}' to version '{VersionId}'", userId, id, versionId); } else { @@ -3210,7 +3210,7 @@ namespace Umbraco.Core.Services.Implement scope.Events.Dispatch(RolledBack, this, rollbackEventArgs); //Logging & Audit message - Logger.Info("User '{UserId}' rolled back content '{ContentId}' to version '{VersionId}'", userId, id, versionId); + Logger.Info("User '{UserId}' rolled back content '{ContentId}' to version '{VersionId}'", userId, id, versionId); Audit(AuditType.RollBack, userId, id, $"Content '{content.Name}' was rolled back to version '{versionId}'"); } @@ -3222,7 +3222,110 @@ namespace Umbraco.Core.Services.Implement #endregion + /// + /// + /// In v9 this can live in another class as we publish the notifications via IEventAggregator. + /// But for v8 must be here for access to the static events. + /// + public IReadOnlyCollection PerformContentVersionCleanup(DateTime asAtDate) + { + return CleanupDocumentVersions(asAtDate); + // Media - ignored + // Members - ignored + } + /// + /// v9 - move to another class + /// + private IReadOnlyCollection CleanupDocumentVersions(DateTime asAtDate) + { + // NOTE: v9 - don't service locate + var documentVersionRepository = Composing.Current.Factory.GetInstance(); + // NOTE: v9 - don't service locate + var cleanupPolicy = Composing.Current.Factory.GetInstance(); + + List 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 (var scope = ScopeProvider.CreateScope(autoComplete: true)) + { + var allHistoricVersions = documentVersionRepository.GetDocumentVersionsEligibleForCleanup(); + + Logger.Debug("Discovered {count} candidate(s) for ContentVersion cleanup.", allHistoricVersions.Count); + versionsToDelete = new List(allHistoricVersions.Count); + + var filteredContentVersions = cleanupPolicy.Apply(asAtDate, allHistoricVersions); + + foreach (var version in filteredContentVersions) + { + var args = new DeleteRevisionsEventArgs(version.ContentId, version.VersionId); + + if (scope.Events.DispatchCancelable(ContentService.DeletingVersions, this, args)) + { + Logger.Debug("Delete cancelled for ContentVersion [{versionId}]", version.VersionId); + continue; + } + + versionsToDelete.Add(version); + } + } + + if (!versionsToDelete.Any()) + { + Logger.Debug("No remaining ContentVersions for cleanup.", versionsToDelete.Count); + return Array.Empty(); + } + + Logger.Debug("Removing {count} ContentVersion(s).", versionsToDelete.Count); + + foreach (var group in versionsToDelete.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + { + scope.WriteLock(Constants.Locks.ContentTree); + var groupEnumerated = group.ToList(); + documentVersionRepository.DeleteVersions(groupEnumerated.Select(x => x.VersionId)); + + foreach (var version in groupEnumerated) + { + var args = new DeleteRevisionsEventArgs(version.ContentId, version.VersionId); + scope.Events.Dispatch(ContentService.DeletedVersions, this, args); + } + } + } + + using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + { + Audit(AuditType.Delete, Constants.Security.SuperUserId, -1, $"Removed {versionsToDelete.Count} ContentVersion(s) according to cleanup policy."); + } + + return versionsToDelete; + } } } diff --git a/src/Umbraco.Core/Services/Implement/DefaultContentVersionCleanupPolicy.cs b/src/Umbraco.Core/Services/Implement/DefaultContentVersionCleanupPolicy.cs new file mode 100644 index 0000000000..014c6ff113 --- /dev/null +++ b/src/Umbraco.Core/Services/Implement/DefaultContentVersionCleanupPolicy.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.Repositories; +using Umbraco.Core.Scoping; + +namespace Umbraco.Core.Services.Implement +{ + public class DefaultContentVersionCleanupPolicy : IContentVersionCleanupPolicy + { + private readonly IContentSection _contentSection; + private readonly IScopeProvider _scopeProvider; + private readonly IDocumentVersionRepository _documentVersionRepository; + + public DefaultContentVersionCleanupPolicy(IContentSection contentSection, IScopeProvider scopeProvider, IDocumentVersionRepository documentVersionRepository) + { + _contentSection = contentSection ?? throw new ArgumentNullException(nameof(contentSection)); + _scopeProvider = scopeProvider ?? throw new ArgumentNullException(nameof(scopeProvider)); + _documentVersionRepository = documentVersionRepository ?? throw new ArgumentNullException(nameof(documentVersionRepository)); + } + + public IEnumerable Apply(DateTime asAtDate, IEnumerable items) + { + // Note: Not checking global enable flag, that's handled in the scheduled job. + // If this method is called and policy is globally disabled someone has chosen to run in code. + + var globalPolicy = _contentSection.ContentVersionCleanupPolicyGlobalSettings; + + var theRest = new List(); + + using(_scopeProvider.CreateScope(autoComplete: true)) + { + var policyOverrides = _documentVersionRepository.GetCleanupPolicies() + .ToDictionary(x => x.ContentTypeId); + + foreach (var version in items) + { + var age = asAtDate - version.VersionDate; + + var overrides = GetOverridePolicy(version, policyOverrides); + + var keepAll = overrides?.KeepAllVersionsNewerThanDays ?? globalPolicy.KeepAllVersionsNewerThanDays!; + var keepLatest = overrides?.KeepLatestVersionPerDayForDays ?? globalPolicy.KeepLatestVersionPerDayForDays; + var preventCleanup = overrides?.PreventCleanup ?? false; + + if (preventCleanup) + { + continue; + } + + if (age.TotalDays <= keepAll) + { + continue; + } + + if (age.TotalDays > keepLatest) + { + + yield return version; + continue; + } + + theRest.Add(version); + } + + var grouped = theRest.GroupBy(x => new + { + x.ContentId, + x.VersionDate.Date + }); + + foreach (var group in grouped) + { + yield return group.OrderByDescending(x => x.VersionId).First(); + } + } + } + + private ContentVersionCleanupPolicySettings GetOverridePolicy( + HistoricContentVersionMeta version, + IDictionary overrides) + { + _ = overrides.TryGetValue(version.ContentTypeId, out var value); + + return value; + } + } +} diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 696de73fd0..818891b376 100755 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -130,8 +130,8 @@ - - + + @@ -163,6 +163,7 @@ + @@ -179,8 +180,11 @@ + + + @@ -193,7 +197,10 @@ + + + diff --git a/src/Umbraco.Tests/Configurations/UmbracoSettings/umbracoSettings.config b/src/Umbraco.Tests/Configurations/UmbracoSettings/umbracoSettings.config index d802cfc7ad..707887bc8b 100644 --- a/src/Umbraco.Tests/Configurations/UmbracoSettings/umbracoSettings.config +++ b/src/Umbraco.Tests/Configurations/UmbracoSettings/umbracoSettings.config @@ -57,6 +57,8 @@ jpg,png,gif + + diff --git a/src/Umbraco.Tests/Persistence/Repositories/DocumentVersionRepository_Tests_Integration.cs b/src/Umbraco.Tests/Persistence/Repositories/DocumentVersionRepository_Tests_Integration.cs new file mode 100644 index 0000000000..9c012bf083 --- /dev/null +++ b/src/Umbraco.Tests/Persistence/Repositories/DocumentVersionRepository_Tests_Integration.cs @@ -0,0 +1,125 @@ +using System.Diagnostics; +using System.Linq; +using NUnit.Framework; +using Umbraco.Core.Persistence; +using Umbraco.Core.Persistence.Dtos; +using Umbraco.Core.Persistence.Repositories.Implement; +using Umbraco.Core.Scoping; +using Umbraco.Tests.TestHelpers; +using Umbraco.Tests.TestHelpers.Entities; +using Umbraco.Tests.Testing; + +namespace Umbraco.Tests.Persistence.Repositories +{ + /// + /// v9 -> Tests.Integration + /// + [TestFixture] + [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] + public class DocumentVersionRepository_Tests_Integration : TestWithDatabaseBase + { + [Test] + public void GetDocumentVersionsEligibleForCleanup_Always_ExcludesActiveVersions() + { + var contentType = MockedContentTypes.CreateSimpleContentType("umbTextpage", "Textpage"); + ServiceContext.FileService.SaveTemplate(contentType.DefaultTemplate); + ServiceContext.ContentTypeService.Save(contentType); + + var content = MockedContent.CreateSimpleContent(contentType); + + ServiceContext.ContentService.SaveAndPublish(content); + // At this point content has 2 versions, a draft version and a published version. + + ServiceContext.ContentService.SaveAndPublish(content); + // At this point content has 3 versions, a historic version, a draft version and a published version. + + var scopeProvider = TestObjects.GetScopeProvider(Logger); + using (scopeProvider.CreateScope()) + { + var sut = new DocumentVersionRepository((IScopeAccessor)scopeProvider); + var results = sut.GetDocumentVersionsEligibleForCleanup(); + + Assert.Multiple(() => + { + Assert.AreEqual(1, results.Count); + Assert.AreEqual(1, results.First().VersionId); + }); + } + } + + [Test] + public void GetDocumentVersionsEligibleForCleanup_Always_ExcludesPinnedVersions() + { + var contentType = MockedContentTypes.CreateSimpleContentType("umbTextpage", "Textpage"); + ServiceContext.FileService.SaveTemplate(contentType.DefaultTemplate); + ServiceContext.ContentTypeService.Save(contentType); + + var content = MockedContent.CreateSimpleContent(contentType); + + ServiceContext.ContentService.SaveAndPublish(content); + // At this point content has 2 versions, a draft version and a published version. + ServiceContext.ContentService.SaveAndPublish(content); + ServiceContext.ContentService.SaveAndPublish(content); + ServiceContext.ContentService.SaveAndPublish(content); + // At this point content has 5 versions, 3 historic versions, a draft version and a published version. + + var allVersions = ServiceContext.ContentService.GetVersions(content.Id); + Debug.Assert(allVersions.Count() == 5); // Sanity check + + var scopeProvider = TestObjects.GetScopeProvider(Logger); + using (var scope = scopeProvider.CreateScope()) + { + scope.Database.Update("set preventCleanup = 1 where id in (1,3)"); + + var sut = new DocumentVersionRepository((IScopeAccessor)scopeProvider); + var results = sut.GetDocumentVersionsEligibleForCleanup(); + + Assert.Multiple(() => + { + Assert.AreEqual(1, results.Count); + + // We pinned 1 & 3 + // 4 is current + // 5 is published + // So all that is left is 2 + Assert.AreEqual(2, results.First().VersionId); + }); + } + } + + [Test] + public void DeleteVersions_Always_DeletesSpecifiedVersions() + { + var contentType = MockedContentTypes.CreateSimpleContentType("umbTextpage", "Textpage"); + ServiceContext.FileService.SaveTemplate(contentType.DefaultTemplate); + ServiceContext.ContentTypeService.Save(contentType); + + var content = MockedContent.CreateSimpleContent(contentType); + + ServiceContext.ContentService.SaveAndPublish(content); + ServiceContext.ContentService.SaveAndPublish(content); + ServiceContext.ContentService.SaveAndPublish(content); + ServiceContext.ContentService.SaveAndPublish(content); + + var scopeProvider = TestObjects.GetScopeProvider(Logger); + using (var scope = scopeProvider.CreateScope()) + { + var query = scope.SqlContext.Sql(); + + query.Select() + .From(); + + var sut = new DocumentVersionRepository((IScopeAccessor)scopeProvider); + sut.DeleteVersions(new []{1,2,3}); + + var after = scope.Database.Fetch(query); + + Assert.Multiple(() => + { + Assert.AreEqual(2, after.Count); + Assert.True(after.All(x => x.Id > 3)); + }); + } + } + } +} diff --git a/src/Umbraco.Tests/Scheduling/ContentVersionCleanup_Tests_UnitTests.cs b/src/Umbraco.Tests/Scheduling/ContentVersionCleanup_Tests_UnitTests.cs new file mode 100644 index 0000000000..82c5c0077e --- /dev/null +++ b/src/Umbraco.Tests/Scheduling/ContentVersionCleanup_Tests_UnitTests.cs @@ -0,0 +1,127 @@ +using System; +using AutoFixture.NUnit3; +using Moq; +using NUnit.Framework; +using Umbraco.Core; +using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Core.Services; +using Umbraco.Core.Sync; +using Umbraco.Tests.Testing; +using Umbraco.Web.Scheduling; + +namespace Umbraco.Tests.Scheduling +{ + [TestFixture] + class ContentVersionCleanup_Tests_UnitTests + { + [Test, AutoMoqData] + public void ContentVersionCleanup_WhenNotEnabled_DoesNotCleanupWillRepeat( + [Frozen] Mock settings, + [Frozen] Mock state, + [Frozen] Mock cleanupService, + ContentVersionCleanup sut) + { + settings.Setup(x => x.EnableCleanup).Returns(false); + + state.Setup(x => x.Level).Returns(RuntimeLevel.Run); + state.Setup(x => x.IsMainDom).Returns(true); + state.Setup(x => x.ServerRole).Returns(ServerRole.Master); + + var result = sut.PerformRun(); + + Assert.Multiple(() => + { + Assert.False(result); + cleanupService.Verify(x => x.PerformContentVersionCleanup(It.IsAny()), Times.Never); + }); + } + + [Test, AutoMoqData] + public void ContentVersionCleanup_RuntimeLevelNotRun_DoesNotCleanupWillRepeat( + [Frozen] Mock settings, + [Frozen] Mock state, + [Frozen] Mock cleanupService, + ContentVersionCleanup sut) + { + settings.Setup(x => x.EnableCleanup).Returns(true); + + state.Setup(x => x.Level).Returns(RuntimeLevel.Unknown); + state.Setup(x => x.IsMainDom).Returns(true); + state.Setup(x => x.ServerRole).Returns(ServerRole.Master); + + var result = sut.PerformRun(); + + Assert.Multiple(() => + { + Assert.True(result); + cleanupService.Verify(x => x.PerformContentVersionCleanup(It.IsAny()), Times.Never); + }); + } + + [Test, AutoMoqData] + public void ContentVersionCleanup_ServerRoleUnknown_DoesNotCleanupWillRepeat( + [Frozen] Mock settings, + [Frozen] Mock state, + [Frozen] Mock cleanupService, + ContentVersionCleanup sut) + { + settings.Setup(x => x.EnableCleanup).Returns(true); + + state.Setup(x => x.Level).Returns(RuntimeLevel.Run); + state.Setup(x => x.IsMainDom).Returns(true); + state.Setup(x => x.ServerRole).Returns(ServerRole.Unknown); + + var result = sut.PerformRun(); + + Assert.Multiple(() => + { + Assert.True(result); + cleanupService.Verify(x => x.PerformContentVersionCleanup(It.IsAny()), Times.Never); + }); + } + + [Test, AutoMoqData] + public void ContentVersionCleanup_NotMainDom_DoesNotCleanupWillNotRepeat( + [Frozen] Mock settings, + [Frozen] Mock state, + [Frozen] Mock cleanupService, + ContentVersionCleanup sut) + { + settings.Setup(x => x.EnableCleanup).Returns(true); + + state.Setup(x => x.Level).Returns(RuntimeLevel.Run); + state.Setup(x => x.IsMainDom).Returns(false); + state.Setup(x => x.ServerRole).Returns(ServerRole.Master); + + var result = sut.PerformRun(); + + Assert.Multiple(() => + { + Assert.False(result); + cleanupService.Verify(x => x.PerformContentVersionCleanup(It.IsAny()), Times.Never); + }); + } + + [Test, AutoMoqData] + public void ContentVersionCleanup_Enabled_DelegatesToCleanupService( + [Frozen] Mock settings, + [Frozen] Mock state, + [Frozen] Mock cleanupService, + ContentVersionCleanup sut) + { + settings.Setup(x => x.EnableCleanup).Returns(true); + + state.Setup(x => x.Level).Returns(RuntimeLevel.Run); + state.Setup(x => x.IsMainDom).Returns(true); + state.Setup(x => x.ServerRole).Returns(ServerRole.Master); + + var result = sut.PerformRun(); + + Assert.Multiple(() => + { + Assert.True(result); + cleanupService.Verify(x => x.PerformContentVersionCleanup(It.IsAny()), Times.Once); + }); + } + } +} diff --git a/src/Umbraco.Tests/Services/ContentVersionCleanupService_Tests_Integration.cs b/src/Umbraco.Tests/Services/ContentVersionCleanupService_Tests_Integration.cs new file mode 100644 index 0000000000..3e6fe63e13 --- /dev/null +++ b/src/Umbraco.Tests/Services/ContentVersionCleanupService_Tests_Integration.cs @@ -0,0 +1,106 @@ +using System; +using System.Data; +using System.Diagnostics; +using NUnit.Framework; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.Dtos; +using Umbraco.Core.Services; +using Umbraco.Tests.TestHelpers; +using Umbraco.Tests.TestHelpers.Entities; +using Umbraco.Tests.Testing; + +namespace Umbraco.Tests.Services +{ + [TestFixture] + [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] + public class ContentVersionCleanupService_Tests_Integration : TestWithDatabaseBase + { + /// + /// This is covered by the unit tests, but nice to know it deletes on infra. + /// And proves implementation is compatible with SQL CE. + /// + [Test] + public void PerformContentVersionCleanup_WithNoKeepPeriods_DeletesEverythingExceptActive() + { + // For reference currently has + // 5000 Documents + // With 200K Versions + // With 11M Property data + + var contentTypeA = MockedContentTypes.CreateSimpleContentType("contentTypeA", "contentTypeA"); + ServiceContext.FileService.SaveTemplate(contentTypeA.DefaultTemplate); + ServiceContext.ContentTypeService.Save(contentTypeA); + + var content = MockedContent.CreateSimpleContent(contentTypeA); + ServiceContext.ContentService.SaveAndPublish(content, raiseEvents: false); + + for (var i = 0; i < 10; i++) + { + ServiceContext.ContentService.SaveAndPublish(content, raiseEvents: false); + } + + var before = GetReport(); + + Debug.Assert(before.ContentVersions == 12); // 10 historic + current draft + current published + Debug.Assert(before.PropertyData == 12 * 3); // CreateSimpleContentType = 3 props + + // Kill all historic + InsertCleanupPolicy(contentTypeA, 0, 0); + + ((IContentVersionCleanupService)ServiceContext.ContentService).PerformContentVersionCleanup(DateTime.Now.AddHours(1)); + + var after = GetReport(); + + Assert.Multiple(() => + { + Assert.AreEqual(2, after.ContentVersions); // current draft, current published + Assert.AreEqual(2, after.DocumentVersions); + Assert.AreEqual(6, after.PropertyData); // CreateSimpleContentType = 3 props + }); + } + + private Report GetReport() + { + var scopeProvider = TestObjects.GetScopeProvider(Logger); + using (var scope = scopeProvider.CreateScope(autoComplete: true)) + { + // SQL CE is fun! + var contentVersions = scope.Database.Single(@"select count(1) from umbracoContentVersion"); + var documentVersions = scope.Database.Single(@"select count(1) from umbracoDocumentVersion"); + var propertyData = scope.Database.Single(@"select count(1) from umbracoPropertyData"); + + return new Report + { + ContentVersions = contentVersions, + DocumentVersions = documentVersions, + PropertyData = propertyData + }; + } + } + + private void InsertCleanupPolicy(IContentType contentType, int daysToKeepAll, int daysToRollupAll, bool preventCleanup = false) + { + var scopeProvider = TestObjects.GetScopeProvider(Logger); + using (var scope = scopeProvider.CreateScope(autoComplete: true)) + { + var entity = new ContentVersionCleanupPolicyDto + { + ContentTypeId = contentType.Id, + KeepAllVersionsNewerThanDays = daysToKeepAll, + KeepLatestVersionPerDayForDays = daysToRollupAll, + PreventCleanup = preventCleanup, + Updated = DateTime.Today + }; + + scope.Database.Insert(entity); + } + } + + class Report + { + public int ContentVersions { get; set; } + public int DocumentVersions { get; set; } + public int PropertyData { get; set; } + } + } +} diff --git a/src/Umbraco.Tests/Services/ContentVersionCleanupService_Tests_UnitTests.cs b/src/Umbraco.Tests/Services/ContentVersionCleanupService_Tests_UnitTests.cs new file mode 100644 index 0000000000..f72b61b4ce --- /dev/null +++ b/src/Umbraco.Tests/Services/ContentVersionCleanupService_Tests_UnitTests.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using AutoFixture.NUnit3; +using Moq; +using NUnit.Framework; +using Umbraco.Core.Composing; +using Umbraco.Core.Events; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.Repositories; +using Umbraco.Core.Scoping; +using Umbraco.Core.Services; +using Umbraco.Core.Services.Implement; +using Umbraco.Tests.Testing; + +namespace Umbraco.Tests.Services +{ + /// + /// v9 -> Tests.UnitTests + /// Sut here is ContentService, but in v9 should be a new class + /// + [TestFixture] + public class ContentVersionCleanupService_Tests_UnitTests + { + [SetUp] + public void Setup() + { + Current.Reset(); + } + + /// + /// For v9 this just needs a rewrite, no static events, no service location etc + /// + [Test, AutoMoqData] + public void PerformContentVersionCleanup_Always_RespectsDeleteRevisionsCancellation( + [Frozen] Mock factory, + [Frozen] Mock scope, + Mock documentVersionRepository, + List someHistoricVersions, + DateTime aDateTime, + ContentService sut) + { + factory.Setup(x => x.GetInstance(typeof(IDocumentVersionRepository))) + .Returns(documentVersionRepository.Object); + + factory.Setup(x => x.GetInstance(typeof(IContentVersionCleanupPolicy))) + .Returns(new EchoingCleanupPolicyStub()); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(someHistoricVersions); + + scope.Setup(x => x.Events).Returns(new PassThroughEventDispatcher()); + + // Wire up service locator + Current.Factory = factory.Object; + + void OnDeletingVersions(IContentService sender, DeleteRevisionsEventArgs args) => args.Cancel = true; + + ContentService.DeletingVersions += OnDeletingVersions; + + // # Act + var report = sut.PerformContentVersionCleanup(aDateTime); + + ContentService.DeletingVersions -= OnDeletingVersions; + + Assert.AreEqual(0, report.Count); + } + + /// + /// For v9 this just needs a rewrite, no static events, no service location etc + /// + [Test, AutoMoqData] + public void PerformContentVersionCleanup_Always_FiresDeletedVersionsForEachDeletedVersion( + [Frozen] Mock factory, + [Frozen] Mock scope, + Mock documentVersionRepository, + List someHistoricVersions, + DateTime aDateTime, + ContentService sut) + { + factory.Setup(x => x.GetInstance(typeof(IDocumentVersionRepository))) + .Returns(documentVersionRepository.Object); + + factory.Setup(x => x.GetInstance(typeof(IContentVersionCleanupPolicy))) + .Returns(new EchoingCleanupPolicyStub()); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(someHistoricVersions); + + scope.Setup(x => x.Events).Returns(new PassThroughEventDispatcher()); + + // Wire up service locator + Current.Factory = factory.Object; + + // v9 can Mock + Verify + var deletedAccordingToEvents = 0; + void OnDeletedVersions(IContentService sender, DeleteRevisionsEventArgs args) => deletedAccordingToEvents++; + + ContentService.DeletedVersions += OnDeletedVersions; + + // # Act + sut.PerformContentVersionCleanup(aDateTime); + + ContentService.DeletedVersions -= OnDeletedVersions; + + Assert.Multiple(() => + { + Assert.Greater(deletedAccordingToEvents, 0); + Assert.AreEqual(someHistoricVersions.Count, deletedAccordingToEvents); + }); + } + + /// + /// For v9 this just needs a rewrite, no static events, no service location etc + /// + [Test, AutoMoqData] + public void PerformContentVersionCleanup_Always_ReturnsReportOfDeletedItems( + [Frozen] Mock factory, + [Frozen] Mock scope, + Mock documentVersionRepository, + List someHistoricVersions, + DateTime aDateTime, + ContentService sut) + { + factory.Setup(x => x.GetInstance(typeof(IDocumentVersionRepository))) + .Returns(documentVersionRepository.Object); + + factory.Setup(x => x.GetInstance(typeof(IContentVersionCleanupPolicy))) + .Returns(new EchoingCleanupPolicyStub()); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(someHistoricVersions); + + scope.Setup(x => x.Events).Returns(new PassThroughEventDispatcher()); + + // Wire up service locator + Current.Factory = factory.Object; + + // # Act + var report = sut.PerformContentVersionCleanup(aDateTime); + + Assert.Multiple(() => + { + Assert.Greater(report.Count, 0); + Assert.AreEqual(someHistoricVersions.Count, report.Count); + }); + } + + /// + /// For v9 this just needs a rewrite, no static events, no service location etc + /// + [Test, AutoMoqData] + public void PerformContentVersionCleanup_Always_AdheresToCleanupPolicy( + [Frozen] Mock factory, + [Frozen] Mock scope, + Mock documentVersionRepository, + Mock cleanupPolicy, + List someHistoricVersions, + DateTime aDateTime, + ContentService sut) + { + factory.Setup(x => x.GetInstance(typeof(IDocumentVersionRepository))) + .Returns(documentVersionRepository.Object); + + factory.Setup(x => x.GetInstance(typeof(IContentVersionCleanupPolicy))) + .Returns(cleanupPolicy.Object); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(someHistoricVersions); + + scope.Setup(x => x.Events).Returns(new PassThroughEventDispatcher()); + + cleanupPolicy.Setup(x => x.Apply(It.IsAny(), It.IsAny>())) + .Returns>((_, items) => items.Take(1)); + + // Wire up service locator + Current.Factory = factory.Object; + + // # Act + var report = sut.PerformContentVersionCleanup(aDateTime); + + Debug.Assert(someHistoricVersions.Count > 1); + + Assert.Multiple(() => + { + cleanupPolicy.Verify(x => x.Apply(aDateTime, someHistoricVersions), Times.Once); + Assert.AreEqual(someHistoricVersions.First(), report.Single()); + }); + } + + /// + /// For v9 this just needs a rewrite, no static events, no service location etc + /// + [Test, AutoMoqData] + public void PerformContentVersionCleanup_HasVersionsToDelete_CallsDeleteOnRepositoryWithFilteredSet( + [Frozen] Mock factory, + [Frozen] Mock scope, + Mock documentVersionRepository, + Mock cleanupPolicy, + List someHistoricVersions, + DateTime aDateTime, + ContentService sut) + { + factory.Setup(x => x.GetInstance(typeof(IDocumentVersionRepository))) + .Returns(documentVersionRepository.Object); + + factory.Setup(x => x.GetInstance(typeof(IContentVersionCleanupPolicy))) + .Returns(cleanupPolicy.Object); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(someHistoricVersions); + + scope.Setup(x => x.Events).Returns(new PassThroughEventDispatcher()); + + var filteredSet = someHistoricVersions.Take(1); + + cleanupPolicy.Setup(x => x.Apply(It.IsAny(), It.IsAny>())) + .Returns>((_, items) => filteredSet); + + // Wire up service locator + Current.Factory = factory.Object; + + // # Act + var report = sut.PerformContentVersionCleanup(aDateTime); + + Debug.Assert(someHistoricVersions.Any()); + + var expectedId = filteredSet.First().VersionId; + + documentVersionRepository.Verify(x => x.DeleteVersions(It.Is>(y => y.Single() == expectedId)), Times.Once); + } + + class EchoingCleanupPolicyStub : IContentVersionCleanupPolicy + { + /// + /// What goes in, must come out + /// + public EchoingCleanupPolicyStub() { } + + /* Note: Could just wire up a mock but its quite wordy. + * + * cleanupPolicy.Setup(x => x.Apply(It.IsAny(), It.IsAny>())) + * .Returns>((date, items) => items); + */ + public IEnumerable Apply( + DateTime asAtDate, + IEnumerable items + ) => items; + } + + /// + /// NPoco < 5 requires a parameter-less constructor but plays nice with get-only properties. + /// Moq won't play nice with get-only properties, but doesn't require a parameter-less constructor. + /// + /// Inheritance solves this so that we get values for test data without a specimen builder + /// + public class TestHistoricContentVersionMeta : HistoricContentVersionMeta + { + public TestHistoricContentVersionMeta(int contentId, int contentTypeId, int versionId, DateTime versionDate) + : base(contentId, contentTypeId, versionId, versionDate) { } + } + } +} diff --git a/src/Umbraco.Tests/Services/DefaultContentVersionCleanupPolicy_Tests_UnitTests.cs b/src/Umbraco.Tests/Services/DefaultContentVersionCleanupPolicy_Tests_UnitTests.cs new file mode 100644 index 0000000000..c20bcc72ef --- /dev/null +++ b/src/Umbraco.Tests/Services/DefaultContentVersionCleanupPolicy_Tests_UnitTests.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutoFixture.NUnit3; +using Moq; +using NUnit.Framework; +using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.Repositories; +using Umbraco.Core.Services.Implement; +using Umbraco.Tests.Testing; + +namespace Umbraco.Tests.Services +{ + [TestFixture] + public class DefaultContentVersionCleanupPolicy_Tests_UnitTests + { + [Test, AutoMoqData] + public void Apply_AllOlderThanKeepSettings_AllVersionsReturned( + [Frozen] Mock documentVersionRepository, + [Frozen] Mock globalSettings, + DefaultContentVersionCleanupPolicy sut) + { + var versionId = 0; + + var historicItems = new List + { + new HistoricContentVersionMeta(versionId: ++versionId, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + new HistoricContentVersionMeta(versionId: ++versionId, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + }; + + globalSettings.Setup(x => x.ContentVersionCleanupPolicyGlobalSettings) + .Returns(new TestCleanupSettings(true, 0, 0)); + + documentVersionRepository.Setup(x => x.GetCleanupPolicies()) + .Returns(Array.Empty()); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(historicItems); + + var results = sut.Apply(DateTime.Today, historicItems).ToList(); + + Assert.AreEqual(2, results.Count); + } + + [Test, AutoMoqData] + public void Apply_OverlappingKeepSettings_KeepAllVersionsNewerThanDaysTakesPriority( + [Frozen] Mock documentVersionRepository, + [Frozen] Mock globalSettings, + DefaultContentVersionCleanupPolicy sut) + { + var versionId = 0; + + var historicItems = new List + { + new HistoricContentVersionMeta(versionId: ++versionId, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + new HistoricContentVersionMeta(versionId: ++versionId, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + }; + + globalSettings.Setup(x => x.ContentVersionCleanupPolicyGlobalSettings) + .Returns(new TestCleanupSettings(true, 2, 2)); + + documentVersionRepository.Setup(x => x.GetCleanupPolicies()) + .Returns(Array.Empty()); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(historicItems); + + var results = sut.Apply(DateTime.Today, historicItems).ToList(); + + Assert.AreEqual(0, results.Count); + } + + [Test, AutoMoqData] + public void Apply_WithinInKeepLatestPerDay_ReturnsSinglePerContentPerDay( + [Frozen] Mock documentVersionRepository, + [Frozen] Mock globalSettings, + DefaultContentVersionCleanupPolicy sut) + { + var historicItems = new List + { + new HistoricContentVersionMeta(versionId: 1, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 2, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 3, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + + new HistoricContentVersionMeta(versionId: 4, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddDays(-1).AddHours(-3)), + new HistoricContentVersionMeta(versionId: 5, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddDays(-1).AddHours(-2)), + new HistoricContentVersionMeta(versionId: 6, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddDays(-1).AddHours(-1)), + // another content + new HistoricContentVersionMeta(versionId: 7, contentId: 2, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 8, contentId: 2, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 9, contentId: 2, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + }; + + globalSettings.Setup(x => x.ContentVersionCleanupPolicyGlobalSettings) + .Returns(new TestCleanupSettings(true, 0, 3)); + + documentVersionRepository.Setup(x => x.GetCleanupPolicies()) + .Returns(Array.Empty()); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(historicItems); + + var results = sut.Apply(DateTime.Today, historicItems).ToList(); + + Assert.Multiple(() => + { + Assert.AreEqual(3, results.Count); + Assert.True(results.Exists(x => x.VersionId == 3)); + Assert.True(results.Exists(x => x.VersionId == 6)); + Assert.True(results.Exists(x => x.VersionId == 9)); + }); + } + + [Test, AutoMoqData] + public void Apply_HasOverridePolicy_RespectsPreventCleanup( + [Frozen] Mock documentVersionRepository, + [Frozen] Mock globalSettings, + DefaultContentVersionCleanupPolicy sut) + { + var historicItems = new List + { + new HistoricContentVersionMeta(versionId: 1, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 2, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 3, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + // another content & type + new HistoricContentVersionMeta(versionId: 4, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 5, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 6, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-1)), + }; + + globalSettings.Setup(x => x.ContentVersionCleanupPolicyGlobalSettings) + .Returns(new TestCleanupSettings(true, 0, 0)); + + documentVersionRepository.Setup(x => x.GetCleanupPolicies()) + .Returns(new ContentVersionCleanupPolicySettings[] + { + new ContentVersionCleanupPolicySettings{ ContentTypeId = 2, PreventCleanup = true } + }); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(historicItems); + + var results = sut.Apply(DateTime.Today, historicItems).ToList(); + + Assert.True(results.All(x => x.ContentTypeId == 1)); + } + + [Test, AutoMoqData] + public void Apply_HasOverridePolicy_RespectsKeepAll( + [Frozen] Mock documentVersionRepository, + [Frozen] Mock globalSettings, + DefaultContentVersionCleanupPolicy sut) + { + var historicItems = new List + { + new HistoricContentVersionMeta(versionId: 1, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 2, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 3, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + // another content & type + new HistoricContentVersionMeta(versionId: 4, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 5, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 6, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-1)), + }; + + globalSettings.Setup(x => x.ContentVersionCleanupPolicyGlobalSettings) + .Returns(new TestCleanupSettings(true, 0, 0)); + + documentVersionRepository.Setup(x => x.GetCleanupPolicies()) + .Returns(new ContentVersionCleanupPolicySettings[] + { + new ContentVersionCleanupPolicySettings{ ContentTypeId = 2, PreventCleanup = false, KeepAllVersionsNewerThanDays = 3 } + }); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(historicItems); + + var results = sut.Apply(DateTime.Today, historicItems).ToList(); + + Assert.True(results.All(x => x.ContentTypeId == 1)); + } + + [Test, AutoMoqData] + public void Apply_HasOverridePolicy_RespectsKeepLatest( + [Frozen] Mock documentVersionRepository, + [Frozen] Mock globalSettings, + DefaultContentVersionCleanupPolicy sut) + { + var historicItems = new List + { + new HistoricContentVersionMeta(versionId: 1, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 2, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 3, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + // another content & type + new HistoricContentVersionMeta(versionId: 4, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 5, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 6, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-1)), + }; + + globalSettings.Setup(x => x.ContentVersionCleanupPolicyGlobalSettings) + .Returns(new TestCleanupSettings(true, 0, 0)); + + documentVersionRepository.Setup(x => x.GetCleanupPolicies()) + .Returns(new ContentVersionCleanupPolicySettings[] + { + new ContentVersionCleanupPolicySettings{ ContentTypeId = 2, PreventCleanup = false, KeepLatestVersionPerDayForDays = 3 } + }); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(historicItems); + + var results = sut.Apply(DateTime.Today, historicItems).ToList(); + + Assert.Multiple(() => + { + Assert.AreEqual(3, results.Count(x => x.ContentTypeId == 1)); + Assert.AreEqual(6, results.Single(x => x.ContentTypeId == 2).VersionId); + }); + } + + class TestCleanupSettings : IContentVersionCleanupPolicyGlobalSettings + { + public bool EnableCleanup { get; set; } + public int KeepAllVersionsNewerThanDays { get; set; } + public int KeepLatestVersionPerDayForDays { get; set; } + + public TestCleanupSettings() { } + + public TestCleanupSettings(bool enable, int keepDays, int keepLatestDays) + { + EnableCleanup = enable; + KeepAllVersionsNewerThanDays = keepDays; + KeepLatestVersionPerDayForDays = keepLatestDays; + } + } + } +} diff --git a/src/Umbraco.Tests/TestHelpers/Entities/MockedContent.cs b/src/Umbraco.Tests/TestHelpers/Entities/MockedContent.cs index d8902d2d62..0de9a43ecd 100644 --- a/src/Umbraco.Tests/TestHelpers/Entities/MockedContent.cs +++ b/src/Umbraco.Tests/TestHelpers/Entities/MockedContent.cs @@ -19,7 +19,15 @@ namespace Umbraco.Tests.TestHelpers.Entities public static Content CreateSimpleContent(IContentType contentType) { - var content = new Content("Home", -1, contentType) { Level = 1, SortOrder = 1, CreatorId = 0, WriterId = 0 }; + var content = new Content("Home", -1, contentType) + { + Level = 1, + SortOrder = 1, + CreatorId = 0, + WriterId = 0, + Key = Guid.NewGuid() + }; + object obj = new { diff --git a/src/Umbraco.Tests/TestHelpers/Entities/MockedContentTypes.cs b/src/Umbraco.Tests/TestHelpers/Entities/MockedContentTypes.cs index d4bf4d14ec..123f59fae8 100644 --- a/src/Umbraco.Tests/TestHelpers/Entities/MockedContentTypes.cs +++ b/src/Umbraco.Tests/TestHelpers/Entities/MockedContentTypes.cs @@ -216,6 +216,7 @@ namespace Umbraco.Tests.TestHelpers.Entities contentType.SortOrder = 1; contentType.CreatorId = 0; contentType.Trashed = false; + contentType.Key = Guid.NewGuid(); var contentCollection = new PropertyTypeCollection(true); contentCollection.Add(new PropertyType(Constants.PropertyEditors.Aliases.TextBox, ValueStorageType.Ntext) { Alias = RandomAlias("title", randomizeAliases), Name = "Title", Description = "", Mandatory = false, SortOrder = 1, DataTypeId = -88, LabelOnTop = true }); diff --git a/src/Umbraco.Tests/Testing/AutoMoqDataAttribute.cs b/src/Umbraco.Tests/Testing/AutoMoqDataAttribute.cs new file mode 100644 index 0000000000..ac03a10053 --- /dev/null +++ b/src/Umbraco.Tests/Testing/AutoMoqDataAttribute.cs @@ -0,0 +1,13 @@ +using AutoFixture; +using AutoFixture.AutoMoq; +using AutoFixture.NUnit3; + +namespace Umbraco.Tests.Testing +{ + public class AutoMoqDataAttribute : AutoDataAttribute + { + public AutoMoqDataAttribute() + : base(() => new Fixture().Customize(new AutoMoqCustomization{ ConfigureMembers = true })) + { } + } +} diff --git a/src/Umbraco.Tests/Umbraco.Tests.csproj b/src/Umbraco.Tests/Umbraco.Tests.csproj index c00c67ca1c..bb0dadf60d 100644 --- a/src/Umbraco.Tests/Umbraco.Tests.csproj +++ b/src/Umbraco.Tests/Umbraco.Tests.csproj @@ -78,6 +78,8 @@ + + @@ -100,10 +102,11 @@ - + - - + + + @@ -150,6 +153,7 @@ + @@ -166,13 +170,17 @@ + + + + @@ -183,6 +191,7 @@ + diff --git a/src/Umbraco.Web.UI/config/umbracoSettings.Release.config b/src/Umbraco.Web.UI/config/umbracoSettings.Release.config index f41eb10ce1..0e51c63cfa 100644 --- a/src/Umbraco.Web.UI/config/umbracoSettings.Release.config +++ b/src/Umbraco.Web.UI/config/umbracoSettings.Release.config @@ -191,7 +191,7 @@ assets/img/application/umbraco_logo_white.svg - + diff --git a/src/Umbraco.Web/Scheduling/ContentVersionCleanup.cs b/src/Umbraco.Web/Scheduling/ContentVersionCleanup.cs new file mode 100644 index 0000000000..4fccbfab0a --- /dev/null +++ b/src/Umbraco.Web/Scheduling/ContentVersionCleanup.cs @@ -0,0 +1,79 @@ +using System; +using Umbraco.Core; +using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Core.Logging; +using Umbraco.Core.Services; +using Umbraco.Core.Sync; + +namespace Umbraco.Web.Scheduling +{ + internal class ContentVersionCleanup : RecurringTaskBase + { + private readonly IRuntimeState _runtimeState; + private readonly IProfilingLogger _logger; + private readonly IContentVersionCleanupPolicyGlobalSettings _settings; + private readonly IContentVersionCleanupService _cleanupService; + + public ContentVersionCleanup( + IBackgroundTaskRunner runner, + long delayMilliseconds, + long periodMilliseconds, + IRuntimeState runtimeState, + IProfilingLogger logger, + IContentVersionCleanupPolicyGlobalSettings settings, + IContentVersionCleanupService cleanupService) + : base(runner, delayMilliseconds, periodMilliseconds) + { + _runtimeState = runtimeState ?? throw new ArgumentNullException(nameof(runtimeState)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _cleanupService = cleanupService ?? throw new ArgumentNullException(nameof(cleanupService)); + } + + public override bool PerformRun() + { + // Globally disabled by feature flag + if (!_settings.EnableCleanup) + { + _logger.Info("ContentVersionCleanup task will not run as it has been globally disabled via configuration."); + return false; + } + + if (_runtimeState.Level != RuntimeLevel.Run) + { + return true; // repeat... + } + + switch (_runtimeState.ServerRole) + { + case ServerRole.Replica: + _logger.Debug("Does not run on replica servers."); + return true; // DO repeat, server role can change + case ServerRole.Unknown: + _logger.Debug("Does not run on servers with unknown role."); + return true; // DO repeat, server role can change + case ServerRole.Single: + case ServerRole.Master: + default: + break; + } + + // Ensure we do not run if not main domain, but do NOT lock it + if (!_runtimeState.IsMainDom) + { + _logger.Debug("Does not run if not MainDom."); + return false; // do NOT repeat, going down + } + + _logger.Info("Starting ContentVersionCleanup task."); + + var report = _cleanupService.PerformContentVersionCleanup(DateTime.Now); + + _logger.Info("Finished ContentVersionCleanup task. Removed {count} item(s).", report.Count); + + return true; + } + + public override bool IsAsync => false; + } +} diff --git a/src/Umbraco.Web/Scheduling/SchedulerComponent.cs b/src/Umbraco.Web/Scheduling/SchedulerComponent.cs index f353a9506e..a8f4cc1e76 100644 --- a/src/Umbraco.Web/Scheduling/SchedulerComponent.cs +++ b/src/Umbraco.Web/Scheduling/SchedulerComponent.cs @@ -25,6 +25,7 @@ namespace Umbraco.Web.Scheduling private readonly IRuntimeState _runtime; private readonly IContentService _contentService; + private readonly IContentVersionCleanupService _cleanupService; private readonly IAuditService _auditService; private readonly IProfilingLogger _logger; private readonly IScopeProvider _scopeProvider; @@ -38,18 +39,26 @@ namespace Umbraco.Web.Scheduling private BackgroundTaskRunner _scrubberRunner; private BackgroundTaskRunner _fileCleanupRunner; private BackgroundTaskRunner _healthCheckRunner; + private BackgroundTaskRunner _contentVersionCleanupRunner; private bool _started; private object _locker = new object(); private IBackgroundTask[] _tasks; - public SchedulerComponent(IRuntimeState runtime, - IContentService contentService, IAuditService auditService, - HealthCheckCollection healthChecks, HealthCheckNotificationMethodCollection notifications, - IScopeProvider scopeProvider, IUmbracoContextFactory umbracoContextFactory, IProfilingLogger logger) + public SchedulerComponent( + IRuntimeState runtime, + IContentService contentService, + IContentVersionCleanupService cleanupService, + IAuditService auditService, + HealthCheckCollection healthChecks, + HealthCheckNotificationMethodCollection notifications, + IScopeProvider scopeProvider, + IUmbracoContextFactory umbracoContextFactory, + IProfilingLogger logger) { _runtime = runtime; _contentService = contentService; + _cleanupService = cleanupService; _auditService = auditService; _scopeProvider = scopeProvider; _logger = logger; @@ -68,6 +77,7 @@ namespace Umbraco.Web.Scheduling _scrubberRunner = new BackgroundTaskRunner("LogScrubber", _logger); _fileCleanupRunner = new BackgroundTaskRunner("TempFileCleanup", _logger); _healthCheckRunner = new BackgroundTaskRunner("HealthCheckNotifier", _logger); + _contentVersionCleanupRunner = new BackgroundTaskRunner("ContentVersionCleanup", _logger); // we will start the whole process when a successful request is made UmbracoModule.RouteAttempt += RegisterBackgroundTasksOnce; @@ -107,6 +117,7 @@ namespace Umbraco.Web.Scheduling tasks.Add(RegisterScheduledPublishing()); tasks.Add(RegisterLogScrubber(settings)); tasks.Add(RegisterTempFileCleanup()); + tasks.Add(RegisterContentVersionCleanup(settings)); var healthCheckConfig = Current.Configs.HealthChecks(); if (healthCheckConfig.NotificationSettings.Enabled) @@ -180,5 +191,23 @@ namespace Umbraco.Web.Scheduling _fileCleanupRunner.TryAdd(task); return task; } + + private IBackgroundTask RegisterContentVersionCleanup(IUmbracoSettingsSection settings) + { + // content version cleanup + // install on all, will only run on non-replica servers. + var task = new ContentVersionCleanup( + _contentVersionCleanupRunner, + DefaultDelayMilliseconds, + OneHourMilliseconds, + _runtime, + _logger, + settings.Content.ContentVersionCleanupPolicyGlobalSettings, + _cleanupService); + + _contentVersionCleanupRunner.TryAdd(task); + + return task; + } } } diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 5f6ab67a42..7d1f50754c 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -320,6 +320,7 @@ +