elsa-core/test/unit/Elsa.Workflows.Runtime.UnitTests/Recovery/InterruptedRecoveryScannerTests.cs
Sipke Schoorstra 5ab383ce86
fix: stop requeuing Finished+Interrupted workflow instances (#8052)
* fix: stop requeuing Finished+Interrupted workflow instances

InterruptedRecoveryScanner now requires Status=Running, matching the
sibling crash-recovery task. DrainOrchestrator skips already-terminal
instances so a runner-clobber race cannot stamp Interrupted onto a
Finished row. Fixes #8052.

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

* fix: conditionally mark Interrupted so drain cannot clobber Finished

PersistInterruptedAsync no longer SaveAsync-es the Find snapshot. The
store now applies Interrupted only when Status is still non-terminal
(EF: ExecuteUpdate WHERE Status != Finished; memory: mutate the live
row). A runner that commits Finished between read and write keeps its
terminal state, so startup recovery cannot requeue completed work.

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

* test: disambiguate NSubstitute Returns for TryMarkInterruptedAsync

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

* fix: make in-memory Interrupted mark atomic against completion

Lock the memory-store check and mutations together, then abort if Status
became Finished in-place so drain cannot record Interrupted on a
completed instance.

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

* fix: share memory-store lock between Interrupted mark and Save

TryMarkInterruptedAsync now serializes with Save/Update/SaveMany so a
runner's terminal persist cannot land between the non-terminal check
and the Interrupted mutations. A finishing Save therefore cannot leave
Finished+Interrupted.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-11 23:05:43 -07:00

146 lines
6.2 KiB
C#

using Elsa.Common.Models;
using Elsa.Common.Multitenancy;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Management.Models;
using Elsa.Workflows.Runtime.Options;
using Elsa.Workflows.Runtime.Services;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace Elsa.Workflows.Runtime.UnitTests.Recovery;
public class InterruptedRecoveryScannerTests
{
private readonly IWorkflowRestarter _restarter = Substitute.For<IWorkflowRestarter>();
private readonly IWorkflowInstanceStore _instanceStore = Substitute.For<IWorkflowInstanceStore>();
private readonly ILogger<InterruptedRecoveryScanner> _logger = Substitute.For<ILogger<InterruptedRecoveryScanner>>();
private readonly RuntimeOptions _runtimeOptions = new() { RestartInterruptedWorkflowsBatchSize = 10 };
[Fact(DisplayName = "Scan filters by SubStatus = Interrupted and Status = Running")]
public async Task FiltersByInterruptedAndRunning()
{
StubInstances(new[] { Summary("a") });
var sut = BuildSut();
await sut.ScanAndRequeueAsync(CancellationToken.None);
await _instanceStore.Received().SummarizeManyAsync(
Arg.Is<WorkflowInstanceFilter>(f =>
f.WorkflowSubStatus == WorkflowSubStatus.Interrupted
&& f.WorkflowStatus == WorkflowStatus.Running),
Arg.Any<PageArgs>(),
Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "Scan calls IWorkflowRestarter once per matching instance and returns the count")]
public async Task RestartsEachInstance()
{
StubInstances(new[] { Summary("a"), Summary("b"), Summary("c") });
var sut = BuildSut();
var count = await sut.ScanAndRequeueAsync(CancellationToken.None);
Assert.Equal(3, count);
await _restarter.Received(1).RestartWorkflowAsync("a", Arg.Any<CancellationToken>());
await _restarter.Received(1).RestartWorkflowAsync("b", Arg.Any<CancellationToken>());
await _restarter.Received(1).RestartWorkflowAsync("c", Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "Per-instance failures are swallowed; remaining instances still requeued")]
public async Task SwallowsPerInstanceFailures()
{
StubInstances(new[] { Summary("a"), Summary("b"), Summary("c") });
_restarter.RestartWorkflowAsync("b", Arg.Any<CancellationToken>())
.Returns(_ => throw new InvalidOperationException("boom"));
var sut = BuildSut();
var count = await sut.ScanAndRequeueAsync(CancellationToken.None);
Assert.Equal(2, count); // 'b' failed
await _restarter.Received(1).RestartWorkflowAsync("a", Arg.Any<CancellationToken>());
await _restarter.Received(1).RestartWorkflowAsync("c", Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "No interrupted instances → returns 0 without invoking restarter")]
public async Task EmptyResultSet()
{
StubInstances(Array.Empty<WorkflowInstanceSummary>());
var sut = BuildSut();
var count = await sut.ScanAndRequeueAsync(CancellationToken.None);
Assert.Equal(0, count);
await _restarter.DidNotReceive().RestartWorkflowAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "Tenant context is pushed for tenant-scoped instances")]
public async Task PushesTenantContext()
{
StubInstances(new[] { Summary("a", tenantId: "tenant-1") });
var tenantService = Substitute.For<ITenantService>();
tenantService.FindAsync("tenant-1", Arg.Any<CancellationToken>()).Returns(new Tenant { Id = "tenant-1", Name = "Tenant 1" });
var tenantAccessor = new DefaultTenantAccessor();
var observedTenant = (string?)null;
_restarter
.When(x => x.RestartWorkflowAsync("a", Arg.Any<CancellationToken>()))
.Do(_ => observedTenant = tenantAccessor.Tenant?.Id);
var sut = BuildSut(tenantService, tenantAccessor);
await sut.ScanAndRequeueAsync(CancellationToken.None);
Assert.Equal("tenant-1", observedTenant);
Assert.Null(tenantAccessor.Tenant); // popped after the call
}
[Theory(DisplayName = "Default or agnostic tenant ids do NOT push tenant context")]
[InlineData(null)]
[InlineData("")]
[InlineData("*")]
public async Task DefaultOrAgnosticTenant(string? tenantId)
{
StubInstances(new[] { Summary("a", tenantId: tenantId) });
var tenantService = Substitute.For<ITenantService>();
var tenantAccessor = new DefaultTenantAccessor();
var observedTenant = (Tenant?)tenantAccessor.Tenant;
_restarter
.When(x => x.RestartWorkflowAsync("a", Arg.Any<CancellationToken>()))
.Do(_ => observedTenant = tenantAccessor.Tenant);
var sut = BuildSut(tenantService, tenantAccessor);
await sut.ScanAndRequeueAsync(CancellationToken.None);
Assert.Null(observedTenant);
await tenantService.DidNotReceive().FindAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
private InterruptedRecoveryScanner BuildSut(ITenantService? tenantService = null, ITenantAccessor? tenantAccessor = null) =>
new(_restarter, _instanceStore, Microsoft.Extensions.Options.Options.Create(_runtimeOptions), _logger, tenantService, tenantAccessor);
private void StubInstances(IReadOnlyList<WorkflowInstanceSummary> all)
{
_instanceStore
.SummarizeManyAsync(Arg.Any<WorkflowInstanceFilter>(), Arg.Any<PageArgs>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var pageArgs = ci.ArgAt<PageArgs>(1);
ICollection<WorkflowInstanceSummary> items = pageArgs.Offset == 0
? all.ToList()
: new List<WorkflowInstanceSummary>();
return new ValueTask<Page<WorkflowInstanceSummary>>(Page.Of(items, all.Count));
});
}
private static WorkflowInstanceSummary Summary(string id, string? tenantId = null) => new()
{
Id = id,
TenantId = tenantId,
DefinitionId = "def-" + id,
DefinitionVersionId = "ver-" + id,
Version = 1,
SubStatus = WorkflowSubStatus.Interrupted,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
};
}