elsa-core/test/integration/Elsa.Workflows.IntegrationTests/GracefulShutdown/DeadlineBreachEndToEndTests.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

153 lines
7.7 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.Runtime;
using Elsa.Workflows.Runtime.Filters;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.GracefulShutdown;
/// <summary>
/// End-to-end test for the drain orchestrator's deadline-breach path against a real workflow execution. A workflow
/// containing a slow activity is started on a background task; the orchestrator is invoked mid-flight with a
/// sub-activity-duration deadline; the test asserts the workflow lands in <see cref="WorkflowSubStatus.Interrupted"/>
/// and a <c>WorkflowInterrupted</c> forensic log entry is written.
///
/// This is the integration-level companion to the unit tests in <c>DrainOrchestratorWaitTests</c> — it exercises the
/// production DI graph, the real <see cref="Pipelines.WorkflowExecution.IWorkflowExecutionPipeline"/>, and the
/// <c>ExecutionCycleHandle.Disposed</c>-await sequencing that ensures the orchestrator's <c>Interrupted</c> persistence wins
/// the race against the workflow runner's terminal commit.
/// </summary>
public class DeadlineBreachEndToEndTests
{
private readonly IServiceProvider _services;
private readonly IWorkflowRunner _workflowRunner;
private readonly IDrainOrchestrator _orchestrator;
public DeadlineBreachEndToEndTests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper)
.AddActivitiesFrom<DeadlineBreachEndToEndTests>()
.ConfigureElsa(elsa => elsa
.UseWorkflowRuntime(runtime => runtime.ConfigureGracefulShutdown(o =>
{
// Sub-activity-duration deadline forces the orchestrator down the deadline-breach path.
o.DrainDeadline = TimeSpan.FromMilliseconds(50);
o.IngressPauseTimeout = TimeSpan.FromMilliseconds(50);
})))
.Build();
_workflowRunner = _services.GetRequiredService<IWorkflowRunner>();
_orchestrator = _services.GetRequiredService<IDrainOrchestrator>();
}
[Fact(DisplayName = "Drain deadline breach against a running workflow persists the instance as Interrupted with a WorkflowInterrupted log entry")]
public async Task DeadlineBreachPersistsInterrupted()
{
await _services.PopulateRegistriesAsync();
var activityState = new ObservableActivityState();
var workflow = new TestWorkflow(builder => builder.Root = new ObservableActivity
{
State = activityState,
DelayMs = 500,
});
// Start the workflow on a background task — its activity runs for ~500 ms.
var workflowTask = Task.Run(() => _workflowRunner.RunAsync(workflow));
// Wait until the activity has actually started executing.
await activityState.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
// Trigger drain mid-flight: 50 ms deadline, 500 ms activity → guaranteed deadline breach.
var outcome = await _orchestrator.DrainAsync(DrainTrigger.HostStopSignal);
// Wait for the workflow runner to fully unwind.
try { await workflowTask.WaitAsync(TimeSpan.FromSeconds(5)); }
catch (Exception ex) when (!ex.IsFatal()) { /* runner may complete normally or surface OCE; either is acceptable */ }
// Drain reports a deadline breach with exactly one force-cancelled execution cycle.
Assert.Equal(DrainResult.DeadlineExceeded, outcome.OverallResult);
Assert.Equal(1, outcome.ExecutionCyclesForceCancelledCount);
Assert.Single(outcome.ForceCancelledInstanceIds);
// The workflow instance ends up persisted as Interrupted. The ExecutionCycleAwareCommitStateHandler decorator
// disposes the execution cycle handle AFTER the runner's commit, so the orchestrator's await-disposed sequencing
// correctly lands the Interrupted write last (no runner-clobber).
using var scope = _services.CreateScope();
var instanceStore = scope.ServiceProvider.GetRequiredService<IWorkflowInstanceStore>();
var interruptedInstances = (await instanceStore.FindManyAsync(
new WorkflowInstanceFilter { WorkflowSubStatus = WorkflowSubStatus.Interrupted },
CancellationToken.None)).ToList();
Assert.NotEmpty(interruptedInstances);
Assert.Equal(WorkflowStatus.Running, interruptedInstances[0].Status);
Assert.False(interruptedInstances[0].IsExecuting,
"An Interrupted instance must have IsExecuting=false so the existing timeout-based crash recovery does not also pick it up.");
// A WorkflowInterrupted forensic log entry was written for the force-cancelled execution cycle.
var logStore = scope.ServiceProvider.GetRequiredService<IWorkflowExecutionLogStore>();
var logRecords = await logStore.FindManyAsync(
new WorkflowExecutionLogRecordFilter(),
PageArgs.FromPage(0, 100),
CancellationToken.None);
var interruptedLog = logRecords.Items.FirstOrDefault(r => r.EventName == WorkflowInterruptedPayload.WorkflowInterruptedEventName);
Assert.NotNull(interruptedLog);
Assert.IsType<WorkflowInterruptedPayload>(interruptedLog!.Payload);
var payload = (WorkflowInterruptedPayload)interruptedLog.Payload!;
Assert.Equal(WorkflowInterruptedPayload.ReasonDeadlineBreach, payload.Reason);
}
[Fact(DisplayName = "Drain that completes within deadline reports CompletedWithinDeadline (success path baseline)")]
public async Task SuccessPathBaseline()
{
await _services.PopulateRegistriesAsync();
// No workflow running — drain finds zero active execution cycles and returns immediately.
var outcome = await _orchestrator.DrainAsync(DrainTrigger.HostStopSignal);
Assert.Equal(DrainResult.CompletedWithinDeadline, outcome.OverallResult);
Assert.Equal(0, outcome.ExecutionCyclesForceCancelledCount);
}
}
/// <summary>Shared state for <see cref="ObservableActivity"/>: signals when execution started and the natural-completion / cancelled outcome.</summary>
public sealed class ObservableActivityState
{
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Cancelled { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource CompletedNaturally { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
}
/// <summary>
/// A test activity that delays for a configurable duration while observing its <see cref="ActivityExecutionContext.CancellationToken"/>.
/// Emits <see cref="ObservableActivityState.Started"/> on entry and either <see cref="ObservableActivityState.Cancelled"/>
/// or <see cref="ObservableActivityState.CompletedNaturally"/> at exit.
/// </summary>
public class ObservableActivity : CodeActivity
{
/// <summary>Delay in milliseconds — using <c>int</c> so the activity remains serializable.</summary>
public int DelayMs { get; set; }
/// <summary>Out-of-band signalling channel for the test (set by the test before scheduling).</summary>
public ObservableActivityState? State { get; set; }
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
State?.Started.TrySetResult();
try
{
await Task.Delay(TimeSpan.FromMilliseconds(DelayMs), context.CancellationToken);
State?.CompletedNaturally.TrySetResult();
}
catch (OperationCanceledException)
{
State?.Cancelled.TrySetResult();
}
}
}