Initial work on workflow instance cancellation

This commit is contained in:
Sipke Schoorstra 2021-09-08 22:37:42 +02:00
parent 94b086f5f0
commit 64636a362b
9 changed files with 195 additions and 6 deletions

View file

@ -6,7 +6,13 @@ using MediatR;
namespace Elsa.Activities.Temporal.Common.Handlers
{
public class RemoveScheduledTriggers : INotificationHandler<BlockingActivityRemoved>, INotificationHandler<WorkflowDefinitionPublished>, INotificationHandler<WorkflowDefinitionRetracted>, INotificationHandler<WorkflowDefinitionDeleted>
public class RemoveScheduledTriggers :
INotificationHandler<BlockingActivityRemoved>,
INotificationHandler<WorkflowDefinitionPublished>,
INotificationHandler<WorkflowDefinitionRetracted>,
INotificationHandler<WorkflowDefinitionDeleted>,
INotificationHandler<WorkflowCancelled>,
INotificationHandler<WorkflowInstanceCancelled>
{
private readonly IWorkflowDefinitionScheduler _workflowDefinitionScheduler;
private readonly IWorkflowInstanceScheduler _workflowInstanceScheduler;
@ -20,14 +26,16 @@ namespace Elsa.Activities.Temporal.Common.Handlers
public async Task Handle(BlockingActivityRemoved notification, CancellationToken cancellationToken)
{
// TODO: Consider introducing a "stereotype" field for activities to exit early in case they are not stereotyped as "temporal".
await _workflowInstanceScheduler.UnscheduleAsync(
notification.WorkflowExecutionContext.WorkflowInstance.Id,
notification.BlockingActivity.ActivityId,
cancellationToken);
}
public Task Handle(WorkflowDefinitionPublished notification, CancellationToken cancellationToken) => _workflowDefinitionScheduler.UnscheduleAsync(notification.WorkflowDefinition.DefinitionId , cancellationToken);
public async Task Handle(WorkflowCancelled notification, CancellationToken cancellationToken) => await _workflowInstanceScheduler.UnscheduleAsync(notification.WorkflowExecutionContext.WorkflowInstance.Id, cancellationToken);
public async Task Handle(WorkflowInstanceCancelled notification, CancellationToken cancellationToken) => await _workflowInstanceScheduler.UnscheduleAsync(notification.WorkflowInstance.Id, cancellationToken);
public Task Handle(WorkflowDefinitionPublished notification, CancellationToken cancellationToken) => _workflowDefinitionScheduler.UnscheduleAsync(notification.WorkflowDefinition.DefinitionId, cancellationToken);
public Task Handle(WorkflowDefinitionRetracted notification, CancellationToken cancellationToken) => _workflowDefinitionScheduler.UnscheduleAsync(notification.WorkflowDefinition.DefinitionId, cancellationToken);
public Task Handle(WorkflowDefinitionDeleted notification, CancellationToken cancellationToken) => _workflowDefinitionScheduler.UnscheduleAsync(notification.WorkflowDefinition.DefinitionId, cancellationToken);
}

View file

@ -0,0 +1,19 @@
using Elsa.Models;
using Elsa.Services.Models;
using MediatR;
namespace Elsa.Events
{
/// <summary>
/// Published when a workflow instance was cancelled
/// </summary>
public class WorkflowInstanceCancelled : INotification
{
public WorkflowInstanceCancelled(WorkflowInstance workflowInstance)
{
WorkflowInstance = workflowInstance;
}
public WorkflowInstance WorkflowInstance { get; }
}
}

View file

@ -0,0 +1,20 @@
using System.Threading;
using System.Threading.Tasks;
using Elsa.Models;
namespace Elsa.Services
{
public interface IWorkflowInstanceCanceller
{
Task<CancelWorkflowInstanceResult> CancelAsync(string workflowInstanceId, CancellationToken cancellationToken = default);
}
public record CancelWorkflowInstanceResult(CancelWorkflowInstanceResultStatus Status, WorkflowInstance? WorkflowInstance);
public enum CancelWorkflowInstanceResultStatus
{
Ok,
NotFound,
InvalidStatus
}
}

View file

@ -0,0 +1,45 @@
using System.Threading;
using System.Threading.Tasks;
using Elsa.Exceptions;
using Elsa.Options;
using Elsa.Services;
using Elsa.Services.Models;
namespace Elsa.Decorators
{
public class LockingWorkflowInstanceCanceller : IWorkflowInstanceCanceller
{
private readonly IWorkflowInstanceCanceller _workflowInstanceCanceller;
private readonly IDistributedLockProvider _distributedLockProvider;
private readonly ElsaOptions _elsaOptions;
public LockingWorkflowInstanceCanceller(IWorkflowInstanceCanceller workflowInstanceCanceller, IDistributedLockProvider distributedLockProvider, ElsaOptions elsaOptions)
{
_workflowInstanceCanceller = workflowInstanceCanceller;
_distributedLockProvider = distributedLockProvider;
_elsaOptions = elsaOptions;
}
public async Task<CancelWorkflowInstanceResult> CancelAsync(string workflowInstanceId, CancellationToken cancellationToken = default)
{
var workflowInstanceLockKey = $"workflow-instance:{workflowInstanceId}";
var currentWorkflowInstanceLockHandle = AmbientLockContext.GetCurrentWorkflowInstanceLock(workflowInstanceId);
var workflowInstanceLockHandle = currentWorkflowInstanceLockHandle ?? await _distributedLockProvider.AcquireLockAsync(workflowInstanceLockKey, _elsaOptions.DistributedLockTimeout, cancellationToken);
if (workflowInstanceLockHandle == null)
throw new LockAcquisitionException("Could not acquire a lock within the configured amount of time");
try
{
AmbientLockContext.SetCurrentWorkflowInstanceLock(workflowInstanceId, workflowInstanceLockHandle);
return await _workflowInstanceCanceller.CancelAsync(workflowInstanceId, cancellationToken);
}
finally
{
AmbientLockContext.DeleteCurrentWorkflowInstanceLock(workflowInstanceId);
await workflowInstanceLockHandle.DisposeAsync();
}
}
}
}

View file

@ -95,6 +95,7 @@ namespace Microsoft.Extensions.DependencyInjection
services.Decorate<IWorkflowDefinitionStore, EventPublishingWorkflowDefinitionStore>();
services.Decorate<IWorkflowInstanceStore, EventPublishingWorkflowInstanceStore>();
services.Decorate<IWorkflowInstanceExecutor, LockingWorkflowInstanceExecutor>();
services.Decorate<IWorkflowInstanceCanceller, LockingWorkflowInstanceCanceller>();
//TenantId default source
services.TryAddScoped<ITenantAccessor, DefaultTenantAccessor>();
@ -174,6 +175,7 @@ namespace Microsoft.Extensions.DependencyInjection
.AddScoped<IWorkflowInstanceExecutor, WorkflowInstanceExecutor>()
.AddScoped<IWorkflowTriggerInterruptor, WorkflowTriggerInterruptor>()
.AddScoped<IWorkflowReviver, WorkflowReviver>()
.AddScoped<IWorkflowInstanceCanceller, WorkflowInstanceCanceller>()
.AddSingleton<IWorkflowFactory, WorkflowFactory>()
.AddTransient<IWorkflowBlueprintMaterializer, WorkflowBlueprintMaterializer>()
.AddSingleton<IWorkflowBlueprintReflector, WorkflowBlueprintReflector>()

View file

@ -0,0 +1,43 @@
using System.Threading;
using System.Threading.Tasks;
using Elsa.Events;
using Elsa.Models;
using Elsa.Persistence;
using MediatR;
using Microsoft.Extensions.Logging;
using NodaTime;
namespace Elsa.Services.Workflows
{
public class WorkflowInstanceCanceller : IWorkflowInstanceCanceller
{
private readonly IWorkflowInstanceStore _workflowInstanceStore;
private readonly IClock _clock;
private readonly IMediator _mediator;
public WorkflowInstanceCanceller(IWorkflowInstanceStore workflowInstanceStore, IClock clock, IMediator mediator)
{
_workflowInstanceStore = workflowInstanceStore;
_clock = clock;
_mediator = mediator;
}
public async Task<CancelWorkflowInstanceResult> CancelAsync(string workflowInstanceId, CancellationToken cancellationToken = default)
{
var workflowInstance = await _workflowInstanceStore.FindByIdAsync(workflowInstanceId, cancellationToken);
if (workflowInstance == null)
return new CancelWorkflowInstanceResult(CancelWorkflowInstanceResultStatus.NotFound, null);
if (workflowInstance.WorkflowStatus != WorkflowStatus.Idle && workflowInstance.WorkflowStatus != WorkflowStatus.Running && workflowInstance.WorkflowStatus != WorkflowStatus.Suspended)
return new CancelWorkflowInstanceResult(CancelWorkflowInstanceResultStatus.InvalidStatus, workflowInstance);
workflowInstance.WorkflowStatus = WorkflowStatus.Cancelled;
workflowInstance.CancelledAt = _clock.GetCurrentInstant();
await _workflowInstanceStore.SaveAsync(workflowInstance, cancellationToken);
return new CancelWorkflowInstanceResult(CancelWorkflowInstanceResultStatus.Ok, workflowInstance);
}
}
}

View file

@ -128,12 +128,12 @@ namespace Elsa.Services.Workflows
default:
throw new ArgumentOutOfRangeException();
}
await _mediator.Publish(new WorkflowExecuted(workflowExecutionContext), cancellationToken);
var statusEvent = workflowExecutionContext.Status switch
{
WorkflowStatus.Cancelled => new WorkflowCancelled(workflowExecutionContext),
WorkflowStatus.Cancelled => new WorkflowCancelled(workflowExecutionContext), // TODO: Publish WorkflowInstanceCancelled event also
WorkflowStatus.Finished => new WorkflowCompleted(workflowExecutionContext),
WorkflowStatus.Faulted => new WorkflowFaulted(workflowExecutionContext),
WorkflowStatus.Suspended => new WorkflowSuspended(workflowExecutionContext),

View file

@ -11,10 +11,15 @@
<script src="/build/assets/js/monaco-editor/min/vs/loader.js"></script>
<script type="module" src="/build/elsa-workflows-studio.esm.js"></script>
<script nomodule src="/build/elsa-workflows-studio.js"></script>
<style>
elsa-studio-dashboard nav.elsa-bg-gray-800 {
background-color: teal;
}
</style>
</head>
<body>
<elsa-studio-root server-url="https://localhost:15265" monaco-lib-path="build/assets/js/monaco-editor/min" culture="en-US">
<elsa-studio-root server-url="https://localhost:11000" monaco-lib-path="build/assets/js/monaco-editor/min" culture="en-US">
<!-- The root dashboard component -->
<elsa-studio-dashboard></elsa-studio-dashboard>

View file

@ -0,0 +1,47 @@
using System.Threading;
using System.Threading.Tasks;
using Elsa.Models;
using Elsa.Persistence;
using Elsa.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
namespace Elsa.Server.Api.Endpoints.WorkflowInstances
{
[ApiController]
[ApiVersion("1")]
[Route("v{apiVersion:apiVersion}/workflow-instances/{id}/cancel")]
[Produces("application/json")]
public class Cancel : Controller
{
private readonly IWorkflowInstanceCanceller _canceller;
public Cancel(IWorkflowInstanceCanceller canceller)
{
_canceller = canceller;
}
[HttpPost]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[SwaggerOperation(
Summary = "Cancels a workflow instance.",
Description = "Retries a workflow instance.",
OperationId = "WorkflowInstances.Retry",
Tags = new[] { "WorkflowInstances" })
]
public async Task<IActionResult> Handle(string id, CancellationToken cancellationToken = default)
{
var result = await _canceller.CancelAsync(id, cancellationToken);
return result.Status switch
{
CancelWorkflowInstanceResultStatus.NotFound => NotFound(),
CancelWorkflowInstanceResultStatus.InvalidStatus => BadRequest($"Cannot cancel a workflow instance with status {result.WorkflowInstance!.WorkflowStatus}"),
_ => Ok()
};
}
}
}