using Elsa.Common; using Elsa.Common.Models; using Elsa.Extensions; using Elsa.Testing.Shared; using Elsa.Workflows.Management; using Elsa.Workflows.Management.Filters; using Elsa.Workflows.Models; using Elsa.Workflows.Runtime; using Elsa.Workflows.Runtime.Messages; using Microsoft.Extensions.DependencyInjection; using Xunit.Abstractions; namespace Elsa.Workflows.IntegrationTests.GracefulShutdown; /// /// A user cancellation that still has its original execution cycle live when drain snapshots /// must stay and must not be requeued. /// public class UserCancelDuringDrainTests { private readonly IServiceProvider _services; private readonly IWorkflowRuntime _workflowRuntime; private readonly IDrainOrchestrator _orchestrator; public UserCancelDuringDrainTests(ITestOutputHelper testOutputHelper) { _services = new TestApplicationBuilder(testOutputHelper) .AddActivitiesFrom() .AddWorkflow() .ConfigureElsa(elsa => elsa .UseWorkflowRuntime(runtime => runtime.ConfigureGracefulShutdown(o => { o.DrainDeadline = TimeSpan.FromMilliseconds(50); o.IngressPauseTimeout = TimeSpan.FromMilliseconds(50); }))) .Build(); _workflowRuntime = _services.GetRequiredService(); _orchestrator = _services.GetRequiredService(); } [Fact(DisplayName = "Normal cancellation with a still-active execution cycle is not promoted to Interrupted or requeued after drain")] public async Task NormalCancelThenDrainDoesNotRequeue() { await _services.PopulateRegistriesAsync(); var client = await _workflowRuntime.CreateClientAsync(); await client.CreateInstanceAsync(new CreateWorkflowInstanceRequest { WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(nameof(LongRunningObservableWorkflow), VersionOptions.Published) }); var cycles = _services.GetRequiredService(); var runTask = Task.Run(() => client.RunInstanceAsync(RunWorkflowInstanceRequest.Empty)); await WaitUntilAsync(() => cycles.ActiveCount > 0, TimeSpan.FromSeconds(2)); await client.CancelAsync(); using var scope = _services.CreateScope(); var instanceStore = scope.ServiceProvider.GetRequiredService(); await WaitUntilAsync(async () => { var current = await instanceStore.FindAsync(new WorkflowInstanceFilter { Id = client.WorkflowInstanceId }); return current is { Status: WorkflowStatus.Finished, SubStatus: WorkflowSubStatus.Cancelled }; }, TimeSpan.FromSeconds(2)); Assert.True(cycles.ActiveCount > 0, "The original execution cycle must still be active when drain starts."); await _orchestrator.DrainAsync(DrainTrigger.HostStopSignal); try { await runTask.WaitAsync(TimeSpan.FromSeconds(5)); } catch (Exception ex) when (!ex.IsFatal()) { /* runner may complete normally or surface OCE */ } var instance = await instanceStore.FindAsync(new WorkflowInstanceFilter { Id = client.WorkflowInstanceId }); Assert.NotNull(instance); Assert.Equal(WorkflowStatus.Finished, instance.Status); Assert.Equal(WorkflowSubStatus.Cancelled, instance.SubStatus); var restarter = new RecordingRestarter(); var scanner = ActivatorUtilities.CreateInstance(scope.ServiceProvider, restarter); var requeued = await scanner.ScanAndRequeueAsync(CancellationToken.None); Assert.Equal(0, requeued); Assert.Empty(restarter.RestartedIds); } private static async Task WaitUntilAsync(Func> condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (await condition()) return; await Task.Delay(20); } throw new TimeoutException($"Condition was not met within {timeout}."); } private static Task WaitUntilAsync(Func condition, TimeSpan timeout) => WaitUntilAsync(() => Task.FromResult(condition()), timeout); private sealed class RecordingRestarter : IWorkflowRestarter { public List RestartedIds { get; } = new(); public Task RestartWorkflowAsync(string workflowInstanceId, CancellationToken cancellationToken = default) { RestartedIds.Add(workflowInstanceId); return Task.CompletedTask; } } } /// Published definition used by . public class LongRunningObservableWorkflow : WorkflowBase { protected override void Build(IWorkflowBuilder builder) { builder.Root = new ObservableActivity { DelayMs = 2000 }; } }