Yield triggerless StateMachine cycles

This commit is contained in:
Sipke Schoorstra 2026-08-31 03:30:17 +02:00
parent 5a820ce33e
commit da5ccca2be
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
2 changed files with 104 additions and 0 deletions

View file

@ -19,7 +19,9 @@ namespace Elsa.Workflows.Activities.StateMachine.Activities;
public class StateMachine : Activity
{
private const string PhaseEntering = "Entering";
private const string PhaseContinuing = "Continuing";
private const string CurrentStateProperty = "CurrentState";
private readonly StateMachineContinuation _automaticTransitionContinuation = new();
/// <inheritdoc />
public StateMachine([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
@ -201,9 +203,25 @@ public class StateMachine : Activity
private async ValueTask CompleteTransitionAsync(ActivityExecutionContext context, Transition transition, ActivityExecutionContext? schedulingContext = null)
{
SetCurrentState(context, transition.To);
// Automatic transitions are normally evaluated inline after entering a state. If the
// target is part of a triggerless cycle, queue the next evaluation instead. This keeps
// the WF4 ordering (the transition has completed and the target state is current) while
// allowing the workflow scheduler to unwind the current call stack between iterations.
if (HasAutomaticCycle(GetCurrentState(context)))
{
await ScheduleAsync(context, _automaticTransitionContinuation, OnAutomaticTransitionContinuationCompletedAsync, PhaseContinuing, schedulingContext);
return;
}
await EnterStateAsync(context, schedulingContext);
}
private async ValueTask OnAutomaticTransitionContinuationCompletedAsync(ActivityCompletedContext context)
{
await EnterStateAsync(context.TargetContext, context.ChildContext);
}
private async ValueTask ScheduleTransitionActivityAsync(
ActivityExecutionContext context,
IActivity activity,
@ -296,6 +314,8 @@ public class StateMachine : Activity
if (transition.Action != null)
yield return transition.Action;
}
yield return _automaticTransitionContinuation;
}
private void EnsureSupportedTriggerIdentities()
@ -342,6 +362,29 @@ public class StateMachine : Activity
? []
: Transitions.Where(x => string.Equals(x.From, sourceState, StringComparison.Ordinal));
private bool HasAutomaticCycle(string? startingState)
{
if (string.IsNullOrWhiteSpace(startingState))
return false;
var visitedStates = new HashSet<string>(StringComparer.Ordinal);
var statesToVisit = new Stack<string>([startingState]);
while (statesToVisit.TryPop(out var state))
{
foreach (var transition in GetOutboundTransitions(state).Where(x => x.Trigger == null && FindState(x.To) != null))
{
if (string.Equals(transition.To, startingState, StringComparison.Ordinal))
return true;
if (visitedStates.Add(transition.To!))
statesToVisit.Push(transition.To!);
}
}
return false;
}
private Transition? FindTransitionByTrigger(ActivityExecutionContext context, IActivity trigger) =>
GetOutboundTransitions(GetCurrentState(context)).FirstOrDefault(x => ReferenceEquals(x.Trigger, trigger));
@ -373,4 +416,8 @@ public class StateMachine : Activity
string.IsNullOrWhiteSpace(transition.Name)
? $"{transition.From}->{transition.To}"
: transition.Name;
private sealed class StateMachineContinuation : CodeActivity
{
}
}

View file

@ -251,6 +251,63 @@ public class StateMachineTests
Assert.Equal(ActivityStatus.Completed, context.Status);
}
[Fact(DisplayName = "StateMachine yields an empty triggerless self-cycle to the workflow scheduler")]
public async Task EmptyTriggerlessSelfCycleYieldsToScheduler()
{
var stateMachine = new StateMachineActivity
{
InitialState = "A",
States = { new StateMachineState { Name = "A" } },
Transitions =
{
new Transition { From = "A", To = "A" }
}
};
var context = await ExecuteAsync(stateMachine);
Assert.Equal("A", context.GetProperty<string>(CurrentStateProperty));
Assert.Equal(ActivityStatus.Running, context.Status);
var continuation = context.WorkflowExecutionContext.Scheduler.Take();
await CompleteScheduledActivityAsync(context, continuation.Activity);
Assert.Equal("A", context.GetProperty<string>(CurrentStateProperty));
Assert.Equal(ActivityStatus.Running, context.Status);
Assert.Single(context.WorkflowExecutionContext.Scheduler.List());
}
[Fact(DisplayName = "StateMachine yields an empty triggerless two-state cycle to the workflow scheduler")]
public async Task EmptyTriggerlessTwoStateCycleYieldsToScheduler()
{
var stateMachine = new StateMachineActivity
{
InitialState = "A",
States =
{
new StateMachineState { Name = "A" },
new StateMachineState { Name = "B" }
},
Transitions =
{
new Transition { From = "A", To = "B" },
new Transition { From = "B", To = "A" }
}
};
var context = await ExecuteAsync(stateMachine);
Assert.Equal("B", context.GetProperty<string>(CurrentStateProperty));
Assert.Equal(ActivityStatus.Running, context.Status);
var continuation = context.WorkflowExecutionContext.Scheduler.Take();
await CompleteScheduledActivityAsync(context, continuation.Activity);
Assert.Equal("A", context.GetProperty<string>(CurrentStateProperty));
Assert.Equal(ActivityStatus.Running, context.Status);
Assert.Single(context.WorkflowExecutionContext.Scheduler.List());
}
[Fact(DisplayName = "StateMachine self-transition executes exit, action and entry in order")]
public async Task SelfTransitionExecutesExitActionAndEntryInOrder()
{