From da5ccca2be0d9243ac93f6abf140927cb629024d Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 31 Aug 2026 03:30:17 +0200 Subject: [PATCH] Yield triggerless StateMachine cycles --- .../StateMachine/Activities/StateMachine.cs | 47 +++++++++++++++ .../StateMachine/StateMachineTests.cs | 57 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs b/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs index 00d1b1e82..f7d713525 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs @@ -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(); /// 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(StringComparer.Ordinal); + var statesToVisit = new Stack([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 + { + } } diff --git a/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs b/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs index b504a48b8..a45a8e4c5 100644 --- a/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs @@ -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(CurrentStateProperty)); + Assert.Equal(ActivityStatus.Running, context.Status); + + var continuation = context.WorkflowExecutionContext.Scheduler.Take(); + await CompleteScheduledActivityAsync(context, continuation.Activity); + + Assert.Equal("A", context.GetProperty(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(CurrentStateProperty)); + Assert.Equal(ActivityStatus.Running, context.Status); + + var continuation = context.WorkflowExecutionContext.Scheduler.Take(); + await CompleteScheduledActivityAsync(context, continuation.Activity); + + Assert.Equal("A", context.GetProperty(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() {