elsa-core/test/integration/Elsa.Workflows.IntegrationTests/GracefulShutdown/UserCancelDuringDrainTests.cs
Sipke Schoorstra 33181ae304
Some checks failed
Packages / Test unit/integration with coverage (push) Has been cancelled
Packages / Test component with coverage (push) Has been cancelled
Packages / Generate coverage report (push) Has been cancelled
Packages / Build packages (push) Has been cancelled
Packages / Publish to feedz.io (push) Has been cancelled
Packages / Publish release to nuget.org (push) Has been cancelled
Packages / Deploy coverage to GitHub Pages (push) Has been cancelled
fix: persist Interrupted after drain force-cancel commits Cancelled (#8069)
* fix: persist Interrupted after drain force-cancel commits Cancelled

Deadline-breach force-cancel makes the runner persist Finished/Cancelled.
#8059 then skipped every Finished row, so Interrupted never landed and
Packages CI failed DeadlineBreachPersistsInterrupted. Treat Cancelled as
interruptible and promote it to Running+Interrupted so recovery can
requeue it, while still refusing naturally completed rows.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix: do not promote user cancellations to Interrupted on drain

Gate Cancelled→Interrupted on instances that were not already Cancelled
when drain snapshotted live cycles. Deadline-breach force-cancel still
promotes the runner's Finished/Cancelled commit; ordinary client
cancellations stay Cancelled and are not requeued.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix: confine Cancelled→Interrupted promote to Drain

Restore TryMarkInterruptedAsync to refuse every Finished row by default
(#8052). Drain PersistInterrupted alone may pass allowFinishedCancelled
when the instance is Finished/Cancelled and in this drain's
force-cancelled set. User cancellations stay cancelled.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* docs: document TryMarkInterruptedAsync parameters

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix: bound pre-cancel snapshot so stalled Find cannot block Cancel

WaitAsync the instance-store snapshot under a short shutdown budget so
a hang or ignored cancellation token cannot delay handle.Cancel().
Unknown pre-state is not treated as already Cancelled; observed
user cancellations are still preserved.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test: restore Fact on terminal-race drain persist skip

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix: exclude unknown snapshot rows from drain-induced promote

A timed-out or failed pre-cancel Find no longer joins drainInduced.
Only a successful read that is clearly not already Cancelled may be
promoted. Cancel still proceeds without waiting on store latency.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix: give each pre-cancel snapshot its own timeout

A shared 250ms overallSnapshotCts let a stalled first Find cancel later
Finds before they started, so those instances were excluded from
drainInduced and never persisted as Interrupted after Phase A Cancel.

Each Find now uses an independent CTS linked only to the host token.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix: snapshot live instances concurrently before Phase A Cancel

Independent per-find 250ms budgets kept recovery, but a serial foreach
still delayed every handle.Cancel by up to N×250ms. Run those bounded
Finds with Task.WhenAll so Cancel waits one timeout window, not N.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix: give each parallel snapshot Find its own DI scope

Task.WhenAll was sharing one scoped IWorkflowInstanceStore. EF DbContext
is not thread-safe; Phase C already persists sequentially for that reason.
Each snapshot task now CreateScope()s its own store and disposes it.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-12 14:23:47 -07:00

122 lines
5 KiB
C#

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;
/// <summary>
/// A user cancellation that still has its original execution cycle live when drain snapshots
/// must stay <see cref="WorkflowSubStatus.Cancelled"/> and must not be requeued.
/// </summary>
public class UserCancelDuringDrainTests
{
private readonly IServiceProvider _services;
private readonly IWorkflowRuntime _workflowRuntime;
private readonly IDrainOrchestrator _orchestrator;
public UserCancelDuringDrainTests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper)
.AddActivitiesFrom<UserCancelDuringDrainTests>()
.AddWorkflow<LongRunningObservableWorkflow>()
.ConfigureElsa(elsa => elsa
.UseWorkflowRuntime(runtime => runtime.ConfigureGracefulShutdown(o =>
{
o.DrainDeadline = TimeSpan.FromMilliseconds(50);
o.IngressPauseTimeout = TimeSpan.FromMilliseconds(50);
})))
.Build();
_workflowRuntime = _services.GetRequiredService<IWorkflowRuntime>();
_orchestrator = _services.GetRequiredService<IDrainOrchestrator>();
}
[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<IExecutionCycleRegistry>();
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<IWorkflowInstanceStore>();
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<Elsa.Workflows.Runtime.Services.InterruptedRecoveryScanner>(scope.ServiceProvider, restarter);
var requeued = await scanner.ScanAndRequeueAsync(CancellationToken.None);
Assert.Equal(0, requeued);
Assert.Empty(restarter.RestartedIds);
}
private static async Task WaitUntilAsync(Func<Task<bool>> 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<bool> condition, TimeSpan timeout) =>
WaitUntilAsync(() => Task.FromResult(condition()), timeout);
private sealed class RecordingRestarter : IWorkflowRestarter
{
public List<string> RestartedIds { get; } = new();
public Task RestartWorkflowAsync(string workflowInstanceId, CancellationToken cancellationToken = default)
{
RestartedIds.Add(workflowInstanceId);
return Task.CompletedTask;
}
}
}
/// <summary>Published definition used by <see cref="UserCancelDuringDrainTests"/>.</summary>
public class LongRunningObservableWorkflow : WorkflowBase
{
protected override void Build(IWorkflowBuilder builder)
{
builder.Root = new ObservableActivity { DelayMs = 2000 };
}
}