* fix(runtime): bound concurrent pre-cancel snapshot Finds during drain Unbounded Task.WhenAll of per-cycle Finds self-contends under large live-cycle N: more 250ms timeouts, more drainInduced excludes, more Interrupted misses. Cap snapshot Finds at 16. The 250ms budget still starts only after a slot is acquired so queued Finds are not fail-open excluded by waiting. Phase C stays sequential. No store-contract change. Closes #8083 Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix(runtime): exclude null pre-cancel snapshots from drainInduced A successful Find that returns no row is unknown pre-state, not a confirmed non-Cancelled snapshot. Joining drainInduced let Phase C rewrite a later Finished/Cancelled as Interrupted. Timeout/error already excluded; null now does too. Closes nothing extra; keeps #8083 fail-open exclude. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix(runtime): promote null snapshot after Phase A force-cancel A successful pre-cancel Find that returns no row is not a persisted user-cancel, but excluding it from drainInduced skipped Interrupted persist after deadline-breach force-cancel of a live cycle (DeadlineBreachPersistsInterrupted). Join drainInduced only after we ourselves cancel that handle. Timeout/error and confirmed Cancelled snapshots stay excluded. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix(runtime): promote null snapshot only when TryCancel transitions Cancel() is a no-op on an already-disposed handle, so treating every Cancel() call as drain-induced could rewrite a Finished/Cancelled row the runner committed while snapshot was in flight. TryCancel reports a real transition; only those ids join drainInduced after a null Find. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
173 lines
6.7 KiB
C#
173 lines
6.7 KiB
C#
using Elsa.Common;
|
|
using Elsa.Workflows.Runtime.Services;
|
|
using NSubstitute;
|
|
|
|
namespace Elsa.Workflows.Runtime.UnitTests.Quiescence;
|
|
|
|
public class ExecutionCycleRegistryTests
|
|
{
|
|
private readonly ISystemClock _clock;
|
|
private readonly IIngressSourceRegistry _sources;
|
|
|
|
public ExecutionCycleRegistryTests()
|
|
{
|
|
_clock = Substitute.For<ISystemClock>();
|
|
_clock.UtcNow.Returns(DateTimeOffset.Parse("2026-04-24T10:00:00Z"));
|
|
_sources = Substitute.For<IIngressSourceRegistry>();
|
|
_sources.Snapshot().Returns(Array.Empty<IngressSourceSnapshot>());
|
|
}
|
|
|
|
[Fact(DisplayName = "Active count increases and decreases with begin/dispose")]
|
|
public void ActiveCountFollowsExecutionCycleLifecycle()
|
|
{
|
|
var sut = new ExecutionCycleRegistry(_sources, _clock);
|
|
|
|
Assert.Equal(0, sut.ActiveCount);
|
|
|
|
var a = sut.BeginCycle("instance-1", ingressSourceName: null, CancellationToken.None);
|
|
Assert.Equal(1, sut.ActiveCount);
|
|
|
|
var b = sut.BeginCycle("instance-2", ingressSourceName: null, CancellationToken.None);
|
|
Assert.Equal(2, sut.ActiveCount);
|
|
|
|
a.Dispose();
|
|
Assert.Equal(1, sut.ActiveCount);
|
|
|
|
b.Dispose();
|
|
Assert.Equal(0, sut.ActiveCount);
|
|
}
|
|
|
|
[Fact(DisplayName = "Begin with null ingress name does NOT flip any source")]
|
|
public void NullIngressNameDoesNotFlip()
|
|
{
|
|
var sut = new ExecutionCycleRegistry(_sources, _clock);
|
|
|
|
using var _ = sut.BeginCycle("instance-1", ingressSourceName: null, CancellationToken.None);
|
|
|
|
_sources.DidNotReceive().MarkPauseFailedAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<Exception?>());
|
|
}
|
|
|
|
[Fact(DisplayName = "Begin from a Paused source flips it to PauseFailed (FR-018)")]
|
|
public void PausedSourceDeliveringIsFlipped()
|
|
{
|
|
var now = _clock.UtcNow;
|
|
var snapshot = new[] { new IngressSourceSnapshot("http.trigger", IngressSourceState.Paused, null, now) };
|
|
_sources.Snapshot().Returns(snapshot);
|
|
var sut = new ExecutionCycleRegistry(_sources, _clock);
|
|
|
|
using var _ = sut.BeginCycle("instance-1", ingressSourceName: "http.trigger", CancellationToken.None);
|
|
|
|
_sources.Received(1).MarkPauseFailedAsync("http.trigger", "delivered-while-paused", Arg.Any<Exception?>());
|
|
}
|
|
|
|
[Fact(DisplayName = "Begin from a Running source does NOT flip")]
|
|
public void RunningSourceIsNotFlipped()
|
|
{
|
|
var now = _clock.UtcNow;
|
|
var snapshot = new[] { new IngressSourceSnapshot("http.trigger", IngressSourceState.Running, null, now) };
|
|
_sources.Snapshot().Returns(snapshot);
|
|
var sut = new ExecutionCycleRegistry(_sources, _clock);
|
|
|
|
using var _ = sut.BeginCycle("instance-1", ingressSourceName: "http.trigger", CancellationToken.None);
|
|
|
|
_sources.DidNotReceive().MarkPauseFailedAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<Exception?>());
|
|
}
|
|
|
|
[Fact(DisplayName = "ListActiveCycles returns a snapshot of live handles")]
|
|
public void ListActiveCyclesReturnsSnapshot()
|
|
{
|
|
var sut = new ExecutionCycleRegistry(_sources, _clock);
|
|
var a = sut.BeginCycle("instance-1", null, CancellationToken.None);
|
|
var b = sut.BeginCycle("instance-2", null, CancellationToken.None);
|
|
|
|
var snapshot = sut.ListActiveCycles();
|
|
|
|
Assert.Equal(2, snapshot.Count);
|
|
Assert.Contains(a, snapshot);
|
|
Assert.Contains(b, snapshot);
|
|
|
|
a.Dispose();
|
|
b.Dispose();
|
|
}
|
|
|
|
[Fact(DisplayName = "ExecutionCycleHandle.Cancel triggers the cancellation token")]
|
|
public void ExecutionCycleHandleCancelFiresToken()
|
|
{
|
|
var sut = new ExecutionCycleRegistry(_sources, _clock);
|
|
using var handle = sut.BeginCycle("instance-1", null, CancellationToken.None);
|
|
|
|
Assert.False(handle.CancellationToken.IsCancellationRequested);
|
|
handle.Cancel();
|
|
Assert.True(handle.CancellationToken.IsCancellationRequested);
|
|
}
|
|
|
|
[Fact(DisplayName = "ExecutionCycleHandle.TryCancel reports whether this call transitioned the handle")]
|
|
public void ExecutionCycleHandleTryCancelReportsTransition()
|
|
{
|
|
var sut = new ExecutionCycleRegistry(_sources, _clock);
|
|
var handle = sut.BeginCycle("instance-1", null, CancellationToken.None);
|
|
|
|
Assert.True(handle.TryCancel());
|
|
Assert.False(handle.TryCancel());
|
|
|
|
handle.Dispose();
|
|
Assert.False(handle.TryCancel());
|
|
|
|
var disposed = sut.BeginCycle("instance-2", null, CancellationToken.None);
|
|
disposed.Dispose();
|
|
Assert.False(disposed.TryCancel());
|
|
}
|
|
|
|
[Fact(DisplayName = "ExecutionCycleHandle.Cancel invokes the cancel callback supplied at registration")]
|
|
public void CancelCallbackIsInvoked()
|
|
{
|
|
var sut = new ExecutionCycleRegistry(_sources, _clock);
|
|
var callbackInvocations = 0;
|
|
using var handle = sut.BeginCycle(
|
|
"instance-1",
|
|
ingressSourceName: null,
|
|
linkedToken: CancellationToken.None,
|
|
cancelCallback: () => Interlocked.Increment(ref callbackInvocations));
|
|
|
|
handle.Cancel();
|
|
Assert.Equal(1, callbackInvocations);
|
|
|
|
// Truly idempotent: a second Cancel() before Dispose() must NOT re-invoke the callback. The handle uses an
|
|
// Interlocked _cancelled flag so callers can't accidentally trigger non-idempotent cancellation side effects.
|
|
handle.Cancel();
|
|
Assert.Equal(1, callbackInvocations);
|
|
|
|
// Once disposed, further Cancel() invocations remain silent no-ops.
|
|
handle.Dispose();
|
|
handle.Cancel();
|
|
Assert.Equal(1, callbackInvocations);
|
|
}
|
|
|
|
[Fact(DisplayName = "ExecutionCycleHandle.Cancel swallows callback exceptions so drain is not interrupted")]
|
|
public void CancelCallbackExceptionsAreSwallowed()
|
|
{
|
|
var sut = new ExecutionCycleRegistry(_sources, _clock);
|
|
using var handle = sut.BeginCycle(
|
|
"instance-1",
|
|
ingressSourceName: null,
|
|
linkedToken: CancellationToken.None,
|
|
cancelCallback: () => throw new InvalidOperationException("activity refused to cancel"));
|
|
|
|
// Should not throw — Cancel() must remain best-effort so a single misbehaving workflow does not crash drain.
|
|
handle.Cancel();
|
|
Assert.True(handle.CancellationToken.IsCancellationRequested);
|
|
}
|
|
|
|
[Fact(DisplayName = "ExecutionCycleHandle.Disposed completes when the handle is disposed")]
|
|
public async Task DisposedTaskCompletesOnDispose()
|
|
{
|
|
var sut = new ExecutionCycleRegistry(_sources, _clock);
|
|
var handle = sut.BeginCycle("instance-1", null, CancellationToken.None);
|
|
|
|
Assert.False(handle.Disposed.IsCompleted);
|
|
handle.Dispose();
|
|
await handle.Disposed.WaitAsync(TimeSpan.FromSeconds(1));
|
|
Assert.True(handle.Disposed.IsCompletedSuccessfully);
|
|
}
|
|
}
|