diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs index f9d9681f07..e39bebd680 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs @@ -58,11 +58,12 @@ public class PublishDocumentWithDescendantsController : DocumentControllerBase true); return attempt.Success && attempt.Result.AcceptedTaskId.HasValue - ? Ok(new PublishWithDescendantsResultModel - { - TaskId = attempt.Result.AcceptedTaskId.Value, - IsComplete = false - }) + ? Ok( + new PublishWithDescendantsResultModel + { + TaskId = attempt.Result.AcceptedTaskId.Value, + IsComplete = false, + }) : DocumentPublishingOperationStatusResult(attempt.Status, failedBranchItems: attempt.Result.FailedItems); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsResultController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsResultController.cs index 9a499ede1e..7325fc1b4b 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsResultController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsResultController.cs @@ -49,21 +49,23 @@ public class PublishDocumentWithDescendantsResultController : DocumentController var isPublishing = await _contentPublishingService.IsPublishingBranchAsync(taskId); if (isPublishing) { - return Ok(new PublishWithDescendantsResultModel - { - TaskId = taskId, - IsComplete = false - }); - }; + return Ok( + new PublishWithDescendantsResultModel + { + TaskId = taskId, + IsComplete = false, + }); + } // If completed, get the result and return the status. Attempt attempt = await _contentPublishingService.GetPublishBranchResultAsync(taskId); return attempt.Success - ? Ok(new PublishWithDescendantsResultModel - { - TaskId = taskId, - IsComplete = true - }) + ? Ok( + new PublishWithDescendantsResultModel + { + TaskId = taskId, + IsComplete = true, + }) : DocumentPublishingOperationStatusResult(attempt.Status, failedBranchItems: attempt.Result.FailedItems); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheController.cs index 8f9cd490eb..f716058fc4 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheController.cs @@ -1,6 +1,8 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PublishedCache; namespace Umbraco.Cms.Api.Management.Controllers.PublishedCache; @@ -15,9 +17,10 @@ public class RebuildPublishedCacheController : PublishedCacheControllerBase [HttpPost("rebuild")] [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status200OK)] - public Task Rebuild(CancellationToken cancellationToken) + public async Task Rebuild(CancellationToken cancellationToken) { - if (_databaseCacheRebuilder.IsRebuilding()) + Attempt attempt = await _databaseCacheRebuilder.RebuildAsync(true); + if (attempt is { Success: false, Result: DatabaseCacheRebuildResult.AlreadyRunning }) { var problemDetails = new ProblemDetails { @@ -26,11 +29,9 @@ public class RebuildPublishedCacheController : PublishedCacheControllerBase Status = StatusCodes.Status400BadRequest, Type = "Error", }; - - return Task.FromResult(Conflict(problemDetails)); + return Conflict(problemDetails); } - _databaseCacheRebuilder.Rebuild(true); - return Task.FromResult(Ok()); + return Ok(); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheStatusController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheStatusController.cs index 5ecceecd3d..e924116751 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheStatusController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheStatusController.cs @@ -16,12 +16,13 @@ public class RebuildPublishedCacheStatusController : PublishedCacheControllerBas [HttpGet("rebuild/status")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(RebuildStatusModel), StatusCodes.Status200OK)] - public Task Status(CancellationToken cancellationToken) + public async Task Status(CancellationToken cancellationToken) { - var isRebuilding = _databaseCacheRebuilder.IsRebuilding(); - return Task.FromResult((IActionResult)Ok(new RebuildStatusModel - { - IsRebuilding = isRebuilding - })); + var isRebuilding = await _databaseCacheRebuilder.IsRebuildingAsync(); + return Ok( + new RebuildStatusModel + { + IsRebuilding = isRebuilding, + }); } } diff --git a/src/Umbraco.Core/AttemptOfTResultTStatus.cs b/src/Umbraco.Core/AttemptOfTResultTStatus.cs index e88465b3ad..ed3fc98225 100644 --- a/src/Umbraco.Core/AttemptOfTResultTStatus.cs +++ b/src/Umbraco.Core/AttemptOfTResultTStatus.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace Umbraco.Cms.Core; /// @@ -9,6 +11,7 @@ namespace Umbraco.Cms.Core; public struct Attempt { // private - use Succeed() or Fail() methods to create attempts + [JsonConstructor] private Attempt(bool success, TResult result, TStatus status, Exception? exception) { Success = success; diff --git a/src/Umbraco.Core/Configuration/Models/LongRunningOperationsCleanupSettings.cs b/src/Umbraco.Core/Configuration/Models/LongRunningOperationsCleanupSettings.cs new file mode 100644 index 0000000000..d4b25c1c6f --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/LongRunningOperationsCleanupSettings.cs @@ -0,0 +1,27 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.ComponentModel; + +namespace Umbraco.Cms.Core.Configuration.Models; + +/// +/// Typed configuration options for long-running operations cleanup settings. +/// +public class LongRunningOperationsCleanupSettings +{ + private const string StaticPeriod = "00:02:00"; + private const string StaticMaxAge = "01:00:00"; + + /// + /// Gets or sets a value for the period in which long-running operations are cleaned up. + /// + [DefaultValue(StaticPeriod)] + public TimeSpan Period { get; set; } = TimeSpan.Parse(StaticPeriod); + + /// + /// Gets or sets the maximum time a long-running operation entry can exist, without being updated, before it is considered for cleanup. + /// + [DefaultValue(StaticMaxAge)] + public TimeSpan MaxEntryAge { get; set; } = TimeSpan.Parse(StaticMaxAge); +} diff --git a/src/Umbraco.Core/Configuration/Models/LongRunningOperationsSettings.cs b/src/Umbraco.Core/Configuration/Models/LongRunningOperationsSettings.cs new file mode 100644 index 0000000000..32db2f73da --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/LongRunningOperationsSettings.cs @@ -0,0 +1,33 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.ComponentModel; + +namespace Umbraco.Cms.Core.Configuration.Models; + +/// +/// Typed configuration options for long-running operations settings. +/// +[UmbracoOptions(Constants.Configuration.ConfigLongRunningOperations)] +public class LongRunningOperationsSettings +{ + private const string StaticExpirationTime = "00:05:00"; + private const string StaticTimeBetweenStatusChecks = "00:00:10"; + + /// + /// Gets or sets the cleanup settings for long-running operations. + /// + public LongRunningOperationsCleanupSettings Cleanup { get; set; } = new(); + + /// + /// Gets or sets the time after which a long-running operation is considered expired/stale, if not updated. + /// + [DefaultValue(StaticExpirationTime)] + public TimeSpan ExpirationTime { get; set; } = TimeSpan.Parse(StaticExpirationTime); + + /// + /// Gets or sets the time between status checks for long-running operations. + /// + [DefaultValue(StaticTimeBetweenStatusChecks)] + public TimeSpan TimeBetweenStatusChecks { get; set; } = TimeSpan.Parse(StaticTimeBetweenStatusChecks); +} diff --git a/src/Umbraco.Core/Constants-Configuration.cs b/src/Umbraco.Core/Constants-Configuration.cs index 78b43009b0..8504210504 100644 --- a/src/Umbraco.Core/Constants-Configuration.cs +++ b/src/Umbraco.Core/Constants-Configuration.cs @@ -40,6 +40,7 @@ public static partial class Constants public const string ConfigExamine = ConfigPrefix + "Examine"; public const string ConfigIndexing = ConfigPrefix + "Indexing"; public const string ConfigLogging = ConfigPrefix + "Logging"; + public const string ConfigLongRunningOperations = ConfigPrefix + "LongRunningOperations"; public const string ConfigMemberPassword = ConfigPrefix + "Security:MemberPassword"; public const string ConfigModelsBuilder = ConfigPrefix + "ModelsBuilder"; public const string ConfigModelsMode = ConfigModelsBuilder + ":ModelsMode"; diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index 1e9a93abf2..cc9b03e65b 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -69,6 +69,7 @@ public static partial class UmbracoBuilderExtensions .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions() + .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions() diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index c6802ed29e..cb5020f5b9 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -34,6 +34,7 @@ using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.ContentTypeEditing; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Preview; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.PublishedCache.Internal; @@ -341,6 +342,7 @@ namespace Umbraco.Cms.Core.DependencyInjection Services.AddUnique(factory => new LocalizedTextService( factory.GetRequiredService>(), factory.GetRequiredService>())); + Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchInternalResult.cs b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchInternalResult.cs new file mode 100644 index 0000000000..fbc598180b --- /dev/null +++ b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchInternalResult.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Models.ContentPublishing; + +internal sealed class ContentPublishingBranchInternalResult +{ + public Guid? ContentKey { get; init; } + + public IContent? Content { get; init; } + + public IEnumerable SucceededItems { get; set; } = []; + + public IEnumerable FailedItems { get; set; } = []; + + public Guid? AcceptedTaskId { get; init; } +} diff --git a/src/Umbraco.Core/Models/DatabaseCacheRebuildResult.cs b/src/Umbraco.Core/Models/DatabaseCacheRebuildResult.cs new file mode 100644 index 0000000000..970ac77f3c --- /dev/null +++ b/src/Umbraco.Core/Models/DatabaseCacheRebuildResult.cs @@ -0,0 +1,17 @@ +namespace Umbraco.Cms.Core.Models; + +/// +/// Represents the result of a database cache rebuild operation. +/// +public enum DatabaseCacheRebuildResult +{ + /// + /// The cache rebuild operation was either successful or enqueued successfully. + /// + Success, + + /// + /// A cache rebuild operation is already in progress. + /// + AlreadyRunning, +} diff --git a/src/Umbraco.Core/Models/LongRunningOperation.cs b/src/Umbraco.Core/Models/LongRunningOperation.cs new file mode 100644 index 0000000000..9b60792f9a --- /dev/null +++ b/src/Umbraco.Core/Models/LongRunningOperation.cs @@ -0,0 +1,22 @@ +namespace Umbraco.Cms.Core.Models; + +/// +/// Represents a long-running operation. +/// +public class LongRunningOperation +{ + /// + /// Gets the unique identifier for the long-running operation. + /// + public required Guid Id { get; init; } + + /// + /// Gets or sets the type of the long-running operation. + /// + public required string Type { get; set; } + + /// + /// Gets or sets the status of the long-running operation. + /// + public required LongRunningOperationStatus Status { get; set; } +} diff --git a/src/Umbraco.Core/Models/LongRunningOperationOfTResult.cs b/src/Umbraco.Core/Models/LongRunningOperationOfTResult.cs new file mode 100644 index 0000000000..805a36f8be --- /dev/null +++ b/src/Umbraco.Core/Models/LongRunningOperationOfTResult.cs @@ -0,0 +1,13 @@ +namespace Umbraco.Cms.Core.Models; + +/// +/// Represents a long-running operation. +/// +/// The type of the result of the long-running operation. +public class LongRunningOperation : LongRunningOperation +{ + /// + /// Gets or sets the result of the long-running operation. + /// + public TResult? Result { get; set; } +} diff --git a/src/Umbraco.Core/Models/LongRunningOperationStatus.cs b/src/Umbraco.Core/Models/LongRunningOperationStatus.cs new file mode 100644 index 0000000000..b370d4c392 --- /dev/null +++ b/src/Umbraco.Core/Models/LongRunningOperationStatus.cs @@ -0,0 +1,32 @@ +namespace Umbraco.Cms.Core.Models; + +/// +/// Represents the status of a long-running operation. +/// +public enum LongRunningOperationStatus +{ + /// + /// The operation has finished successfully. + /// + Success, + + /// + /// The operation has failed. + /// + Failed, + + /// + /// The operation has been queued. + /// + Enqueued, + + /// + /// The operation is currently running. + /// + Running, + + /// + /// The operation wasn't updated within the expected time frame and is considered stale. + /// + Stale, +} diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index c275fdd108..9dc3148b7d 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -98,6 +98,8 @@ public static partial class Constants public const string Webhook2Headers = Webhook + "2Headers"; public const string WebhookLog = Webhook + "Log"; public const string WebhookRequest = Webhook + "Request"; + + public const string LongRunningOperation = TableNamePrefix + "LongRunningOperation"; } } } diff --git a/src/Umbraco.Core/Persistence/Constants-Locks.cs b/src/Umbraco.Core/Persistence/Constants-Locks.cs index 874c0ffe2f..f8b7ce61cf 100644 --- a/src/Umbraco.Core/Persistence/Constants-Locks.cs +++ b/src/Umbraco.Core/Persistence/Constants-Locks.cs @@ -80,5 +80,10 @@ public static partial class Constants /// All webhook logs. /// public const int WebhookLogs = -343; + + /// + /// Long-running operations. + /// + public const int LongRunningOperations = -344; } } diff --git a/src/Umbraco.Core/Persistence/Repositories/ILongRunningOperationRepository.cs b/src/Umbraco.Core/Persistence/Repositories/ILongRunningOperationRepository.cs new file mode 100644 index 0000000000..5821f1b144 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/ILongRunningOperationRepository.cs @@ -0,0 +1,74 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +/// +/// Represents a repository for managing long-running operations. +/// +public interface ILongRunningOperationRepository +{ + /// + /// Creates a new long-running operation. + /// + /// The operation to create. + /// The date and time when the operation should be considered stale. + /// A representing the asynchronous operation. + Task CreateAsync(LongRunningOperation operation, DateTimeOffset expirationDate); + + /// + /// Retrieves a long-running operation by its ID. + /// + /// The unique identifier of the long-running operation. + /// The long-running operation if found; otherwise, null. + Task GetAsync(Guid id); + + /// + /// Retrieves a long-running operation by its ID. + /// + /// The type of the result of the long-running operation. + /// The unique identifier of the long-running operation. + /// The long-running operation if found; otherwise, null. + Task?> GetAsync(Guid id); + + /// + /// Gets all long-running operations of a specific type, optionally filtered by their statuses. + /// + /// Type of the long-running operation. + /// Array of statuses to filter the operations by. + /// Number of entries to skip. + /// Number of entries to take. + /// A paged model of objects. + Task> GetByTypeAsync(string type, LongRunningOperationStatus[] statuses, int skip, int take); + + /// + /// Gets the status of a long-running operation by its unique identifier. + /// + /// The unique identifier for the operation. + /// The long-running operation if found; otherwise, null. + Task GetStatusAsync(Guid id); + + /// + /// Updates the status of a long-running operation identified by its ID. + /// + /// The unique identifier of the long-running operation. + /// The new status to set for the operation. + /// The date and time when the operation should be considered stale. + /// A representing the asynchronous operation. + public Task UpdateStatusAsync(Guid id, LongRunningOperationStatus status, DateTimeOffset expirationDate); + + /// + /// Sets the result of a long-running operation identified by its ID. + /// + /// The unique identifier of the long-running operation. + /// The result of the operation. + /// The type of the result. + /// A representing the asynchronous operation. + public Task SetResultAsync(Guid id, T result); + + /// + /// Cleans up long-running operations that haven't been updated for a certain period of time. + /// + /// The cutoff date and time for operations to be considered for deletion. + /// A representing the asynchronous operation. + Task CleanOperationsAsync(DateTimeOffset olderThan); +} diff --git a/src/Umbraco.Core/PublishedCache/IDatabaseCacheRebuilder.cs b/src/Umbraco.Core/PublishedCache/IDatabaseCacheRebuilder.cs index c4b7e2883a..ce46f1152c 100644 --- a/src/Umbraco.Core/PublishedCache/IDatabaseCacheRebuilder.cs +++ b/src/Umbraco.Core/PublishedCache/IDatabaseCacheRebuilder.cs @@ -1,3 +1,5 @@ +using Umbraco.Cms.Core.Models; + namespace Umbraco.Cms.Core.PublishedCache; /// @@ -11,6 +13,12 @@ public interface IDatabaseCacheRebuilder /// bool IsRebuilding() => false; + /// + /// Indicates if the database cache is in the process of being rebuilt. + /// + /// + Task IsRebuildingAsync() => Task.FromResult(IsRebuilding()); + /// /// Rebuilds the database cache. /// @@ -21,13 +29,36 @@ public interface IDatabaseCacheRebuilder /// Rebuilds the database cache, optionally using a background thread. /// /// Flag indicating whether to use a background thread for the operation and immediately return to the caller. + [Obsolete("Use RebuildAsync instead. Scheduled for removal in Umbraco 18.")] void Rebuild(bool useBackgroundThread) #pragma warning disable CS0618 // Type or member is obsolete => Rebuild(); #pragma warning restore CS0618 // Type or member is obsolete /// - /// Rebuids the database cache if the configured serializer has changed. + /// Rebuilds the database cache, optionally using a background thread. /// + /// Flag indicating whether to use a background thread for the operation and immediately return to the caller. + /// An attempt indicating the result of the rebuild operation. + Task> RebuildAsync(bool useBackgroundThread) + { + Rebuild(useBackgroundThread); + return Task.FromResult(Attempt.Succeed(DatabaseCacheRebuildResult.Success)); + } + + /// + /// Rebuilds the database cache if the configured serializer has changed. + /// + [Obsolete("Use the async version. Scheduled for removal in Umbraco 18.")] void RebuildDatabaseCacheIfSerializerChanged(); + + /// + /// Rebuilds the database cache if the configured serializer has changed. + /// + /// A representing the asynchronous operation. + Task RebuildDatabaseCacheIfSerializerChangedAsync() + { + RebuildDatabaseCacheIfSerializerChanged(); + return Task.CompletedTask; + } } diff --git a/src/Umbraco.Core/Services/ContentPublishingService.cs b/src/Umbraco.Core/Services/ContentPublishingService.cs index 98505483a1..ddabe19448 100644 --- a/src/Umbraco.Core/Services/ContentPublishingService.cs +++ b/src/Umbraco.Core/Services/ContentPublishingService.cs @@ -1,9 +1,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; -using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.ContentPublishing; @@ -15,8 +13,7 @@ namespace Umbraco.Cms.Core.Services; internal sealed class ContentPublishingService : IContentPublishingService { - private const string IsPublishingBranchRuntimeCacheKeyPrefix = "temp_indexing_op_"; - private const string PublishingBranchResultCacheKeyPrefix = "temp_indexing_result_"; + private const string PublishBranchOperationType = "ContentPublishBranch"; private readonly ICoreScopeProvider _coreScopeProvider; private readonly IContentService _contentService; @@ -27,8 +24,7 @@ internal sealed class ContentPublishingService : IContentPublishingService private ContentSettings _contentSettings; private readonly IRelationService _relationService; private readonly ILogger _logger; - private readonly IBackgroundTaskQueue _backgroundTaskQueue; - private readonly IAppPolicyCache _runtimeCache; + private readonly ILongRunningOperationService _longRunningOperationService; public ContentPublishingService( ICoreScopeProvider coreScopeProvider, @@ -40,8 +36,7 @@ internal sealed class ContentPublishingService : IContentPublishingService IOptionsMonitor optionsMonitor, IRelationService relationService, ILogger logger, - IBackgroundTaskQueue backgroundTaskQueue, - IAppPolicyCache runtimeCache) + ILongRunningOperationService longRunningOperationService) { _coreScopeProvider = coreScopeProvider; _contentService = contentService; @@ -51,8 +46,7 @@ internal sealed class ContentPublishingService : IContentPublishingService _languageService = languageService; _relationService = relationService; _logger = logger; - _backgroundTaskQueue = backgroundTaskQueue; - _runtimeCache = runtimeCache; + _longRunningOperationService = longRunningOperationService; _contentSettings = optionsMonitor.CurrentValue; optionsMonitor.OnChange((contentSettings) => { @@ -281,123 +275,125 @@ internal sealed class ContentPublishingService : IContentPublishingService => await PublishBranchAsync(key, cultures, publishBranchFilter, userKey, false); /// - public async Task> PublishBranchAsync(Guid key, IEnumerable cultures, PublishBranchFilter publishBranchFilter, Guid userKey, bool useBackgroundThread) + public async Task> PublishBranchAsync( + Guid key, + IEnumerable cultures, + PublishBranchFilter publishBranchFilter, + Guid userKey, + bool useBackgroundThread) { - if (useBackgroundThread) + if (useBackgroundThread is false) { - _logger.LogInformation("Starting async background thread for publishing branch."); + Attempt minimalAttempt + = await PerformPublishBranchAsync(key, cultures, publishBranchFilter, userKey, returnContent: true); + return MapInternalPublishingAttempt(minimalAttempt); + } - var taskId = Guid.NewGuid(); - _backgroundTaskQueue.QueueBackgroundWorkItem( - cancellationToken => - { - using (ExecutionContext.SuppressFlow()) + _logger.LogInformation("Starting async background thread for publishing branch."); + Attempt enqueueAttempt = await _longRunningOperationService.RunAsync( + PublishBranchOperationType, + async _ => await PerformPublishBranchAsync(key, cultures, publishBranchFilter, userKey, returnContent: false), + allowConcurrentExecution: true); + if (enqueueAttempt.Success) + { + return Attempt.SucceedWithStatus( + ContentPublishingOperationStatus.Accepted, + new ContentPublishingBranchResult { AcceptedTaskId = enqueueAttempt.Result }); + } + + return Attempt.FailWithStatus( + ContentPublishingOperationStatus.Unknown, + new ContentPublishingBranchResult + { + FailedItems = + [ + new ContentPublishingBranchItemResult { - Task.Run(async () => await PerformPublishBranchAsync(key, cultures, publishBranchFilter, userKey, taskId) ); - return Task.CompletedTask; + Key = key, + OperationStatus = ContentPublishingOperationStatus.Unknown, } - }); - - return Attempt.SucceedWithStatus(ContentPublishingOperationStatus.Accepted, new ContentPublishingBranchResult { AcceptedTaskId = taskId}); - } - else - { - return await PerformPublishBranchAsync(key, cultures, publishBranchFilter, userKey); - } + ], + }); } - private async Task> PerformPublishBranchAsync(Guid key, IEnumerable cultures, PublishBranchFilter publishBranchFilter, Guid userKey, Guid? taskId = null) + private async Task> PerformPublishBranchAsync( + Guid key, + IEnumerable cultures, + PublishBranchFilter publishBranchFilter, + Guid userKey, + bool returnContent) { - try + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + IContent? content = _contentService.GetById(key); + if (content is null) { - if (taskId.HasValue) - { - SetIsPublishingBranch(taskId.Value); - } - - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - IContent? content = _contentService.GetById(key); - if (content is null) - { - return Attempt.FailWithStatus( - ContentPublishingOperationStatus.ContentNotFound, - new ContentPublishingBranchResult - { - FailedItems = new[] + return Attempt.FailWithStatus( + ContentPublishingOperationStatus.ContentNotFound, + new ContentPublishingBranchInternalResult + { + FailedItems = + [ + new ContentPublishingBranchItemResult { - new ContentPublishingBranchItemResult - { - Key = key, - OperationStatus = ContentPublishingOperationStatus.ContentNotFound, - } + Key = key, + OperationStatus = ContentPublishingOperationStatus.ContentNotFound, } - }); - } - - var userId = await _userIdKeyResolver.GetAsync(userKey); - IEnumerable result = _contentService.PublishBranch(content, publishBranchFilter, cultures.ToArray(), userId); - scope.Complete(); - - var itemResults = result.ToDictionary(r => r.Content.Key, ToContentPublishingOperationStatus); - var branchResult = new ContentPublishingBranchResult - { - Content = content, - SucceededItems = itemResults - .Where(i => i.Value is ContentPublishingOperationStatus.Success) - .Select(i => new ContentPublishingBranchItemResult { Key = i.Key, OperationStatus = i.Value }) - .ToArray(), - FailedItems = itemResults - .Where(i => i.Value is not ContentPublishingOperationStatus.Success) - .Select(i => new ContentPublishingBranchItemResult { Key = i.Key, OperationStatus = i.Value }) - .ToArray() - }; - - Attempt attempt = branchResult.FailedItems.Any() is false - ? Attempt.SucceedWithStatus(ContentPublishingOperationStatus.Success, branchResult) - : Attempt.FailWithStatus(ContentPublishingOperationStatus.FailedBranch, branchResult); - if (taskId.HasValue) - { - SetPublishingBranchResult(taskId.Value, attempt); - } - - return attempt; + ], + }); } - finally + + var userId = await _userIdKeyResolver.GetAsync(userKey); + IEnumerable result = _contentService.PublishBranch(content, publishBranchFilter, cultures.ToArray(), userId); + scope.Complete(); + + var itemResults = result.ToDictionary(r => r.Content.Key, ToContentPublishingOperationStatus); + var branchResult = new ContentPublishingBranchInternalResult { - if (taskId.HasValue) - { - ClearIsPublishingBranch(taskId.Value); - } - } + ContentKey = content.Key, + Content = returnContent ? content : null, + SucceededItems = itemResults + .Where(i => i.Value is ContentPublishingOperationStatus.Success) + .Select(i => new ContentPublishingBranchItemResult { Key = i.Key, OperationStatus = i.Value }) + .ToArray(), + FailedItems = itemResults + .Where(i => i.Value is not ContentPublishingOperationStatus.Success) + .Select(i => new ContentPublishingBranchItemResult { Key = i.Key, OperationStatus = i.Value }) + .ToArray(), + }; + + Attempt attempt = branchResult.FailedItems.Any() is false + ? Attempt.SucceedWithStatus(ContentPublishingOperationStatus.Success, branchResult) + : Attempt.FailWithStatus(ContentPublishingOperationStatus.FailedBranch, branchResult); + + return attempt; } /// - public Task IsPublishingBranchAsync(Guid taskId) => Task.FromResult(_runtimeCache.Get(GetIsPublishingBranchCacheKey(taskId)) is not null); + public async Task IsPublishingBranchAsync(Guid taskId) + => await _longRunningOperationService.GetStatusAsync(taskId) is LongRunningOperationStatus.Enqueued or LongRunningOperationStatus.Running; /// - public Task> GetPublishBranchResultAsync(Guid taskId) + public async Task> GetPublishBranchResultAsync(Guid taskId) { - var taskResult = _runtimeCache.Get(GetPublishingBranchResultCacheKey(taskId)) as Attempt?; - if (taskResult is null) + Attempt, LongRunningOperationResultStatus> result = + await _longRunningOperationService + .GetResultAsync>(taskId); + + if (result.Success is false) { - return Task.FromResult(Attempt.FailWithStatus(ContentPublishingOperationStatus.TaskResultNotFound, new ContentPublishingBranchResult())); + return Attempt.FailWithStatus( + result.Status switch + { + LongRunningOperationResultStatus.OperationNotFound => ContentPublishingOperationStatus.TaskResultNotFound, + LongRunningOperationResultStatus.OperationFailed => ContentPublishingOperationStatus.Failed, + _ => ContentPublishingOperationStatus.Unknown, + }, + new ContentPublishingBranchResult()); } - // We won't clear the cache here just in case we remove references to the returned object. It expires after 60 seconds anyway. - return Task.FromResult(taskResult.Value); + return MapInternalPublishingAttempt(result.Result); } - private void SetIsPublishingBranch(Guid taskId) => _runtimeCache.Insert(GetIsPublishingBranchCacheKey(taskId), () => "tempValue", TimeSpan.FromMinutes(10)); - - private void ClearIsPublishingBranch(Guid taskId) => _runtimeCache.Clear(GetIsPublishingBranchCacheKey(taskId)); - - private static string GetIsPublishingBranchCacheKey(Guid taskId) => IsPublishingBranchRuntimeCacheKeyPrefix + taskId; - - private void SetPublishingBranchResult(Guid taskId, Attempt result) - => _runtimeCache.Insert(GetPublishingBranchResultCacheKey(taskId), () => result, TimeSpan.FromMinutes(1)); - - private static string GetPublishingBranchResultCacheKey(Guid taskId) => PublishingBranchResultCacheKeyPrefix + taskId; - /// public async Task> UnpublishAsync(Guid key, ISet? cultures, Guid userKey) { @@ -564,4 +560,19 @@ internal sealed class ContentPublishingService : IContentPublishingService PublishResultType.FailedUnpublishCancelledByEvent => ContentPublishingOperationStatus.CancelledByEvent, _ => throw new ArgumentOutOfRangeException() }; + + private Attempt MapInternalPublishingAttempt( + Attempt minimalAttempt) => + minimalAttempt.Success + ? Attempt.SucceedWithStatus(minimalAttempt.Status, MapMinimalPublishingBranchResult(minimalAttempt.Result)) + : Attempt.FailWithStatus(minimalAttempt.Status, MapMinimalPublishingBranchResult(minimalAttempt.Result)); + + private ContentPublishingBranchResult MapMinimalPublishingBranchResult(ContentPublishingBranchInternalResult internalResult) => + new() + { + Content = internalResult.Content + ?? (internalResult.ContentKey is null ? null : _contentService.GetById(internalResult.ContentKey.Value)), + SucceededItems = internalResult.SucceededItems, + FailedItems = internalResult.FailedItems, + }; } diff --git a/src/Umbraco.Core/Services/ILongRunningOperationService.cs b/src/Umbraco.Core/Services/ILongRunningOperationService.cs new file mode 100644 index 0000000000..6258d2b36a --- /dev/null +++ b/src/Umbraco.Core/Services/ILongRunningOperationService.cs @@ -0,0 +1,72 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +/// +/// A service for managing long-running operations that can be executed in the background. +/// +public interface ILongRunningOperationService +{ + /// + /// Enqueues a long-running operation to be executed in the background. + /// + /// The type of the long-running operation, used for categorization. + /// The operation to execute, which should accept a . + /// Whether to allow multiple instances of the same operation type to run concurrently. + /// Whether to run the operation in the background. + /// An indicating the status of the enqueue operation. + /// Thrown if attempting to run an operation in the foreground within a scope. + Task> RunAsync( + string type, + Func operation, + bool allowConcurrentExecution = false, + bool runInBackground = true); + + /// + /// Enqueues a long-running operation to be executed in the background. + /// + /// The type of the long-running operation, used for categorization. + /// The operation to execute, which should accept a . + /// Whether to allow multiple instances of the same operation type to run concurrently. + /// Whether to run the operation in the background. + /// An indicating the status of the enqueue operation. + /// The type of the result expected from the operation. + /// Thrown if attempting to run an operation in the foreground within a scope. + Task> RunAsync( + string type, + Func> operation, + bool allowConcurrentExecution = false, + bool runInBackground = true); + + /// + /// Gets the status of a long-running operation by its unique identifier. + /// + /// The unique identifier for the operation. + /// True if the operation is running or enqueued; otherwise, false. + Task GetStatusAsync(Guid operationId); + + /// + /// Gets the active long-running operations of a specific type. + /// + /// The type of the long-running operation. + /// Number of operations to skip. + /// Number of operations to take. + /// Optional array of statuses to filter the operations by. If null, only enqueued and running + /// operations are returned. + /// True if the operation is running or enqueued; otherwise, false. + Task> GetByTypeAsync( + string type, + int skip, + int take, + LongRunningOperationStatus[]? statuses = null); + + /// + /// Gets the result of a long-running operation. + /// + /// The unique identifier of the long-running operation. + /// The type of the result expected from the operation. + /// An containing the result of the operation + /// and its status. If the operation is not found or has not completed, the result will be null. + Task> GetResultAsync(Guid operationId); +} diff --git a/src/Umbraco.Core/Services/LongRunningOperationService.cs b/src/Umbraco.Core/Services/LongRunningOperationService.cs new file mode 100644 index 0000000000..7c867c178c --- /dev/null +++ b/src/Umbraco.Core/Services/LongRunningOperationService.cs @@ -0,0 +1,250 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +/// +internal class LongRunningOperationService : ILongRunningOperationService +{ + private readonly IOptions _options; + private readonly ILongRunningOperationRepository _repository; + private readonly ICoreScopeProvider _scopeProvider; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The repository for tracking long-running operations. + /// The scope provider for managing database transactions. + /// The time provider for getting the current UTC time. + /// The logger for logging information and errors related to long-running operations. + public LongRunningOperationService( + IOptions options, + ILongRunningOperationRepository repository, + ICoreScopeProvider scopeProvider, + TimeProvider timeProvider, + ILogger logger) + { + _options = options; + _repository = repository; + _scopeProvider = scopeProvider; + _timeProvider = timeProvider; + _logger = logger; + } + + /// + public Task> RunAsync( + string type, + Func operation, + bool allowConcurrentExecution = false, + bool runInBackground = true) + => RunInner( + type, + async cancellationToken => + { + await operation(cancellationToken); + return null; + }, + allowConcurrentExecution, + runInBackground); + + /// + public Task> RunAsync( + string type, + Func> operation, + bool allowConcurrentExecution = false, + bool runInBackground = true) + => RunInner( + type, + operation, + allowConcurrentExecution, + runInBackground); + + /// + public async Task GetStatusAsync(Guid operationId) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + return await _repository.GetStatusAsync(operationId); + } + + /// + public async Task> GetByTypeAsync( + string type, + int skip, + int take, + LongRunningOperationStatus[]? statuses = null) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + return await _repository.GetByTypeAsync( + type, + statuses ?? [LongRunningOperationStatus.Enqueued, LongRunningOperationStatus.Running], + skip, + take); + } + + /// + public async Task> GetResultAsync(Guid operationId) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + LongRunningOperation? operation = await _repository.GetAsync(operationId); + if (operation?.Status is not LongRunningOperationStatus.Success) + { + return Attempt.FailWithStatus( + operation?.Status switch + { + LongRunningOperationStatus.Enqueued or LongRunningOperationStatus.Running => LongRunningOperationResultStatus.OperationPending, + LongRunningOperationStatus.Failed => LongRunningOperationResultStatus.OperationFailed, + null => LongRunningOperationResultStatus.OperationNotFound, + _ => throw new ArgumentOutOfRangeException(nameof(operation.Status), operation.Status, "Unhandled operation status."), + }, + default); + } + + return Attempt.SucceedWithStatus(LongRunningOperationResultStatus.Success, operation.Result); + } + + private async Task> RunInner( + string type, + Func> operation, + bool allowConcurrentExecution = true, + bool runInBackground = true, + CancellationToken cancellationToken = default) + { + if (!runInBackground && _scopeProvider.Context is not null) + { + throw new InvalidOperationException("Long running operations cannot be executed in the foreground within an existing scope."); + } + + Guid operationId; + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) + { + if (allowConcurrentExecution is false) + { + // Acquire a write lock to ensure that no other operations of the same type can be enqueued while this one is being processed. + // This is only needed if we do not allow multiple runs of the same type. + scope.WriteLock(Constants.Locks.LongRunningOperations); + if (await IsAlreadyRunning(type)) + { + scope.Complete(); + return Attempt.FailWithStatus(LongRunningOperationEnqueueStatus.AlreadyRunning, Guid.Empty); + } + } + + operationId = Guid.CreateVersion7(); + await _repository.CreateAsync( + new LongRunningOperation + { + Id = operationId, + Type = type, + Status = LongRunningOperationStatus.Enqueued, + }, + _timeProvider.GetUtcNow() + _options.Value.ExpirationTime); + scope.Complete(); + } + + if (runInBackground) + { + using (ExecutionContext.SuppressFlow()) + { + _ = Task.Run( + async () => + { + try + { + await RunOperation(operationId, type, operation, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while running long-running background operation {Type} with id {OperationId}.", type, operationId); + } + }, + cancellationToken); + } + } + else + { + await RunOperation(operationId, type, operation, cancellationToken); + } + + return Attempt.SucceedWithStatus(LongRunningOperationEnqueueStatus.Success, operationId); + } + + private async Task RunOperation( + Guid operationId, + string type, + Func> operation, + CancellationToken cancellationToken = default) + { + _logger.LogDebug("Running long-running operation {Type} with id {OperationId}.", type, operationId); + + Task operationTask = operation(cancellationToken); + + Task task; + using (ExecutionContext.SuppressFlow()) + { + task = Task.Run( + () => TrackOperationStatus(operationId, type, operationTask), + CancellationToken.None); + } + + await task.ConfigureAwait(false); + } + + private async Task TrackOperationStatus( + Guid operationId, + string type, + Task operationTask) + { + _logger.LogDebug("Started tracking long-running operation {Type} with id {OperationId}.", type, operationId); + + try + { + while (operationTask.IsCompleted is false) + { + // Update the status in the database and increase the expiration time. + // That way, even if the status has not changed, we know that the operation is still being processed. + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) + { + await _repository.UpdateStatusAsync(operationId, LongRunningOperationStatus.Running, _timeProvider.GetUtcNow() + _options.Value.ExpirationTime); + scope.Complete(); + } + + await Task.WhenAny(operationTask, Task.Delay(_options.Value.TimeBetweenStatusChecks)).ConfigureAwait(false); + } + } + catch (Exception) + { + // If an exception occurs, we update the status to Failed and rethrow the exception. + _logger.LogDebug("Finished long-running operation {Type} with id {OperationId} and status {Status}.", type, operationId, LongRunningOperationStatus.Failed); + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) + { + await _repository.UpdateStatusAsync(operationId, LongRunningOperationStatus.Failed, _timeProvider.GetUtcNow() + _options.Value.ExpirationTime); + scope.Complete(); + } + + throw; + } + + _logger.LogDebug("Finished long-running operation {Type} with id {OperationId} and status {Status}.", type, operationId, LongRunningOperationStatus.Success); + + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) + { + await _repository.UpdateStatusAsync(operationId, LongRunningOperationStatus.Success, _timeProvider.GetUtcNow() + _options.Value.ExpirationTime); + if (operationTask.Result != null) + { + await _repository.SetResultAsync(operationId, operationTask.Result); + } + + scope.Complete(); + } + } + + private async Task IsAlreadyRunning(string type) + => (await _repository.GetByTypeAsync(type, [LongRunningOperationStatus.Enqueued, LongRunningOperationStatus.Running], 0, 0)).Total != 0; +} diff --git a/src/Umbraco.Core/Services/OperationStatus/LongRunningOperationEnqueueStatus.cs b/src/Umbraco.Core/Services/OperationStatus/LongRunningOperationEnqueueStatus.cs new file mode 100644 index 0000000000..2c964cb8c0 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/LongRunningOperationEnqueueStatus.cs @@ -0,0 +1,17 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +/// +/// Represents the result of attempting to enqueue a long-running operation. +/// +public enum LongRunningOperationEnqueueStatus +{ + /// + /// The operation was successfully enqueued and will be executed in the background. + /// + Success, + + /// + /// The operation is already running. + /// + AlreadyRunning, +} diff --git a/src/Umbraco.Core/Services/OperationStatus/LongRunningOperationResultStatus.cs b/src/Umbraco.Core/Services/OperationStatus/LongRunningOperationResultStatus.cs new file mode 100644 index 0000000000..a178aff19e --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/LongRunningOperationResultStatus.cs @@ -0,0 +1,27 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +/// +/// Represents the status of the result of a long-running operation. +/// +public enum LongRunningOperationResultStatus +{ + /// + /// The operation result was successfully retrieved. + /// + Success, + + /// + /// The operation was not found, possibly due to unknown type or ID or it was already deleted. + /// + OperationNotFound, + + /// + /// The operation is still running and the result is not yet available. + /// + OperationPending, + + /// + /// The operation has failed, and the result is not available. + /// + OperationFailed, +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LongRunningOperationsCleanupJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LongRunningOperationsCleanupJob.cs new file mode 100644 index 0000000000..46d139b08b --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LongRunningOperationsCleanupJob.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; + +/// +/// Cleans up long-running operations that have exceeded a specified age. +/// +public class LongRunningOperationsCleanupJob : IRecurringBackgroundJob +{ + private readonly ICoreScopeProvider _scopeProvider; + private readonly ILongRunningOperationRepository _longRunningOperationRepository; + private readonly TimeProvider _timeProvider; + private readonly TimeSpan _maxEntryAge; + + /// + /// Initializes a new instance of the class. + /// + /// The long-running operations settings. + /// The scope provider for managing database transactions. + /// The repository for managing long-running operations. + /// The time provider for getting the current time. + public LongRunningOperationsCleanupJob( + IOptions options, + ICoreScopeProvider scopeProvider, + ILongRunningOperationRepository longRunningOperationRepository, + TimeProvider timeProvider) + { + _scopeProvider = scopeProvider; + _longRunningOperationRepository = longRunningOperationRepository; + _timeProvider = timeProvider; + _maxEntryAge = options.Value.Cleanup.MaxEntryAge; + Period = options.Value.Cleanup.Period; + } + + /// + public event EventHandler? PeriodChanged + { + add { } + remove { } + } + + /// + public TimeSpan Period { get; } + + /// + public TimeSpan Delay { get; } = TimeSpan.FromSeconds(10); + + /// + public async Task RunJobAsync() + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + await _longRunningOperationRepository.CleanOperationsAsync(_timeProvider.GetUtcNow() - _maxEntryAge); + scope.Complete(); + } +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index 73ee0d263a..1b624aa5f6 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -82,6 +82,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Infrastructure/HostedServices/QueuedHostedService.cs b/src/Umbraco.Infrastructure/HostedServices/QueuedHostedService.cs index 79d93a928f..038691cc99 100644 --- a/src/Umbraco.Infrastructure/HostedServices/QueuedHostedService.cs +++ b/src/Umbraco.Infrastructure/HostedServices/QueuedHostedService.cs @@ -39,18 +39,26 @@ public class QueuedHostedService : BackgroundService { Func? workItem = await TaskQueue.DequeueAsync(stoppingToken); + if (workItem is null) + { + continue; + } + try { - if (workItem is not null) + Task task; + using (ExecutionContext.SuppressFlow()) { - await workItem(stoppingToken); + task = Task.Run(async () => await workItem(stoppingToken), stoppingToken); } + + await task; } catch (Exception ex) { _logger.LogError( ex, - "Error occurred executing {WorkItem}.", nameof(workItem)); + "Error occurred executing workItem."); } } } diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs index b30423be99..5255b79518 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs @@ -1050,6 +1050,7 @@ internal sealed class DatabaseDataCreator _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MainDom, Name = "MainDom" }); _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.WebhookRequest, Name = "WebhookRequest" }); _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.WebhookLogs, Name = "WebhookLogs" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.LongRunningOperations, Name = "LongRunningOperations" }); } private void CreateContentTypeData() diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index cd7fcd44d0..d454d97847 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -90,6 +90,7 @@ public class DatabaseSchemaCreator typeof(WebhookLogDto), typeof(WebhookRequestDto), typeof(UserDataDto), + typeof(LongRunningOperationDto), }; private readonly IUmbracoDatabase _database; diff --git a/src/Umbraco.Infrastructure/Migrations/MigrationPlanExecutor.cs b/src/Umbraco.Infrastructure/Migrations/MigrationPlanExecutor.cs index 48c8899c81..878d0e4d3b 100644 --- a/src/Umbraco.Infrastructure/Migrations/MigrationPlanExecutor.cs +++ b/src/Umbraco.Infrastructure/Migrations/MigrationPlanExecutor.cs @@ -122,7 +122,7 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor if (_rebuildCache) { _logger.LogInformation("Starts rebuilding the cache. This can be a long running operation"); - RebuildCache(); + await RebuildCache(); } // If any completed migration requires us to sign out the user we'll do that. @@ -295,11 +295,11 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor } } - private void RebuildCache() + private async Task RebuildCache() { _appCaches.RuntimeCache.Clear(); _appCaches.IsolatedCaches.ClearAllCaches(); - _databaseCacheRebuilder.Rebuild(false); + await _databaseCacheRebuilder.RebuildAsync(false); _distributedCache.RefreshAllPublishedSnapshot(); } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 21cfe558a6..c5a6abd4b7 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -119,5 +119,8 @@ public class UmbracoPlan : MigrationPlan // To 16.0.0 To("{C6681435-584F-4BC8-BB8D-BC853966AF0B}"); To("{D1568C33-A697-455F-8D16-48060CB954A1}"); + + // To 16.2.0 + To("{741C22CF-5FB8-4343-BF79-B97A58C2CCBA}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_2_0/AddLongRunningOperations.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_2_0/AddLongRunningOperations.cs new file mode 100644 index 0000000000..b9f9afcdfb --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_2_0/AddLongRunningOperations.cs @@ -0,0 +1,35 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_16_2_0; + +[Obsolete("Remove in Umbraco 18.")] +public class AddLongRunningOperations : MigrationBase +{ + public AddLongRunningOperations(IMigrationContext context) + : base(context) + { + } + + protected override void Migrate() + { + if (!TableExists(Constants.DatabaseSchema.Tables.LongRunningOperation)) + { + Create.Table().Do(); + } + + Sql sql = Database.SqlContext.Sql() + .Select() + .From() + .Where(x => x.Id == Constants.Locks.LongRunningOperations); + + LockDto? longRunningOperationsLock = Database.FirstOrDefault(sql); + if (longRunningOperationsLock is null) + { + Database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.LongRunningOperations, Name = "LongRunningOperations" }); + } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/LongRunningOperationDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/LongRunningOperationDto.cs new file mode 100644 index 0000000000..8b223b5c06 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/LongRunningOperationDto.cs @@ -0,0 +1,45 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(Constants.DatabaseSchema.Tables.LongRunningOperation)] +[PrimaryKey("id", AutoIncrement = false)] +[ExplicitColumns] +internal class LongRunningOperationDto +{ + [Column("id")] + [PrimaryKeyColumn(Name = "PK_umbracoLongRunningOperation", AutoIncrement = false)] + public Guid Id { get; set; } + + [Column("type")] + [Length(50)] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string Type { get; set; } = null!; + + [Column("status")] + [Length(50)] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string Status { get; set; } = null!; + + [Column("result")] + [SpecialDbType(SpecialDbTypes.NVARCHARMAX)] + [NullSetting(NullSetting = NullSettings.Null)] + public string? Result { get; set; } = null; + + [Column("createDate", ForceToUtc = false)] + [NullSetting(NullSetting = NullSettings.NotNull)] + [Constraint(Default = SystemMethods.CurrentDateTime)] + public DateTime CreateDate { get; set; } = DateTime.UtcNow; + + [Column("updateDate", ForceToUtc = false)] + [NullSetting(NullSetting = NullSettings.NotNull)] + [Constraint(Default = SystemMethods.CurrentDateTime)] + public DateTime UpdateDate { get; set; } = DateTime.UtcNow; + + [Column("expirationDate", ForceToUtc = false)] + [NullSetting(NullSetting = NullSettings.NotNull)] + public DateTime ExpirationDate { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Querying/ExpressionVisitorBase.cs b/src/Umbraco.Infrastructure/Persistence/Querying/ExpressionVisitorBase.cs index 897628f806..a585151929 100644 --- a/src/Umbraco.Infrastructure/Persistence/Querying/ExpressionVisitorBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/Querying/ExpressionVisitorBase.cs @@ -681,6 +681,11 @@ internal abstract class ExpressionVisitorBase inBuilder.Append(SqlParameters.Count - 1); } + if (inFirst) + { + inBuilder.Append("NULL"); + } + inBuilder.Append(")"); return inBuilder.ToString(); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LongRunningOperationRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LongRunningOperationRepository.cs new file mode 100644 index 0000000000..0f53cb1dd3 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LongRunningOperationRepository.cs @@ -0,0 +1,186 @@ +using System.Linq.Expressions; +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +/// +/// Repository for managing long-running operations. +/// +internal class LongRunningOperationRepository : RepositoryBase, ILongRunningOperationRepository +{ + private readonly IJsonSerializer _jsonSerializer; + private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new instance of the class. + /// + public LongRunningOperationRepository( + IJsonSerializer jsonSerializer, + IScopeAccessor scopeAccessor, + AppCaches appCaches, + TimeProvider timeProvider) + : base(scopeAccessor, appCaches) + { + _jsonSerializer = jsonSerializer; + _timeProvider = timeProvider; + } + + /// + public async Task CreateAsync(LongRunningOperation operation, DateTimeOffset expirationDate) + { + LongRunningOperationDto dto = MapEntityToDto(operation, expirationDate); + await Database.InsertAsync(dto); + } + + /// + public async Task GetAsync(Guid id) + { + Sql sql = Sql() + .Select() + .From() + .Where(x => x.Id == id); + + LongRunningOperationDto dto = await Database.FirstOrDefaultAsync(sql); + return dto == null ? null : MapDtoToEntity(dto); + } + + /// + public async Task?> GetAsync(Guid id) + { + Sql sql = Sql() + .Select() + .From() + .Where(x => x.Id == id); + + LongRunningOperationDto dto = await Database.FirstOrDefaultAsync(sql); + return dto == null ? null : MapDtoToEntity(dto); + } + + /// + public async Task> GetByTypeAsync( + string type, + LongRunningOperationStatus[] statuses, + int skip, + int take) + { + Sql sql = Sql() + .Select() + .From() + .Where(x => x.Type == type); + + if (statuses.Length > 0) + { + var includeStale = statuses.Contains(LongRunningOperationStatus.Stale); + string[] possibleStaleStatuses = + [nameof(LongRunningOperationStatus.Enqueued), nameof(LongRunningOperationStatus.Running)]; + IEnumerable statusList = statuses.Except([LongRunningOperationStatus.Stale]).Select(s => s.ToString()); + + DateTime now = _timeProvider.GetUtcNow().UtcDateTime; + sql = sql.Where(x => + (statusList.Contains(x.Status) && (!possibleStaleStatuses.Contains(x.Status) || x.ExpirationDate >= now)) + || (includeStale && possibleStaleStatuses.Contains(x.Status) && x.ExpirationDate < now)); + } + + return await Database.PagedAsync( + sql, + skip, + take, + sortingAction: sql2 => sql2.OrderBy(x => x.CreateDate), + mapper: MapDtoToEntity); + } + + /// + public async Task GetStatusAsync(Guid id) + { + Sql sql = Sql() + .Select(x => x.Status) + .From() + .Where(x => x.Id == id); + + return (await Database.ExecuteScalarAsync(sql))?.EnumParse(false); + } + + /// + public async Task UpdateStatusAsync(Guid id, LongRunningOperationStatus status, DateTimeOffset expirationTime) + { + Sql sql = Sql() + .Update(x => x + .Set(y => y.Status, status.ToString()) + .Set(y => y.UpdateDate, DateTime.UtcNow) + .Set(y => y.ExpirationDate, expirationTime.DateTime)) + .Where(x => x.Id == id); + + await Database.ExecuteAsync(sql); + } + + /// + public async Task SetResultAsync(Guid id, T result) + { + Sql sql = Sql() + .Update(x => x + .Set(y => y.Result, _jsonSerializer.Serialize(result)) + .Set(y => y.UpdateDate, DateTime.UtcNow)) + .Where(x => x.Id == id); + + await Database.ExecuteAsync(sql); + } + + /// + public async Task CleanOperationsAsync(DateTimeOffset olderThan) + { + Sql sql = Sql() + .Delete() + .Where(x => x.UpdateDate < olderThan); + + await Database.ExecuteAsync(sql); + } + + private LongRunningOperation MapDtoToEntity(LongRunningOperationDto dto) => + new() + { + Id = dto.Id, + Type = dto.Type, + Status = DetermineStatus(dto), + }; + + private LongRunningOperation MapDtoToEntity(LongRunningOperationDto dto) => + new() + { + Id = dto.Id, + Type = dto.Type, + Status = DetermineStatus(dto), + Result = dto.Result == null ? default : _jsonSerializer.Deserialize(dto.Result), + }; + + private static LongRunningOperationDto MapEntityToDto(LongRunningOperation entity, DateTimeOffset expirationTime) => + new() + { + Id = entity.Id, + Type = entity.Type, + Status = entity.Status.ToString(), + CreateDate = DateTime.UtcNow, + UpdateDate = DateTime.UtcNow, + ExpirationDate = expirationTime.UtcDateTime, + }; + + private LongRunningOperationStatus DetermineStatus(LongRunningOperationDto dto) + { + LongRunningOperationStatus status = dto.Status.EnumParse(false); + DateTimeOffset now = _timeProvider.GetUtcNow(); + if (status is LongRunningOperationStatus.Enqueued or LongRunningOperationStatus.Running + && now.UtcDateTime >= dto.ExpirationDate) + { + status = LongRunningOperationStatus.Stale; + } + + return status; + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/UmbracoDatabaseExtensions.cs b/src/Umbraco.Infrastructure/Persistence/UmbracoDatabaseExtensions.cs index 767655e4b3..7fd3f614f5 100644 --- a/src/Umbraco.Infrastructure/Persistence/UmbracoDatabaseExtensions.cs +++ b/src/Umbraco.Infrastructure/Persistence/UmbracoDatabaseExtensions.cs @@ -1,4 +1,6 @@ +using System.Linq.Expressions; using NPoco; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence; using Umbraco.Cms.Infrastructure.Persistence.Dtos; using Umbraco.Cms.Infrastructure.Persistence.SqlSyntax; @@ -82,4 +84,47 @@ internal static class UmbracoDatabaseExtensions return database.ExecuteScalar(query); } + + public static async Task CountAsync(this IUmbracoDatabase database, Sql sql) + { + // We need to copy the sql into a new object, to avoid this method from changing the sql. + Sql query = new Sql().Select("COUNT(*)").From().Append("(").Append(new Sql(sql.SQL, sql.Arguments)).Append(") as count_query"); + + return await database.ExecuteScalarAsync(query); + } + + public static async Task> PagedAsync( + this IUmbracoDatabase database, + Sql sql, + int skip, + int take, + Action> sortingAction, + Func mapper) + { + ArgumentOutOfRangeException.ThrowIfLessThan(skip, 0, nameof(skip)); + ArgumentOutOfRangeException.ThrowIfLessThan(take, 0, nameof(take)); + + var count = await database.CountAsync(sql); + if (take == 0 || skip >= count) + { + return new PagedModel + { + Total = count, + Items = [], + }; + } + + sortingAction(sql); + + List results = await database.SkipTakeAsync( + skip, + take, + sql); + + return new PagedModel + { + Total = count, + Items = results.Select(mapper), + }; + } } diff --git a/src/Umbraco.PublishedCache.HybridCache/DatabaseCacheRebuilder.cs b/src/Umbraco.PublishedCache.HybridCache/DatabaseCacheRebuilder.cs index 40ad248728..b61daf7f7c 100644 --- a/src/Umbraco.PublishedCache.HybridCache/DatabaseCacheRebuilder.cs +++ b/src/Umbraco.PublishedCache.HybridCache/DatabaseCacheRebuilder.cs @@ -1,12 +1,13 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Infrastructure.HybridCache.Persistence; namespace Umbraco.Cms.Infrastructure.HybridCache; @@ -17,7 +18,7 @@ namespace Umbraco.Cms.Infrastructure.HybridCache; internal sealed class DatabaseCacheRebuilder : IDatabaseCacheRebuilder { private const string NuCacheSerializerKey = "Umbraco.Web.PublishedCache.NuCache.Serializer"; - private const string IsRebuildingDatabaseCacheRuntimeCacheKey = "temp_database_cache_rebuild_op"; + private const string RebuildOperationName = "DatabaseCacheRebuild"; private readonly IDatabaseCacheRepository _databaseCacheRepository; private readonly ICoreScopeProvider _coreScopeProvider; @@ -25,8 +26,7 @@ internal sealed class DatabaseCacheRebuilder : IDatabaseCacheRebuilder private readonly IKeyValueService _keyValueService; private readonly ILogger _logger; private readonly IProfilingLogger _profilingLogger; - private readonly IBackgroundTaskQueue _backgroundTaskQueue; - private readonly IAppPolicyCache _runtimeCache; + private readonly ILongRunningOperationService _longRunningOperationService; /// /// Initializes a new instance of the class. @@ -38,8 +38,7 @@ internal sealed class DatabaseCacheRebuilder : IDatabaseCacheRebuilder IKeyValueService keyValueService, ILogger logger, IProfilingLogger profilingLogger, - IBackgroundTaskQueue backgroundTaskQueue, - IAppPolicyCache runtimeCache) + ILongRunningOperationService longRunningOperationService) { _databaseCacheRepository = databaseCacheRepository; _coreScopeProvider = coreScopeProvider; @@ -47,65 +46,60 @@ internal sealed class DatabaseCacheRebuilder : IDatabaseCacheRebuilder _keyValueService = keyValueService; _logger = logger; _profilingLogger = profilingLogger; - _backgroundTaskQueue = backgroundTaskQueue; - _runtimeCache = runtimeCache; + _longRunningOperationService = longRunningOperationService; } /// - public bool IsRebuilding() => _runtimeCache.Get(IsRebuildingDatabaseCacheRuntimeCacheKey) is not null; + public bool IsRebuilding() => IsRebuildingAsync().GetAwaiter().GetResult(); /// + public async Task IsRebuildingAsync() + => (await _longRunningOperationService.GetByTypeAsync(RebuildOperationName, 0, 0)).Total != 0; + + /// + [Obsolete("Use the overload with the useBackgroundThread parameter. Scheduled for removal in Umbraco 17.")] public void Rebuild() => Rebuild(false); /// - public void Rebuild(bool useBackgroundThread) - { - if (useBackgroundThread) - { - _logger.LogInformation("Starting async background thread for rebuilding database cache."); - - _backgroundTaskQueue.QueueBackgroundWorkItem( - cancellationToken => - { - using (ExecutionContext.SuppressFlow()) - { - Task.Run(() => PerformRebuild()); - return Task.CompletedTask; - } - }); - } - else - { - PerformRebuild(); - } - } - - private void PerformRebuild() - { - try - { - SetIsRebuilding(); - - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - _databaseCacheRepository.Rebuild(); - scope.Complete(); - } - finally - { - ClearIsRebuilding(); - } - } - - private void SetIsRebuilding() => _runtimeCache.Insert(IsRebuildingDatabaseCacheRuntimeCacheKey, () => "tempValue", TimeSpan.FromMinutes(10)); - - private void ClearIsRebuilding() => _runtimeCache.Clear(IsRebuildingDatabaseCacheRuntimeCacheKey); + [Obsolete("Use RebuildAsync instead. Scheduled for removal in Umbraco 18.")] + public void Rebuild(bool useBackgroundThread) => + RebuildAsync(useBackgroundThread).GetAwaiter().GetResult(); /// - public void RebuildDatabaseCacheIfSerializerChanged() + public async Task> RebuildAsync(bool useBackgroundThread) + { + Attempt attempt = await _longRunningOperationService.RunAsync( + RebuildOperationName, + _ => PerformRebuild(), + allowConcurrentExecution: false, + runInBackground: useBackgroundThread); + + if (attempt.Success) + { + return Attempt.Succeed(DatabaseCacheRebuildResult.Success); + } + + return attempt.Status switch + { + LongRunningOperationEnqueueStatus.AlreadyRunning => Attempt.Fail(DatabaseCacheRebuildResult.AlreadyRunning), + _ => throw new InvalidOperationException( + $"Unexpected status {attempt.Status} when trying to enqueue the database cache rebuild operation."), + }; + } + + /// + public void RebuildDatabaseCacheIfSerializerChanged() => + RebuildDatabaseCacheIfSerializerChangedAsync().GetAwaiter().GetResult(); + + /// + public async Task RebuildDatabaseCacheIfSerializerChangedAsync() { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); NuCacheSerializerType serializer = _nucacheSettings.Value.NuCacheSerializerType; - var currentSerializerValue = _keyValueService.GetValue(NuCacheSerializerKey); + string? currentSerializerValue; + using (ICoreScope scope = _coreScopeProvider.CreateCoreScope(autoComplete: true)) + { + currentSerializerValue = _keyValueService.GetValue(NuCacheSerializerKey); + } if (Enum.TryParse(currentSerializerValue, out NuCacheSerializerType currentSerializer) && serializer == currentSerializer) { @@ -119,10 +113,24 @@ internal sealed class DatabaseCacheRebuilder : IDatabaseCacheRebuilder using (_profilingLogger.TraceDuration($"Rebuilding database cache with {serializer} serializer")) { - Rebuild(false); - _keyValueService.SetValue(NuCacheSerializerKey, serializer.ToString()); + await RebuildAsync(false); + } + } + + private Task PerformRebuild() + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + _databaseCacheRepository.Rebuild(); + + // If the serializer type has changed, we also need to update it in the key value store. + var currentSerializerValue = _keyValueService.GetValue(NuCacheSerializerKey); + if (!Enum.TryParse(currentSerializerValue, out NuCacheSerializerType currentSerializer) || + _nucacheSettings.Value.NuCacheSerializerType != currentSerializer) + { + _keyValueService.SetValue(NuCacheSerializerKey, _nucacheSettings.Value.NuCacheSerializerType.ToString()); } scope.Complete(); + return Task.CompletedTask; } } diff --git a/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/HybridCacheStartupNotificationHandler.cs b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/HybridCacheStartupNotificationHandler.cs index 19d524f791..43b19ab17f 100644 --- a/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/HybridCacheStartupNotificationHandler.cs +++ b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/HybridCacheStartupNotificationHandler.cs @@ -20,13 +20,13 @@ public class HybridCacheStartupNotificationHandler : INotificationAsyncHandler RuntimeLevel.Install) + if (_runtimeState.Level <= RuntimeLevel.Install) { - _databaseCacheRebuilder.RebuildDatabaseCacheIfSerializerChanged(); + return; } - return Task.CompletedTask; + await _databaseCacheRebuilder.RebuildDatabaseCacheIfSerializerChangedAsync(); } } diff --git a/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs b/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs index 5ae2260cb8..91f8ad8e92 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs @@ -120,7 +120,6 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe RebuildContentDbCache(serializer, _nucacheSettings.Value.SqlPageSize, contentTypeIds); RebuildMediaDbCache(serializer, _nucacheSettings.Value.SqlPageSize, mediaTypeIds); RebuildMemberDbCache(serializer, _nucacheSettings.Value.SqlPageSize, memberTypeIds); - } public async Task GetContentSourceAsync(Guid key, bool preview = false) diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 2fd5348f4a..d0eec0039e 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -190,7 +190,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddRecurringBackgroundJob(); builder.Services.AddRecurringBackgroundJob(); builder.Services.AddRecurringBackgroundJob(); - + builder.Services.AddRecurringBackgroundJob(); builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory); builder.Services.AddHostedService(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/LongRunningOperationRepositoryTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/LongRunningOperationRepositoryTests.cs new file mode 100644 index 0000000000..2ad6d33f26 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/LongRunningOperationRepositoryTests.cs @@ -0,0 +1,228 @@ +using System.Data.Common; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Persistence.Repositories; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class LongRunningOperationRepositoryTests : UmbracoIntegrationTest +{ + [Test] + public async Task Get_ReturnsNull_WhenOperationDoesNotExist() + { + var provider = ScopeProvider; + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider); + await CreateTestData(repository); + + var result = await repository.GetAsync(Guid.NewGuid()); + Assert.IsNull(result); + } + + [Test] + public async Task Get_ReturnsExpectedOperation_WhenOperationExists() + { + var provider = ScopeProvider; + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider); + await CreateTestData(repository); + + var testOperation = _operations[1]; + var result = await repository.GetAsync(testOperation.Operation.Id); + + Assert.IsNotNull(result); + Assert.AreEqual(testOperation.Operation.Id, result.Id); + Assert.AreEqual(testOperation.Operation.Type, result.Type); + Assert.AreEqual(testOperation.Operation.Status, result.Status); + } + + [TestCase("Test", new LongRunningOperationStatus[] { }, 0, 100, 5, 5)] + [TestCase("Test", new[] { LongRunningOperationStatus.Enqueued }, 0, 100, 1, 1)] + [TestCase("Test", new[] { LongRunningOperationStatus.Running }, 0, 100, 1, 1)] + [TestCase("Test", new[] { LongRunningOperationStatus.Enqueued, LongRunningOperationStatus.Running }, 0, 100, 2, 2)] + [TestCase("Test", new[] { LongRunningOperationStatus.Stale }, 0, 100, 1, 1)] + [TestCase("Test", new[] { LongRunningOperationStatus.Running, LongRunningOperationStatus.Stale }, 0, 100, 2, 2)] + [TestCase("Test", new[] { LongRunningOperationStatus.Success, LongRunningOperationStatus.Stale }, 0, 100, 2, 2)] + [TestCase("AnotherTest", new LongRunningOperationStatus[] { }, 0, 100, 1, 1)] + [TestCase("Test", new LongRunningOperationStatus[] { }, 0, 0, 0, 5)] + [TestCase("Test", new LongRunningOperationStatus[] { }, 0, 1, 1, 5)] + [TestCase("Test", new LongRunningOperationStatus[] { }, 2, 2, 2, 5)] + [TestCase("Test", new LongRunningOperationStatus[] { }, 5, 1, 0, 5)] + public async Task GetByType_ReturnsExpectedOperations(string type, LongRunningOperationStatus[] statuses, int skip, int take, int expectedCount, int expectedTotal) + { + var provider = ScopeProvider; + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider); + await CreateTestData(repository); + + var result = await repository.GetByTypeAsync(type, statuses, skip, take); + + Assert.IsNotNull(result); + Assert.AreEqual(expectedCount, result.Items.Count(), "Count of returned items should match the expected count"); + Assert.AreEqual(expectedTotal, result.Total, "Total count should match the expected total count"); + } + + [Test] + public async Task GetStatus_ReturnsNull_WhenOperationDoesNotExist() + { + var provider = ScopeProvider; + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider); + await CreateTestData(repository); + + var result = await repository.GetStatusAsync(Guid.NewGuid()); + Assert.IsNull(result); + } + + [Test] + public async Task GetStatus_ReturnsExpectedStatus_WhenOperationExists() + { + var provider = ScopeProvider; + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider); + await CreateTestData(repository); + + var result = await repository.GetStatusAsync(_operations[0].Operation.Id); + Assert.AreEqual(_operations[0].Operation.Status, result); + } + + [Test] + public async Task Create_InsertsOperationIntoDatabase() + { + var provider = ScopeProvider; + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider); + await CreateTestData(repository); + + var newOperation = new LongRunningOperation + { + Id = Guid.NewGuid(), + Type = "NewTest", + Status = LongRunningOperationStatus.Enqueued, + }; + await repository.CreateAsync(newOperation, DateTimeOffset.UtcNow.AddMinutes(5)); + + var result = await repository.GetAsync(newOperation.Id); + Assert.IsNotNull(result); + Assert.AreEqual(newOperation.Id, result.Id); + Assert.AreEqual(newOperation.Type, result.Type); + Assert.AreEqual(newOperation.Status, result.Status); + } + + [Test] + public async Task Create_ThrowsException_WhenOperationWithTheSameIdExists() + { + var provider = ScopeProvider; + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider); + await CreateTestData(repository); + + var newOperation = new LongRunningOperation + { + Id = _operations[0].Operation.Id, + Type = "NewTest", + Status = LongRunningOperationStatus.Enqueued, + }; + Assert.ThrowsAsync(Is.InstanceOf(), () => repository.CreateAsync(newOperation, DateTimeOffset.UtcNow.AddMinutes(5))); + } + + [Test] + public async Task UpdateStatus_UpdatesOperationStatusInDatabase() + { + var provider = ScopeProvider; + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider); + await CreateTestData(repository); + + var testOperation = _operations[1]; + repository.UpdateStatusAsync(testOperation.Operation.Id, LongRunningOperationStatus.Failed, DateTimeOffset.UtcNow); + + var result = await repository.GetAsync(testOperation.Operation.Id); + Assert.IsNotNull(result); + Assert.AreEqual(LongRunningOperationStatus.Failed, result.Status); + } + + [Test] + public async Task SetResult_UpdatesOperationResultInDatabase() + { + var provider = ScopeProvider; + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider); + await CreateTestData(repository); + + var testOperation = _operations[1]; + var opResult = new LongRunningOperationResult { Result = true }; + await repository.SetResultAsync(testOperation.Operation.Id, opResult); + + var result = await repository.GetAsync(testOperation.Operation.Id); + Assert.IsNotNull(result); + Assert.IsNotNull(result.Result); + Assert.AreEqual(opResult.Result, result.Result.Result); + } + + [Test] + public async Task CleanOperations_RemovesOldOperationsFromTheDatabase() + { + var provider = ScopeProvider; + using var scope = provider.CreateScope(); + var repository = CreateRepository(provider); + await CreateTestData(repository); + + var oldOperation = _operations[0]; + + // Check that the operation is present before cleaning + var result = await repository.GetAsync(oldOperation.Operation.Id); + Assert.IsNotNull(result); + + await repository.CleanOperationsAsync(DateTimeOffset.UtcNow.AddMinutes(1)); + + // Check that the operation is removed after cleaning + result = await repository.GetAsync(oldOperation.Operation.Id); + Assert.IsNull(result); + } + + private LongRunningOperationRepository CreateRepository(IScopeProvider provider) + => new(GetRequiredService(), (IScopeAccessor)provider, AppCaches.Disabled, TimeProvider.System); + + private async Task CreateTestData(LongRunningOperationRepository repository) + { + foreach (var op in _operations) + { + await repository.CreateAsync(op.Operation, op.ExpiresIn); + } + } + + private readonly List<(LongRunningOperation Operation, DateTimeOffset ExpiresIn)> _operations = + [ + ( + Operation: new LongRunningOperation { Id = Guid.NewGuid(), Type = "Test", Status = LongRunningOperationStatus.Success }, + ExpiresIn: DateTimeOffset.UtcNow.AddMinutes(5)), + ( + Operation: new LongRunningOperation { Id = Guid.NewGuid(), Type = "Test", Status = LongRunningOperationStatus.Enqueued }, + ExpiresIn: DateTimeOffset.UtcNow.AddMinutes(5)), + ( + Operation: new LongRunningOperation { Id = Guid.NewGuid(), Type = "Test", Status = LongRunningOperationStatus.Running }, + ExpiresIn: DateTimeOffset.UtcNow.AddMinutes(5)), + ( + Operation: new LongRunningOperation { Id = Guid.NewGuid(), Type = "Test", Status = LongRunningOperationStatus.Running }, + ExpiresIn: DateTimeOffset.UtcNow.AddMinutes(-1)), + ( + Operation: new LongRunningOperation { Id = Guid.NewGuid(), Type = "Test", Status = LongRunningOperationStatus.Failed }, + ExpiresIn: DateTimeOffset.UtcNow.AddMinutes(-1)), + ( + Operation: new LongRunningOperation { Id = Guid.NewGuid(), Type = "AnotherTest", Status = LongRunningOperationStatus.Success, }, + ExpiresIn: DateTimeOffset.UtcNow.AddMinutes(5)), + ]; + + private class LongRunningOperationResult + { + public bool Result { get; init; } + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/LongRunningOperationServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/LongRunningOperationServiceTests.cs new file mode 100644 index 0000000000..4174f1235e --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/LongRunningOperationServiceTests.cs @@ -0,0 +1,417 @@ +using System.Data; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services; + +[TestFixture] +public class LongRunningOperationServiceTests +{ + private ILongRunningOperationService _longRunningOperationService; + private Mock _scopeProviderMock; + private Mock _longRunningOperationRepositoryMock; + private Mock _timeProviderMock; + private Mock _scopeMock; + + [SetUp] + public void Setup() + { + _scopeProviderMock = new Mock(MockBehavior.Strict); + _longRunningOperationRepositoryMock = new Mock(MockBehavior.Strict); + _timeProviderMock = new Mock(MockBehavior.Strict); + _scopeMock = new Mock(); + + _longRunningOperationService = new LongRunningOperationService( + Options.Create(new LongRunningOperationsSettings()), + _longRunningOperationRepositoryMock.Object, + _scopeProviderMock.Object, + _timeProviderMock.Object, + Mock.Of>()); + } + + [Test] + public async Task Run_ReturnsFailedAttempt_WhenOperationIsAlreadyRunning() + { + SetupScopeProviderMock(); + _longRunningOperationRepositoryMock + .Setup(repo => repo.GetByTypeAsync("Test", It.IsAny(), 0, 0)) + .Callback((_, statuses, _, _) => + { + Assert.AreEqual(2, statuses.Length); + Assert.Contains(LongRunningOperationStatus.Enqueued, statuses); + Assert.Contains(LongRunningOperationStatus.Running, statuses); + }) + .ReturnsAsync( + new PagedModel + { + Total = 1, + Items = new List { new() { Id = Guid.NewGuid(), Type = "Test", Status = LongRunningOperationStatus.Running } }, + }) + .Verifiable(Times.Once); + + var result = await _longRunningOperationService.RunAsync( + "Test", + _ => Task.CompletedTask, + allowConcurrentExecution: false, + runInBackground: true); + + _longRunningOperationRepositoryMock.VerifyAll(); + + Assert.IsFalse(result.Success); + Assert.AreEqual(LongRunningOperationEnqueueStatus.AlreadyRunning, result.Status); + } + + [Test] + public async Task Run_CreatesAndRunsOperation_WhenNotInBackground() + { + SetupScopeProviderMock(); + + _timeProviderMock.Setup(repo => repo.GetUtcNow()) + .Returns(() => DateTime.UtcNow) + .Verifiable(Times.Exactly(2)); + + _longRunningOperationRepositoryMock + .Setup(repo => repo.CreateAsync(It.IsAny(), It.IsAny())) + .Callback((op, exp) => + { + Assert.AreEqual("Test", op.Type); + Assert.IsNotNull(op.Id); + Assert.AreEqual(LongRunningOperationStatus.Enqueued, op.Status); + }) + .Returns(Task.CompletedTask) + .Verifiable(Times.Once); + + _scopeProviderMock.Setup(scopeProvider => scopeProvider.Context) + .Returns(default(IScopeContext?)) + .Verifiable(Times.Exactly(1)); + + var expectedStatuses = new List + { + LongRunningOperationStatus.Enqueued, + LongRunningOperationStatus.Running, + LongRunningOperationStatus.Success, + }; + + _longRunningOperationRepositoryMock.Setup(repo => repo.UpdateStatusAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((id, status, exp) => + { + Assert.Contains(status, expectedStatuses); + }) + .Returns(Task.CompletedTask); + + var opCalls = 0; + var result = await _longRunningOperationService.RunAsync( + "Test", + _ => + { + opCalls++; + return Task.CompletedTask; + }, + allowConcurrentExecution: true, + runInBackground: false); + + _longRunningOperationRepositoryMock.VerifyAll(); + + Assert.IsTrue(result.Success); + Assert.AreEqual(LongRunningOperationEnqueueStatus.Success, result.Status); + Assert.AreEqual(1, opCalls, "Operation should have run and increased the call count, since it's not configured to run in the background."); + } + + [Test] + public void Run_ThrowsException_WhenAttemptingToRunOperationNotInBackgroundInsideAScope() + { + SetupScopeProviderMock(); + + _scopeProviderMock.Setup(scopeProvider => scopeProvider.Context) + .Returns(new ScopeContext()) + .Verifiable(Times.Exactly(1)); + + var opCalls = 0; + Assert.ThrowsAsync(async () => await _longRunningOperationService.RunAsync( + "Test", + _ => + { + opCalls++; + return Task.CompletedTask; + }, + allowConcurrentExecution: true, + runInBackground: false)); + Assert.AreEqual(0, opCalls, "The operation should not have been called."); + } + + [Test] + public async Task Run_CreatesAndQueuesOperation_WhenInBackground() + { + SetupScopeProviderMock(); + + _timeProviderMock.Setup(repo => repo.GetUtcNow()) + .Returns(() => DateTime.UtcNow) + .Verifiable(Times.Exactly(2)); + + _longRunningOperationRepositoryMock + .Setup(repo => repo.CreateAsync(It.IsAny(), It.IsAny())) + .Callback((op, exp) => + { + Assert.AreEqual("Test", op.Type); + Assert.IsNotNull(op.Id); + Assert.AreEqual(LongRunningOperationStatus.Enqueued, op.Status); + }) + .Returns(Task.CompletedTask) + .Verifiable(Times.Once); + + var result = await _longRunningOperationService.RunAsync( + "Test", + _ => Task.CompletedTask, + allowConcurrentExecution: true, + runInBackground: true); + + _longRunningOperationRepositoryMock.VerifyAll(); + + Assert.IsTrue(result.Success); + Assert.AreEqual(LongRunningOperationEnqueueStatus.Success, result.Status); + } + + [Test] + public async Task GetStatus_ReturnsExpectedStatus_WhenOperationExists() + { + SetupScopeProviderMock(); + var operationId = Guid.NewGuid(); + _longRunningOperationRepositoryMock + .Setup(repo => repo.GetStatusAsync(operationId)) + .ReturnsAsync(LongRunningOperationStatus.Running) + .Verifiable(Times.Once); + + var status = await _longRunningOperationService.GetStatusAsync(operationId); + + _longRunningOperationRepositoryMock.VerifyAll(); + Assert.IsTrue(status.HasValue); + Assert.AreEqual(LongRunningOperationStatus.Running, status.Value); + } + + [Test] + public async Task GetStatus_ReturnsNull_WhenOperationDoesNotExist() + { + SetupScopeProviderMock(); + var operationId = Guid.NewGuid(); + _longRunningOperationRepositoryMock + .Setup(repo => repo.GetStatusAsync(operationId)) + .ReturnsAsync((LongRunningOperationStatus?)null) + .Verifiable(Times.Once); + + var status = await _longRunningOperationService.GetStatusAsync(operationId); + + _longRunningOperationRepositoryMock.VerifyAll(); + Assert.IsFalse(status.HasValue); + } + + [Test] + public async Task GetByType_ReturnsExpectedOperations_WhenOperationsExist() + { + SetupScopeProviderMock(); + const string operationType = "Test"; + var operations = new List + { + new() { Id = Guid.NewGuid(), Type = operationType, Status = LongRunningOperationStatus.Running }, + new() { Id = Guid.NewGuid(), Type = operationType, Status = LongRunningOperationStatus.Enqueued }, + }; + _longRunningOperationRepositoryMock + .Setup(repo => repo.GetByTypeAsync(operationType, It.IsAny(), 0, 100)) + .Callback((_, statuses, _, _) => + { + Assert.AreEqual(2, statuses.Length); + Assert.Contains(LongRunningOperationStatus.Enqueued, statuses); + Assert.Contains(LongRunningOperationStatus.Running, statuses); + }) + .ReturnsAsync( + new PagedModel + { + Total = 2, + Items = operations, + }) + .Verifiable(Times.Once); + + var result = await _longRunningOperationService.GetByTypeAsync(operationType, 0, 100); + + _longRunningOperationRepositoryMock.VerifyAll(); + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Items.Count()); + Assert.AreEqual(2, result.Total); + Assert.IsTrue(result.Items.All(op => op.Type == operationType)); + } + + [Test] + public async Task GetByType_ReturnsExpectedOperations_WhenOperationsExistWithProvidedStatuses() + { + SetupScopeProviderMock(); + const string operationType = "Test"; + var operations = new List + { + new() { Id = Guid.NewGuid(), Type = operationType, Status = LongRunningOperationStatus.Failed }, + }; + _longRunningOperationRepositoryMock + .Setup(repo => repo.GetByTypeAsync(operationType, It.IsAny(), 0, 30)) + .Callback((type, statuses, _, _) => + { + Assert.AreEqual(1, statuses.Length); + Assert.Contains(LongRunningOperationStatus.Failed, statuses); + }) + .ReturnsAsync( + new PagedModel + { + Total = 1, + Items = operations, + }) + .Verifiable(Times.Once); + + var result = await _longRunningOperationService.GetByTypeAsync(operationType, 0, 30, [LongRunningOperationStatus.Failed]); + + _longRunningOperationRepositoryMock.VerifyAll(); + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Total); + Assert.AreEqual(1, result.Items.Count()); + Assert.IsTrue(result.Items.All(op => op.Type == operationType)); + } + + [Test] + public async Task GetResult_ReturnsExpectedResult_WhenOperationExists() + { + SetupScopeProviderMock(); + const string operationType = "Test"; + var operationId = Guid.NewGuid(); + const string expectedResult = "TestResult"; + _longRunningOperationRepositoryMock + .Setup(repo => repo.GetAsync(operationId)) + .ReturnsAsync( + new LongRunningOperation + { + Id = operationId, + Type = operationType, + Status = LongRunningOperationStatus.Success, + Result = expectedResult, + }) + .Verifiable(Times.Once); + + var result = await _longRunningOperationService.GetResultAsync(operationId); + + _longRunningOperationRepositoryMock.VerifyAll(); + Assert.IsTrue(result.Success); + Assert.AreEqual(LongRunningOperationResultStatus.Success, result.Status); + Assert.AreEqual(expectedResult, result.Result); + } + + [Test] + public async Task GetResult_ReturnsFailedAttempt_WhenOperationDoesNotExist() + { + SetupScopeProviderMock(); + var operationId = Guid.NewGuid(); + _longRunningOperationRepositoryMock + .Setup(repo => repo.GetAsync(operationId)) + .ReturnsAsync(default(LongRunningOperation)) + .Verifiable(Times.Once); + + var result = await _longRunningOperationService.GetResultAsync(operationId); + + _longRunningOperationRepositoryMock.VerifyAll(); + Assert.IsFalse(result.Success); + Assert.AreEqual(result.Status, LongRunningOperationResultStatus.OperationNotFound); + Assert.IsNull(result.Result); + } + + [Test] + public async Task GetResult_ReturnsFailedAttempt_WhenOperationFailed() + { + SetupScopeProviderMock(); + const string operationType = "Test"; + var operationId = Guid.NewGuid(); + _longRunningOperationRepositoryMock + .Setup(repo => repo.GetAsync(operationId)) + .ReturnsAsync( + new LongRunningOperation + { + Id = operationId, + Type = operationType, + Status = LongRunningOperationStatus.Failed, + }) + .Verifiable(Times.Once); + + var result = await _longRunningOperationService.GetResultAsync(operationId); + + _longRunningOperationRepositoryMock.VerifyAll(); + Assert.IsFalse(result.Success); + Assert.AreEqual(result.Status, LongRunningOperationResultStatus.OperationFailed); + Assert.IsNull(result.Result); + } + + [Test] + public async Task GetResult_ReturnsFailedAttempt_WhenOperationIsRunning() + { + SetupScopeProviderMock(); + const string operationType = "Test"; + var operationId = Guid.NewGuid(); + _longRunningOperationRepositoryMock + .Setup(repo => repo.GetAsync(operationId)) + .ReturnsAsync( + new LongRunningOperation + { + Id = operationId, + Type = operationType, + Status = LongRunningOperationStatus.Running, + }) + .Verifiable(Times.Once); + + var result = await _longRunningOperationService.GetResultAsync(operationId); + + _longRunningOperationRepositoryMock.VerifyAll(); + Assert.IsFalse(result.Success); + Assert.AreEqual(result.Status, LongRunningOperationResultStatus.OperationPending); + Assert.IsNull(result.Result); + } + + [Test] + public async Task GetResult_ReturnsFailedAttempt_WhenOperationIsEnqueued() + { + SetupScopeProviderMock(); + const string operationType = "Test"; + var operationId = Guid.NewGuid(); + _longRunningOperationRepositoryMock + .Setup(repo => repo.GetAsync(operationId)) + .ReturnsAsync( + new LongRunningOperation + { + Id = operationId, + Type = operationType, + Status = LongRunningOperationStatus.Enqueued, + }) + .Verifiable(Times.Once); + + var result = await _longRunningOperationService.GetResultAsync(operationId); + + _longRunningOperationRepositoryMock.VerifyAll(); + Assert.IsFalse(result.Success); + Assert.AreEqual(result.Status, LongRunningOperationResultStatus.OperationPending); + Assert.IsNull(result.Result); + } + + private void SetupScopeProviderMock() => + _scopeProviderMock + .Setup(x => x.CreateCoreScope( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(_scopeMock.Object); +} + +