Fix StateMachine transition lifecycle ordering

This commit is contained in:
Sipke Schoorstra 2026-08-31 02:47:29 +02:00
parent dff7d9f987
commit 051e12f864
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
2 changed files with 121 additions and 19 deletions

View file

@ -98,7 +98,7 @@ public class StateMachine : Activity
private async ValueTask ScheduleOutboundTriggersAsync(ActivityExecutionContext context, ActivityExecutionContext? schedulingContext = null)
{
var outboundTransitions = GetOutboundTransitions(GetCurrentState(context)).Where(x => x.Trigger != null && FindState(x.To) != null).ToList();
var outboundTransitions = GetOutboundTransitions(GetCurrentState(context)).Where(x => FindState(x.To) != null).ToList();
if (!outboundTransitions.Any())
{
@ -106,7 +106,18 @@ public class StateMachine : Activity
return;
}
foreach (var transition in outboundTransitions)
foreach (var transition in outboundTransitions.Where(x => x.Trigger == null))
{
if (await TryTakeTransitionAsync(context, transition, schedulingContext))
return;
}
var triggeredTransitions = outboundTransitions.Where(x => x.Trigger != null).ToList();
if (!triggeredTransitions.Any())
return;
foreach (var transition in triggeredTransitions)
await ScheduleAsync(context, transition.Trigger!, OnTriggerCompletedAsync, GetTransitionKey(transition), schedulingContext);
}
@ -118,25 +129,40 @@ public class StateMachine : Activity
if (transition == null || !IsCurrentSource(targetContext, transition) || FindState(transition.To) == null)
return;
var canTransition = transition.Condition == null || await EvaluateConditionAsync(targetContext, transition.Condition);
if (!canTransition)
if (!await TryTakeTransitionAsync(targetContext, transition, context.ChildContext))
{
if (transition.Trigger != null)
await ScheduleAsync(targetContext, transition.Trigger, OnTriggerCompletedAsync, GetTransitionKey(transition), context.ChildContext);
return;
}
}
await CancelCompetingTriggersAsync(targetContext, transition, context.ChildContext);
private async ValueTask<bool> TryTakeTransitionAsync(
ActivityExecutionContext context,
Transition transition,
ActivityExecutionContext? schedulingContext)
{
var canTransition = transition.Condition == null || await EvaluateConditionAsync(context, transition.Condition);
if (!canTransition)
return false;
await CancelCompetingTriggersAsync(context, transition, schedulingContext);
await ExitStateAsync(context, transition, schedulingContext);
return true;
}
private async ValueTask RunTransitionActionAsync(ActivityExecutionContext context, Transition transition, ActivityExecutionContext? schedulingContext = null)
{
if (!IsCurrentSource(context, transition))
return;
if (transition.Action != null)
{
await ScheduleTransitionActivityAsync(targetContext, transition.Action, transition, OnTransitionActionCompletedAsync, context.ChildContext);
await ScheduleTransitionActivityAsync(context, transition.Action, transition, OnTransitionActionCompletedAsync, schedulingContext);
return;
}
await ExitStateAsync(targetContext, transition, context.ChildContext);
await CompleteTransitionAsync(context, transition, schedulingContext);
}
private async ValueTask OnTransitionActionCompletedAsync(ActivityCompletedContext context)
@ -147,7 +173,7 @@ public class StateMachine : Activity
if (transition == null || !IsCurrentSource(targetContext, transition))
return;
await ExitStateAsync(targetContext, transition, context.ChildContext);
await CompleteTransitionAsync(targetContext, transition, context.ChildContext);
}
private async ValueTask ExitStateAsync(ActivityExecutionContext context, Transition transition, ActivityExecutionContext? schedulingContext = null)
@ -160,7 +186,7 @@ public class StateMachine : Activity
return;
}
await CompleteTransitionAsync(context, transition, schedulingContext);
await RunTransitionActionAsync(context, transition, schedulingContext);
}
private async ValueTask OnStateExitCompletedAsync(ActivityCompletedContext context)
@ -171,7 +197,7 @@ public class StateMachine : Activity
if (transition == null || !IsCurrentSource(targetContext, transition))
return;
await CompleteTransitionAsync(targetContext, transition, context.ChildContext);
await RunTransitionActionAsync(targetContext, transition, context.ChildContext);
}
private async ValueTask CompleteTransitionAsync(ActivityExecutionContext context, Transition transition, ActivityExecutionContext? schedulingContext = null)
@ -207,7 +233,7 @@ public class StateMachine : Activity
await context.ScheduleActivityAsync(activity, options);
}
private async Task CancelCompetingTriggersAsync(ActivityExecutionContext context, Transition winningTransition, ActivityExecutionContext winningTriggerContext)
private async Task CancelCompetingTriggersAsync(ActivityExecutionContext context, Transition winningTransition, ActivityExecutionContext? winningTriggerContext)
{
var competingTriggerIds = GetOutboundTransitions(winningTransition.From)
.Where(x => !ReferenceEquals(x, winningTransition))

View file

@ -83,18 +83,19 @@ public class StateMachineTests
Assert.Equal(ActivityStatus.Running, context.Status);
}
[Fact(DisplayName = "StateMachine executes accepted transition action, source exit, target entry and target triggers")]
[Fact(DisplayName = "StateMachine executes source exit, accepted transition action, target entry and target triggers")]
public async Task ExecutesAcceptedTransitionPath()
{
var context = await ExecuteAndEnterNewStateAsync();
await CompleteScheduledActivityAsync(context, _payTrigger);
Assert.True(context.HasScheduledActivity(_newExit));
Assert.False(context.HasScheduledActivity(_payAction));
await CompleteScheduledActivityAsync(context, _newExit);
Assert.True(context.HasScheduledActivity(_payAction));
await CompleteScheduledActivityAsync(context, _payAction);
Assert.True(context.HasScheduledActivity(_newExit));
await CompleteScheduledActivityAsync(context, _newExit);
Assert.Equal("Paid", _stateMachine.CurrentState);
Assert.Equal("Paid", context.GetProperty<string>(CurrentStateProperty));
Assert.True(context.HasScheduledActivity(_paidEntry));
@ -120,6 +121,80 @@ public class StateMachineTests
Assert.True(context.HasScheduledActivity(_paidTrigger));
}
[Fact(DisplayName = "StateMachine evaluates a triggerless transition immediately after state entry")]
public async Task TriggerlessTransitionIsEvaluatedImmediately()
{
var payTransition = _stateMachine.Transitions.Single(x => x.Name == "Pay");
payTransition.Trigger = null;
payTransition.Condition = null;
var context = await ExecuteAsync();
await CompleteScheduledActivityAsync(context, _newEntry);
Assert.True(context.HasScheduledActivity(_newExit));
Assert.False(context.HasScheduledActivity(_cancelTrigger));
await CompleteScheduledActivityAsync(context, _newExit);
await CompleteScheduledActivityAsync(context, _payAction);
Assert.Equal("Paid", context.GetProperty<string>(CurrentStateProperty));
Assert.True(context.HasScheduledActivity(_paidEntry));
}
[Fact(DisplayName = "StateMachine schedules event triggers when triggerless transition conditions are false")]
public async Task FalseTriggerlessConditionAllowsTriggeredTransitions()
{
var payTransition = _stateMachine.Transitions.Single(x => x.Name == "Pay");
payTransition.Trigger = null;
payTransition.Condition = new(false);
var context = await ExecuteAsync();
await CompleteScheduledActivityAsync(context, _newEntry);
Assert.Equal("New", _stateMachine.CurrentState);
Assert.False(context.HasScheduledActivity(_newExit));
Assert.True(context.HasScheduledActivity(_cancelTrigger));
Assert.Equal(ActivityStatus.Running, context.Status);
}
[Fact(DisplayName = "StateMachine leaves an all-false triggerless state active without rescheduling")]
public async Task AllFalseTriggerlessTransitionsDoNotCompleteOrSpin()
{
_stateMachine.Transitions.Remove(_stateMachine.Transitions.Single(x => x.Name == "Cancel"));
var payTransition = _stateMachine.Transitions.Single(x => x.Name == "Pay");
payTransition.Trigger = null;
payTransition.Condition = new(false);
var context = await ExecuteAsync();
await CompleteScheduledActivityAsync(context, _newEntry);
Assert.Equal("New", _stateMachine.CurrentState);
Assert.Equal(ActivityStatus.Running, context.Status);
Assert.False(context.HasScheduledActivity(_newExit));
Assert.False(context.HasScheduledActivity(_payAction));
Assert.False(context.HasScheduledActivity(_paidEntry));
Assert.DoesNotContain(context.WorkflowExecutionContext.CompletionCallbacks, x => x.Owner == context);
}
[Fact(DisplayName = "StateMachine self-transition executes exit, action and entry in order")]
public async Task SelfTransitionExecutesExitActionAndEntryInOrder()
{
var transition = _stateMachine.Transitions.Single(x => x.Name == "Pay");
transition.To = "New";
var context = await ExecuteAndEnterNewStateAsync();
await CompleteScheduledActivityAsync(context, _payTrigger);
Assert.True(context.HasScheduledActivity(_newExit));
Assert.False(context.HasScheduledActivity(_payAction));
await CompleteScheduledActivityAsync(context, _newExit);
Assert.True(context.HasScheduledActivity(_payAction));
await CompleteScheduledActivityAsync(context, _payAction);
Assert.Equal("New", _stateMachine.CurrentState);
Assert.True(context.HasScheduledActivity(_newEntry));
}
[Fact(DisplayName = "StateMachine false transition condition leaves competing triggers active")]
public async Task FalseConditionLeavesCompetingTriggersActive()
{
@ -242,10 +317,11 @@ public class StateMachineTests
var context = await ExecuteAndEnterNewStateAsync();
await CompleteScheduledActivityAsync(context, _payTrigger);
await CompleteScheduledActivityAsync(context, _newExit);
context.SetProperty(CurrentStateProperty, "Paid");
await CompleteScheduledActivityAsync(context, _payAction);
Assert.False(context.HasScheduledActivity(_newExit));
Assert.False(context.HasScheduledActivity(_paidEntry));
}
private async Task<ActivityExecutionContext> ExecuteAndEnterNewStateAsync()