Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public enum WorkflowSubStatus
/// The workflow is currently suspended and waiting for external stimuli to resume.
/// </summary>
Suspended,

/// <summary>
/// The workflow completed successfully.
/// </summary>
Expand All @@ -40,4 +40,9 @@ public enum WorkflowSubStatus
/// The instance is resumable and will be picked up by the runtime's shell-activation recovery scan.
/// </summary>
Interrupted,
}

/// <summary>
/// Cancellation was requested and is being processed.
/// </summary>
Cancelling,
}
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,7 @@ private WorkflowStatus GetMainStatus(WorkflowSubStatus subStatus) =>
WorkflowSubStatus.Faulted => WorkflowStatus.Finished,
WorkflowSubStatus.Finished => WorkflowStatus.Finished,
WorkflowSubStatus.Suspended => WorkflowStatus.Running,
WorkflowSubStatus.Cancelling => WorkflowStatus.Running,
_ => throw new ArgumentOutOfRangeException(nameof(subStatus), subStatus, null)
};

Expand Down
9 changes: 7 additions & 2 deletions src/modules/Elsa.Workflows.Core/Enums/WorkflowSubStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public enum WorkflowSubStatus
/// The workflow is currently suspended and waiting for external stimuli to resume.
/// </summary>
Suspended,

/// <summary>
/// The workflow completed successfully.
/// </summary>
Expand All @@ -43,4 +43,9 @@ public enum WorkflowSubStatus
/// sub-status with a stale liveness timestamp.
/// </summary>
Interrupted,
}

/// <summary>
/// Cancellation was requested and is being processed.
/// </summary>
Cancelling,
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Elsa.Common;
using Elsa.Common.Models;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
Expand All @@ -10,6 +11,7 @@ namespace Elsa.Workflows.Runtime;
public class WorkflowCancellationService(
IWorkflowDefinitionService workflowDefinitionService,
IWorkflowInstanceStore workflowInstanceStore,
ISystemClock systemClock,
IWorkflowCancellationDispatcher dispatcher)
: IWorkflowCancellationService
{
Expand Down Expand Up @@ -67,19 +69,47 @@ public async Task<int> CancelWorkflowByDefinitionAsync(string definitionId, Vers

private async Task<int> CancelWorkflows(IList<WorkflowInstance> workflowInstances, CancellationToken cancellationToken)
{
var tasks = workflowInstances.Where(i => i.Status != WorkflowStatus.Finished)
var cancellableWorkflowInstances = workflowInstances.Where(i => i.Status != WorkflowStatus.Finished).ToList();
var instanceIds = workflowInstances.Select(i => i.Id).ToList();

await MarkWorkflowInstancesAsCancellingAsync(cancellableWorkflowInstances, cancellationToken);

var tasks = cancellableWorkflowInstances
.Select(i => dispatcher.DispatchAsync(new DispatchCancelWorkflowRequest
{
WorkflowInstanceId = i.Id
}, cancellationToken)).ToList();

var instanceIds = workflowInstances.Select(i => i.Id).ToList();
await CancelChildWorkflowInstances(instanceIds, cancellationToken);
await Task.WhenAll(tasks);

return tasks.Count;
}

private async Task MarkWorkflowInstancesAsCancellingAsync(ICollection<WorkflowInstance> workflowInstances, CancellationToken cancellationToken)
{
if (workflowInstances.Count == 0)
return;

var now = systemClock.UtcNow;

foreach (var workflowInstance in workflowInstances)
{
workflowInstance.Status = WorkflowStatus.Running;
workflowInstance.SubStatus = WorkflowSubStatus.Cancelling;
workflowInstance.UpdatedAt = now;

if (workflowInstance.WorkflowState is not null)
{
workflowInstance.WorkflowState.Status = WorkflowStatus.Running;
workflowInstance.WorkflowState.SubStatus = WorkflowSubStatus.Cancelling;
workflowInstance.WorkflowState.UpdatedAt = now;
}
}

await workflowInstanceStore.SaveManyAsync(workflowInstances, cancellationToken);
}

private async Task CancelChildWorkflowInstances(IEnumerable<string> workflowInstanceIds, CancellationToken cancellationToken)
{
var tasks = new List<Task<int>>();
Expand All @@ -101,4 +131,4 @@ private async Task CancelChildWorkflowInstances(IEnumerable<string> workflowInst

await Task.WhenAll(tasks);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using Elsa.Common;
using Elsa.Common.Models;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Runtime.Requests;
using Elsa.Workflows.Runtime.Responses;
using Elsa.Workflows.State;
using NSubstitute;

namespace Elsa.Workflows.Runtime.UnitTests.Services;

public class WorkflowCancellationServiceTests
{
private readonly IWorkflowDefinitionService _workflowDefinitionService = Substitute.For<IWorkflowDefinitionService>();
private readonly IWorkflowInstanceStore _workflowInstanceStore = Substitute.For<IWorkflowInstanceStore>();
private readonly ISystemClock _systemClock = Substitute.For<ISystemClock>();
private readonly IWorkflowCancellationDispatcher _dispatcher = Substitute.For<IWorkflowCancellationDispatcher>();

[Fact]
public async Task CancelWorkflowsAsync_MarksNonFinishedInstancesAsCancellingBeforeDispatchingCancellation()
{
var now = new DateTimeOffset(2026, 5, 22, 17, 0, 0, TimeSpan.Zero);
var events = new List<string>();
var suspendedInstance = CreateInstance("workflow-1", WorkflowStatus.Running, WorkflowSubStatus.Suspended);
var finishedInstance = CreateInstance("workflow-2", WorkflowStatus.Finished, WorkflowSubStatus.Finished);
var instances = new[] { suspendedInstance, finishedInstance };
var service = CreateService();

_systemClock.UtcNow.Returns(now);
_workflowInstanceStore
.FindManyAsync(Arg.Any<WorkflowInstanceFilter>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var filter = callInfo.ArgAt<WorkflowInstanceFilter>(0);
var matchesRequestedIds = filter.Ids?.Any() == true;
IEnumerable<WorkflowInstance> result = matchesRequestedIds ? instances : Array.Empty<WorkflowInstance>();
return new ValueTask<IEnumerable<WorkflowInstance>>(result);
});
_workflowInstanceStore
.SaveManyAsync(Arg.Any<IEnumerable<WorkflowInstance>>(), Arg.Any<CancellationToken>())
.Returns(_ =>
{
events.Add("save");
return ValueTask.CompletedTask;
});
_dispatcher
.DispatchAsync(Arg.Any<DispatchCancelWorkflowRequest>(), Arg.Any<CancellationToken>())
.Returns(_ =>
{
events.Add("dispatch");
return Task.FromResult(new DispatchCancelWorkflowsResponse());
});

var count = await service.CancelWorkflowsAsync(new[] { "workflow-1", "workflow-2" });

Assert.Equal(1, count);
Assert.Equal(new[] { "save", "dispatch" }, events);
Assert.Equal(WorkflowStatus.Running, suspendedInstance.Status);
Assert.Equal(WorkflowSubStatus.Cancelling, suspendedInstance.SubStatus);
Assert.Equal(now, suspendedInstance.UpdatedAt);
Assert.Equal(WorkflowStatus.Running, suspendedInstance.WorkflowState.Status);
Assert.Equal(WorkflowSubStatus.Cancelling, suspendedInstance.WorkflowState.SubStatus);
Assert.Equal(now, suspendedInstance.WorkflowState.UpdatedAt);
Assert.Equal(WorkflowSubStatus.Finished, finishedInstance.SubStatus);

await _workflowInstanceStore.Received(1).SaveManyAsync(
Arg.Is<IEnumerable<WorkflowInstance>>(items =>
items.Count() == 1 &&
items.Single().Id == suspendedInstance.Id),
Arg.Any<CancellationToken>());
await _dispatcher.Received(1).DispatchAsync(
Arg.Is<DispatchCancelWorkflowRequest>(request => request.WorkflowInstanceId == suspendedInstance.Id),
Arg.Any<CancellationToken>());
}

[Fact]
public async Task CancelWorkflowAsync_ReturnsFalseWithoutDispatching_WhenInstanceDoesNotExist()
{
var service = CreateService();
_workflowInstanceStore
.FindAsync(Arg.Any<WorkflowInstanceFilter>(), Arg.Any<CancellationToken>())
.Returns(new ValueTask<WorkflowInstance?>((WorkflowInstance?)null));

var result = await service.CancelWorkflowAsync("missing-workflow");

Assert.False(result);
await _workflowInstanceStore.DidNotReceive().SaveManyAsync(Arg.Any<IEnumerable<WorkflowInstance>>(), Arg.Any<CancellationToken>());
await _dispatcher.DidNotReceive().DispatchAsync(Arg.Any<DispatchCancelWorkflowRequest>(), Arg.Any<CancellationToken>());
}

private WorkflowCancellationService CreateService() => new(_workflowDefinitionService, _workflowInstanceStore, _systemClock, _dispatcher);

private static WorkflowInstance CreateInstance(string id, WorkflowStatus status, WorkflowSubStatus subStatus)
{
var workflowState = new WorkflowState
{
Id = id,
Status = status,
SubStatus = subStatus
};

return new()
{
Id = id,
DefinitionId = "definition-1",
DefinitionVersionId = "definition-version-1",
Status = status,
SubStatus = subStatus,
WorkflowState = workflowState
};
}
}
Loading