From 7966980e4141d73b10640d8221a5877db5c9df33 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 19 Oct 2023 10:56:37 -0700 Subject: [PATCH] Add a way to restart a workflow instance (#14470) --- .../Workflows/Activities/ContentActivity.cs | 42 ++++- .../Workflows/Handlers/ContentsHandler.cs | 3 +- .../Controllers/WorkflowController.cs | 52 ++++++ .../Services/WorkflowManager.cs | 165 +++++++++++++----- .../Drivers/UserTaskEventContentDriver.cs | 3 +- .../Views/Workflow/Details.cshtml | 39 +++-- .../Views/Workflow/Index.cshtml | 33 ++-- .../Workflows/ContentEventContext.cs | 1 + .../Activities/Activity.cs | 10 ++ .../Activities/IActivity.cs | 10 ++ .../Services/IWorkflowManager.cs | 27 +++ src/docs/releases/1.8.0.md | 15 +- 12 files changed, 323 insertions(+), 77 deletions(-) diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Activities/ContentActivity.cs b/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Activities/ContentActivity.cs index 72711504803..5c94e70abd0 100644 --- a/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Activities/ContentActivity.cs +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Activities/ContentActivity.cs @@ -1,8 +1,9 @@ -using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Localization; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using OrchardCore.ContentManagement; using OrchardCore.ContentManagement.Workflows; using OrchardCore.Workflows.Abstractions.Models; @@ -17,7 +18,10 @@ public abstract class ContentActivity : Activity { protected readonly IStringLocalizer S; - protected ContentActivity(IContentManager contentManager, IWorkflowScriptEvaluator scriptEvaluator, IStringLocalizer localizer) + protected ContentActivity( + IContentManager contentManager, + IWorkflowScriptEvaluator scriptEvaluator, + IStringLocalizer localizer) { ContentManager = contentManager; ScriptEvaluator = scriptEvaluator; @@ -68,6 +72,40 @@ public override ActivityExecutionResult Execute(WorkflowExecutionContext workflo return Outcomes("Done"); } + public override async Task OnWorkflowRestartingAsync(WorkflowExecutionContext workflowContext, CancellationToken cancellationToken = default) + { + ContentItem contentItem = null; + + if (workflowContext.Input.TryGetValue(ContentEventConstants.ContentEventInputKey, out var contentEvent)) + { + var contentEventContext = ((JObject)contentEvent).ToObject(); + + if (contentEventContext?.ContentItemVersionId != null) + { + contentItem = await ContentManager.GetVersionAsync(contentEventContext.ContentItemVersionId); + } + if (contentItem == null && contentEventContext?.ContentItemId != null) + { + contentItem = await ContentManager.GetAsync(contentEventContext.ContentItemId); + } + } + + if (contentItem == null && workflowContext.Input.TryGetValue(ContentEventConstants.ContentItemInputKey, out var contentItemEvent)) + { + var item = ((JObject)contentItemEvent).ToObject(); + + if (item?.ContentItemId != null) + { + contentItem = await ContentManager.GetAsync(item.ContentItemId); + } + } + + if (contentItem != null) + { + workflowContext.Input[ContentEventConstants.ContentItemInputKey] = contentItem; + } + } + protected virtual async Task GetContentAsync(WorkflowExecutionContext workflowContext) { IContent content; diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Handlers/ContentsHandler.cs b/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Handlers/ContentsHandler.cs index 07a72f186da..2e2d62a9654 100644 --- a/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Handlers/ContentsHandler.cs +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Handlers/ContentsHandler.cs @@ -58,7 +58,8 @@ private Task TriggerWorkflowEventAsync(string name, ContentItem contentItem) { Name = name, ContentType = contentItem.ContentType, - ContentItemId = contentItem.ContentItemId + ContentItemId = contentItem.ContentItemId, + ContentItemVersionId = contentItem.ContentItemVersionId, }; var input = new Dictionary diff --git a/src/OrchardCore.Modules/OrchardCore.Workflows/Controllers/WorkflowController.cs b/src/OrchardCore.Modules/OrchardCore.Workflows/Controllers/WorkflowController.cs index e7223836dcf..4f4de22c1c1 100644 --- a/src/OrchardCore.Modules/OrchardCore.Workflows/Controllers/WorkflowController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Workflows/Controllers/WorkflowController.cs @@ -15,6 +15,7 @@ using OrchardCore.DisplayManagement; using OrchardCore.DisplayManagement.ModelBinding; using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Locking.Distributed; using OrchardCore.Mvc.Core.Utilities; using OrchardCore.Navigation; using OrchardCore.Routing; @@ -42,6 +43,7 @@ public class WorkflowController : Controller private readonly IUpdateModelAccessor _updateModelAccessor; protected readonly dynamic New; protected readonly IHtmlLocalizer H; + private readonly IDistributedLock _distributedLock; protected readonly IStringLocalizer S; public WorkflowController( @@ -55,6 +57,7 @@ public WorkflowController( IShapeFactory shapeFactory, INotifier notifier, IHtmlLocalizer htmlLocalizer, + IDistributedLock distributedLock, IStringLocalizer stringLocalizer, IUpdateModelAccessor updateModelAccessor) { @@ -69,6 +72,7 @@ public WorkflowController( _updateModelAccessor = updateModelAccessor; New = shapeFactory; H = htmlLocalizer; + _distributedLock = distributedLock; S = stringLocalizer; } @@ -242,6 +246,54 @@ public async Task Delete(long id) return RedirectToAction(nameof(Index), new { workflowTypeId = workflowType.Id }); } + [HttpPost] + public async Task Restart(long id) + { + if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageWorkflows)) + { + return Forbid(); + } + + var workflow = await _workflowStore.GetAsync(id); + + if (workflow == null) + { + return NotFound(); + } + + var workflowType = await _workflowTypeStore.GetAsync(workflow.WorkflowTypeId); + + if (workflowType == null) + { + return NotFound(); + } + + // If a singleton, try to acquire a lock per workflow type. + (var locker, var locked) = await _distributedLock.TryAcquireWorkflowTypeLockAsync(workflowType); + if (!locked) + { + await _notifier.ErrorAsync(H["Another instance is already running.", id]); + } + else + { + await using var acquiredLock = locker; + + // Check if this is a workflow singleton and there's already an halted instance on any activity. + if (workflowType.IsSingleton && await _workflowStore.HasHaltedInstanceAsync(workflowType.WorkflowTypeId)) + { + await _notifier.ErrorAsync(H["Another instance is already running.", id]); + } + else + { + await _workflowManager.RestartWorkflowAsync(workflow, workflowType); + + await _notifier.SuccessAsync(H["Workflow {0} has been restarted.", id]); + } + } + + return RedirectToAction(nameof(Index), new { workflowTypeId = workflowType.Id }); + } + [HttpPost] [ActionName(nameof(Index))] [FormValueRequired("submit.BulkAction")] diff --git a/src/OrchardCore.Modules/OrchardCore.Workflows/Services/WorkflowManager.cs b/src/OrchardCore.Modules/OrchardCore.Workflows/Services/WorkflowManager.cs index 9d2fcc7aaa2..e3eee6c7ef4 100644 --- a/src/OrchardCore.Modules/OrchardCore.Workflows/Services/WorkflowManager.cs +++ b/src/OrchardCore.Modules/OrchardCore.Workflows/Services/WorkflowManager.cs @@ -64,13 +64,18 @@ public WorkflowManager public Workflow NewWorkflow(WorkflowType workflowType, string correlationId = null) { + if (workflowType == null) + { + throw new ArgumentNullException(nameof(workflowType)); + } + var workflow = new Workflow { WorkflowTypeId = workflowType.WorkflowTypeId, Status = WorkflowStatus.Idle, State = JObject.FromObject(new WorkflowState { - ActivityStates = workflowType.Activities.Select(x => x).ToDictionary(x => x.ActivityId, x => x.Properties) + ActivityStates = workflowType.Activities.ToDictionary(x => x.ActivityId, x => x.Properties) }), CorrelationId = correlationId, LockTimeout = workflowType.LockTimeout, @@ -84,6 +89,16 @@ public Workflow NewWorkflow(WorkflowType workflowType, string correlationId = nu public async Task CreateWorkflowExecutionContextAsync(WorkflowType workflowType, Workflow workflow, IDictionary input = null) { + if (workflowType == null) + { + throw new ArgumentNullException(nameof(workflowType)); + } + + if (workflow == null) + { + throw new ArgumentNullException(nameof(workflow)); + } + var state = workflow.State.ToObject(); var activityQuery = await Task.WhenAll(workflowType.Activities.Select(x => { @@ -100,11 +115,17 @@ public async Task CreateWorkflowExecutionContextAsync( var output = await DeserializeAsync(state.Output); var lastResult = await DeserializeAsync(state.LastResult); var executedActivities = state.ExecutedActivities; + return new WorkflowExecutionContext(workflowType, workflow, mergedInput, output, properties, executedActivities, lastResult, activityQuery); } public Task CreateActivityExecutionContextAsync(ActivityRecord activityRecord, JObject properties) { + if (activityRecord == null) + { + throw new ArgumentNullException(nameof(activityRecord)); + } + var activity = _activityLibrary.InstantiateActivity(activityRecord.Name, properties); if (activity == null) @@ -227,6 +248,16 @@ public async Task TriggerEventAsync(string name, IDictionary inp public async Task ResumeWorkflowAsync(Workflow workflow, BlockingActivity awaitingActivity, IDictionary input = null) { + if (workflow == null) + { + throw new ArgumentNullException(nameof(workflow)); + } + + if (awaitingActivity == null) + { + throw new ArgumentNullException(nameof(awaitingActivity)); + } + var workflowType = await _workflowTypeStore.GetAsync(workflow.WorkflowTypeId); var activityRecord = workflowType.Activities.SingleOrDefault(x => x.ActivityId == awaitingActivity.ActivityId); var workflowContext = await CreateWorkflowExecutionContextAsync(workflowType, workflow, input); @@ -243,29 +274,28 @@ public async Task ResumeWorkflowAsync(Workflow workflo { // Workflow is aborted. workflowContext.Status = WorkflowStatus.Aborted; + + return workflowContext; } - else - { - // Check if the current activity can execute. - var activityContext = workflowContext.GetActivity(activityRecord.ActivityId); - if (await activityContext.Activity.CanExecuteAsync(workflowContext, activityContext)) - { - // Signal every activity that the workflow is resumed. - await InvokeActivitiesAsync(workflowContext, x => x.Activity.OnWorkflowResumedAsync(workflowContext)); - // Remove the blocking activity. - workflowContext.Workflow.BlockingActivities.Remove(awaitingActivity); + // Check if the current activity can execute. + var activityContext = workflowContext.GetActivity(activityRecord.ActivityId); + if (!await activityContext.Activity.CanExecuteAsync(workflowContext, activityContext)) + { + workflowContext.Status = WorkflowStatus.Halted; - // Resume the workflow at the specified blocking activity. - await ExecuteWorkflowAsync(workflowContext, activityRecord); - } - else - { - workflowContext.Status = WorkflowStatus.Halted; - return workflowContext; - } + return workflowContext; } + // Signal every activity that the workflow is resumed. + await InvokeActivitiesAsync(workflowContext, x => x.Activity.OnWorkflowResumedAsync(workflowContext)); + + // Remove the blocking activity. + workflowContext.Workflow.BlockingActivities.Remove(awaitingActivity); + + // Resume the workflow at the specified blocking activity. + await ExecuteWorkflowAsync(workflowContext, activityRecord); + if (workflowContext.Status == WorkflowStatus.Finished && workflowType.DeleteFinishedWorkflows) { await _workflowStore.DeleteAsync(workflowContext.Workflow); @@ -278,18 +308,73 @@ public async Task ResumeWorkflowAsync(Workflow workflo return workflowContext; } - public async Task StartWorkflowAsync(WorkflowType workflowType, ActivityRecord startActivity = null, IDictionary input = null, string correlationId = null) + public async Task RestartWorkflowAsync(WorkflowType workflowType, IDictionary input = null, string correlationId = null) { - if (startActivity == null) + if (workflowType == null) { - startActivity = workflowType.Activities.FirstOrDefault(x => x.IsStart); + throw new ArgumentNullException(nameof(workflowType)); + } - if (startActivity == null) - { - throw new InvalidOperationException($"Workflow with ID {workflowType.Id} does not have a start activity."); - } + var startActivity = workflowType.Activities?.FirstOrDefault(x => x.IsStart) + ?? throw new InvalidOperationException($"Workflow with ID {workflowType.Id} does not have a start activity."); + + // Create a new workflow instance. + var workflow = NewWorkflow(workflowType, correlationId); + + // Create a workflow context. + var workflowContext = await CreateWorkflowExecutionContextAsync(workflowType, workflow, input); + workflowContext.Status = WorkflowStatus.Starting; + + // Signal every activity that the workflow is about to start. + // This should be called prior OnInputReceivedAsync. + var cancellationToken = new CancellationToken(); + await InvokeActivitiesAsync(workflowContext, x => x.Activity.OnWorkflowRestartingAsync(workflowContext, cancellationToken)); + + // Signal every activity about available input. + await InvokeActivitiesAsync(workflowContext, x => x.Activity.OnInputReceivedAsync(workflowContext, input)); + + if (cancellationToken.IsCancellationRequested) + { + // Workflow is aborted. + workflowContext.Status = WorkflowStatus.Aborted; + + return workflowContext; + } + + // Check if the current activity can execute. + var activityContext = workflowContext.GetActivity(startActivity.ActivityId); + if (!await activityContext.Activity.CanExecuteAsync(workflowContext, activityContext)) + { + workflowContext.Status = WorkflowStatus.Idle; + + return workflowContext; + } + + // Signal every activity that the workflow has started. + await InvokeActivitiesAsync(workflowContext, x => x.Activity.OnWorkflowRestartedAsync(workflowContext)); + + // Execute the activity. + await ExecuteWorkflowAsync(workflowContext, startActivity); + + if (workflowContext.Status != WorkflowStatus.Finished || !workflowType.DeleteFinishedWorkflows) + { + // Serialize state. + await PersistAsync(workflowContext); + } + + return workflowContext; + } + + public async Task StartWorkflowAsync(WorkflowType workflowType, ActivityRecord startActivity = null, IDictionary input = null, string correlationId = null) + { + if (workflowType == null) + { + throw new ArgumentNullException(nameof(workflowType)); } + startActivity ??= workflowType.Activities?.FirstOrDefault(x => x.IsStart) + ?? throw new InvalidOperationException($"Workflow with ID {workflowType.Id} does not have a start activity."); + // Create a new workflow instance. var workflow = NewWorkflow(workflowType, correlationId); @@ -308,27 +393,25 @@ public async Task StartWorkflowAsync(WorkflowType work { // Workflow is aborted. workflowContext.Status = WorkflowStatus.Aborted; + return workflowContext; } - else + + // Check if the current activity can execute. + var activityContext = workflowContext.GetActivity(startActivity.ActivityId); + if (!await activityContext.Activity.CanExecuteAsync(workflowContext, activityContext)) { - // Check if the current activity can execute. - var activityContext = workflowContext.GetActivity(startActivity.ActivityId); - if (await activityContext.Activity.CanExecuteAsync(workflowContext, activityContext)) - { - // Signal every activity that the workflow has started. - await InvokeActivitiesAsync(workflowContext, x => x.Activity.OnWorkflowStartedAsync(workflowContext)); + workflowContext.Status = WorkflowStatus.Idle; - // Execute the activity. - await ExecuteWorkflowAsync(workflowContext, startActivity); - } - else - { - workflowContext.Status = WorkflowStatus.Idle; - return workflowContext; - } + return workflowContext; } + // Signal every activity that the workflow has started. + await InvokeActivitiesAsync(workflowContext, x => x.Activity.OnWorkflowStartedAsync(workflowContext)); + + // Execute the activity. + await ExecuteWorkflowAsync(workflowContext, startActivity); + if (workflowContext.Status != WorkflowStatus.Finished || !workflowType.DeleteFinishedWorkflows) { // Serialize state. diff --git a/src/OrchardCore.Modules/OrchardCore.Workflows/UserTasks/Drivers/UserTaskEventContentDriver.cs b/src/OrchardCore.Modules/OrchardCore.Workflows/UserTasks/Drivers/UserTaskEventContentDriver.cs index 3a90d8ec0d8..68fc4e38086 100644 --- a/src/OrchardCore.Modules/OrchardCore.Workflows/UserTasks/Drivers/UserTaskEventContentDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Workflows/UserTasks/Drivers/UserTaskEventContentDriver.cs @@ -77,7 +77,8 @@ public override async Task UpdateAsync(ContentItem model, IUpdat { Name = nameof(UserTaskEvent), ContentType = model.ContentType, - ContentItemId = model.ContentItemId + ContentItemId = model.ContentItemId, + ContentItemVersionId = model.ContentItemVersionId, }; var input = new Dictionary diff --git a/src/OrchardCore.Modules/OrchardCore.Workflows/Views/Workflow/Details.cshtml b/src/OrchardCore.Modules/OrchardCore.Workflows/Views/Workflow/Details.cshtml index f5f842ee5ce..44085feb893 100644 --- a/src/OrchardCore.Modules/OrchardCore.Workflows/Views/Workflow/Details.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Workflows/Views/Workflow/Details.cshtml @@ -6,14 +6,14 @@

@RenderTitleSegments(Model.WorkflowType.Name)

- +
@@ -82,15 +82,22 @@
@*TODO: Enable this when workflow logging is implemented.*@ @*
-
-
- Pretty LOG goes here... -
-
-
*@ +
+
+ Pretty LOG goes here... +
+
+ *@
@T["Back"] + @if (Model.Workflow.Status != WorkflowStatus.Executing + && Model.Workflow.Status != WorkflowStatus.Halted + && Model.Workflow.Status != WorkflowStatus.Resuming + && Model.Workflow.Status != WorkflowStatus.Starting) + { + @T["Restart"] + } @T["Delete"]
diff --git a/src/OrchardCore.Modules/OrchardCore.Workflows/Views/Workflow/Index.cshtml b/src/OrchardCore.Modules/OrchardCore.Workflows/Views/Workflow/Index.cshtml index da11be743bf..b6bee6b75b9 100644 --- a/src/OrchardCore.Modules/OrchardCore.Workflows/Views/Workflow/Index.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Workflows/Views/Workflow/Index.cshtml @@ -64,8 +64,8 @@ @for (var i = 0; i < Model.Workflows.Count; i++) { var entry = Model.Workflows[i]; - var statusCss = ""; - + var statusCss = string.Empty; + var restartable = true; switch (entry.Workflow.Status) { case WorkflowStatus.Aborted: @@ -83,6 +83,7 @@ case WorkflowStatus.Resuming: case WorkflowStatus.Starting: statusCss = "info"; + restartable = false; break; case WorkflowStatus.Faulted: statusCss = "danger"; @@ -92,6 +93,10 @@
  • + @if (restartable) + { + @T["Restart"] + } @T["Delete"]
    @@ -132,7 +137,9 @@