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 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>
122 lines
5 KiB
C#
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 };
|
|
}
|
|
}
|