test(bpmn): prove execution state and the work ledger survive real persistence (#7947)

* test(bpmn): prove BpmnExecutionState pruning and ledger rehydration survive a real suspend/resume

Prune() was already being called before every persisted write in BpmnScopeHost, but nothing proved
it, and no BPMN test had ever exercised a genuine rehydration: every existing scenario used the
in-memory WorkflowState object straight from the previous run. Add tests that round-trip a
suspended scope's state through Elsa's own IWorkflowStateSerializer -- the boundary that mangled
values before -- and assert the persisted BpmnExecutionState stays bounded across many evaluations
and that a resumed scope with two live units of work matches each completion back to its binding
through the rehydrated BpmnWorkLedger. Both tests were confirmed red by mutation-testing away
Prune() and by returning an empty ledger from BpmnScopeMemory.Load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(bpmn): prove a nested scope's ledger survives real persistence

Both new tests in the prior commit suspended a root scope with live work
outstanding, but neither crossed a nested scope's own ledger through Elsa's
real IWorkflowStateSerializer -- the intersection of the two things that
have actually broken here: the handle-to-context map, and the serializer
boundary. Add a nested parallel split/join, blocking on both branches inside
an embedded subprocess, round-tripped through the serializer between each
branch's completion, and confirmed red by returning an empty ledger from
BpmnScopeMemory.Load and green with it restored.

Also extract the start/split/left/right/join/after/end topology shared
verbatim by ParallelSplitAndJoin and ParallelSplitAndJoinBlocking into one
private builder parameterised by the branches' work, keeping both public
factories unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sipke Schoorstra 2026-08-14 20:45:07 +02:00 committed by GitHub
parent 6a3d65ff7e
commit 7eaf056d65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 254 additions and 20 deletions

View file

@ -0,0 +1,128 @@
using Bpmn.Model.State;
using Elsa.Workflows;
using Xunit.Abstractions;
namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
/// <summary>
/// What survives a suspend and a resume: the pruned <see cref="BpmnExecutionState"/> and the host-side work ledger,
/// both persisted as JSON strings in <see cref="ActivityExecutionContext.Properties"/>.
/// </summary>
public class BpmnPersistenceTests(ITestOutputHelper testOutputHelper)
{
private readonly BpmnTestHost _host = new(testOutputHelper);
[Fact(DisplayName = "The persisted execution state stays bounded across many evaluations of one scope")]
public async Task PersistedExecutionState_StaysBoundedAcrossManyEvaluations()
{
// A sequential multi-instance loop drives one evaluation per iteration, all on the same scope. Every
// iteration but the last consumes a token that nothing afterward can ever reference again: an interpreter
// state that is written without being pruned first re-serializes every one of them, so the token count
// grows with the iteration count instead of staying flat.
const int cardinality = 12;
// Arrange & Act: run every iteration but the last, capturing the persisted token count while the scope is
// still suspended and mid-loop after each one. The scope's own context stops being persisted the moment it
// completes, so the last iteration -- which also completes "after" and the scope itself -- is run separately.
await _host.RunAsync(BpmnTestProcesses.SequentialMultiInstanceTask(_host.Log, cardinality));
var maxTokenCount = 0;
for (var i = 0; i < cardinality - 1; i++)
{
await _host.FinishWorkAsync("each");
var (state, _) = _host.PersistedScopeMemory();
Assert.NotNull(state);
maxTokenCount = Math.Max(maxTokenCount, state.Tokens.Count);
}
await _host.FinishWorkAsync("each");
// Assert: the loop actually ran to completion...
Assert.Equal(cardinality, _host.Log.Occurrences("executed:each"));
Assert.Equal(1, _host.Log.Occurrences("executed:after"));
// ...and the persisted state's token count stayed flat rather than growing with the iteration count. Without
// pruning, a consumed token from every prior iteration would still be there by the time the loop is nearly
// done, so the count would climb toward the iteration count instead of staying near-constant.
Assert.True(
maxTokenCount < cardinality,
$"Expected the persisted token count to stay well below the iteration count ({cardinality}) because Prune() drops consumed tokens no active work references, but the highest observed count was {maxTokenCount}.");
}
[Fact(DisplayName = "A scope resumed from a JSON round trip matches each completion back to its binding through the rehydrated ledger")]
public async Task ResumedScope_WithTwoUnitsOfLiveWork_MatchesEachCompletionToItsBindingThroughTheRehydratedLedger()
{
// Arrange: suspend with two live units of work outstanding, both bound under the same scope.
await _host.RunAsync(BpmnTestProcesses.ParallelSplitAndJoinBlocking(_host.Log));
var (_, ledgerBeforeResume) = _host.PersistedScopeMemory();
Assert.Equal(2, ledgerBeforeResume.Records.Count);
Assert.Contains(ledgerBeforeResume.Records, x => x.BindingRef == BpmnTestProcesses.BindingRef("left"));
Assert.Contains(ledgerBeforeResume.Records, x => x.BindingRef == BpmnTestProcesses.BindingRef("right"));
// Act: round-trip the workflow state through Elsa's own serializer -- what a real persistence store would
// do -- and resume each branch by name, one at a time, round-tripping again between them.
_host.RoundTripStateThroughJson();
// Assert: the ledger the resumed scope would read from is intact before either branch is even resumed.
var (_, ledgerAfterResume) = _host.PersistedScopeMemory();
Assert.Equal(2, ledgerAfterResume.Records.Count);
Assert.Contains(ledgerAfterResume.Records, x => x.BindingRef == BpmnTestProcesses.BindingRef("left"));
Assert.Contains(ledgerAfterResume.Records, x => x.BindingRef == BpmnTestProcesses.BindingRef("right"));
await _host.FinishWorkAsync("left");
_host.RoundTripStateThroughJson();
var result = await _host.FinishWorkAsync("right");
// Assert: both branches ran, and the join fired exactly once. Neither is possible unless the resumed scope's
// ledger still mapped each completing child context back to its own binding: a scope with a lost or shared
// handle map either drops a completion outright (the join never fires and the workflow never finishes) or
// cannot tell the two branches apart (the join fires more than once).
Assert.Contains("resumed:left", _host.Log.Entries);
Assert.Contains("resumed:right", _host.Log.Entries);
Assert.Equal(1, _host.Log.Occurrences("executed:after"));
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
}
[Fact(DisplayName = "A nested scope resumed from a JSON round trip matches each completion back to its binding through its own rehydrated ledger")]
public async Task ResumedNestedScope_WithTwoUnitsOfLiveWork_MatchesEachCompletionToItsBindingThroughTheRehydratedLedger()
{
// Arrange: suspend with two live units of work outstanding inside the nested scope -- the embedded
// subprocess -- while the root scope's own ledger holds only the subprocess itself.
await _host.RunAsync(BpmnTestProcesses.NestedParallelSplitAndJoinBlocking(_host.Log));
var (_, nestedLedgerBeforeResume) = _host.PersistedScopeMemory(nested: true);
Assert.Equal(2, nestedLedgerBeforeResume.Records.Count);
Assert.Contains(nestedLedgerBeforeResume.Records, x => x.BindingRef == BpmnTestProcesses.BindingRef("subLeft"));
Assert.Contains(nestedLedgerBeforeResume.Records, x => x.BindingRef == BpmnTestProcesses.BindingRef("subRight"));
// Act: round-trip the workflow state through Elsa's own serializer, then resume each branch by name, one at
// a time, round-tripping again between them -- exactly as the root-scope test above does, but crossing into
// the nested scope's own ledger instead of the root's.
_host.RoundTripStateThroughJson();
// Assert: the nested scope's own ledger is intact before either branch is even resumed.
var (_, nestedLedgerAfterResume) = _host.PersistedScopeMemory(nested: true);
Assert.Equal(2, nestedLedgerAfterResume.Records.Count);
Assert.Contains(nestedLedgerAfterResume.Records, x => x.BindingRef == BpmnTestProcesses.BindingRef("subLeft"));
Assert.Contains(nestedLedgerAfterResume.Records, x => x.BindingRef == BpmnTestProcesses.BindingRef("subRight"));
await _host.FinishWorkAsync("subLeft");
_host.RoundTripStateThroughJson();
var result = await _host.FinishWorkAsync("subRight");
// Assert: both branches ran, the nested join fired exactly once, and the process ran all the way out to the
// root scope's own "after" step and completion. None of this is possible unless the resumed nested scope's
// ledger still mapped each completing child context back to its own binding: a nested scope with a lost or
// shared handle map either drops a completion outright (the nested join never fires and the process never
// finishes) or cannot tell the two branches apart (the nested join fires more than once).
Assert.Contains("resumed:subLeft", _host.Log.Entries);
Assert.Contains("resumed:subRight", _host.Log.Entries);
Assert.Equal(1, _host.Log.Occurrences("executed:subAfter"));
Assert.Equal(1, _host.Log.Occurrences("executed:after"));
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
}
}

View file

@ -1,3 +1,5 @@
using System.Text.Json;
using Bpmn.Model.State;
using Elsa.Bpmn.Hosting;
using Elsa.Bpmn.IntegrationTests.Scenarios.HostPort.Activities;
using Elsa.Extensions;
@ -83,6 +85,42 @@ public sealed class BpmnTestHost
return ledger.Records.Select(record => (record.BindingRef, record.IterationId)).ToList();
}
/// <summary>
/// Replaces the current <see cref="WorkflowState"/> with what a round trip through Elsa's own
/// <see cref="IWorkflowStateSerializer"/> hands back — what a real persistence store would return on load,
/// rather than the exact same in-memory object the previous run produced.
/// </summary>
public void RoundTripStateThroughJson()
{
var state = _state ?? throw new InvalidOperationException("The workflow has not been run yet.");
var serializer = _services.GetRequiredService<IWorkflowStateSerializer>();
_state = serializer.Deserialize(serializer.Serialize(state));
}
/// <summary>
/// A BPMN scope's own memory, read straight off the current <see cref="WorkflowState"/> the way a resumed scope
/// itself sees it, rather than off any live <see cref="ActivityExecutionContext"/> the current process happens
/// to still hold. Sound for any process with exactly one BPMN scope; a process with a nested scope has two, so
/// <paramref name="nested"/> tells them apart by call-stack depth -- the nested scope's context is always
/// deeper than the root's.
/// </summary>
internal (BpmnExecutionState? State, BpmnWorkLedger Work) PersistedScopeMemory(bool nested = false)
{
var state = _state ?? throw new InvalidOperationException("The workflow has not been run yet.");
var scopeStates = state.ActivityExecutionContexts.Where(x => x.Properties.ContainsKey(BpmnScopeMemory.WorkLedgerPropertyKey));
var scopeState = nested
? scopeStates.OrderByDescending(x => x.CallStackDepth).First()
: scopeStates.OrderBy(x => x.CallStackDepth).First();
var executionStateJson = scopeState.Properties.TryGetValue(BpmnScopeMemory.ExecutionStatePropertyKey, out var value) ? value as string : null;
var workLedgerJson = (string)scopeState.Properties[BpmnScopeMemory.WorkLedgerPropertyKey];
return (
executionStateJson is null ? null : JsonSerializer.Deserialize<BpmnExecutionState>(executionStateJson),
JsonSerializer.Deserialize<BpmnWorkLedger>(workLedgerJson) ?? new BpmnWorkLedger());
}
private RunWorkflowResult Record(RunWorkflowResult result)
{
_result = result;

View file

@ -71,26 +71,8 @@ internal static class BpmnTestProcesses
}
/// <summary>A parallel gateway split and join.</summary>
public static BpmnProcess ParallelSplitAndJoin(BpmnTestLog log)
{
var definition = new BpmnProcessBuilder("parallel-split-and-join")
.StartEvent("start")
.ParallelGateway("split")
.Task("left", bindingRef: BindingRef("left"))
.Task("right", bindingRef: BindingRef("right"))
.ParallelGateway("join")
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "split")
.Connect("split", "left")
.Connect("split", "right")
.Connect("left", "join")
.Connect("right", "join")
.ConnectSequence("join", "after", "end")
.Build();
return Scope("scope", definition, Immediate("left", log), Immediate("right", log), Immediate("after", log));
}
public static BpmnProcess ParallelSplitAndJoin(BpmnTestLog log) =>
ParallelSplitAndJoinTopology("parallel-split-and-join", Immediate("left", log), Immediate("right", log), log);
/// <summary>A task that fails, with an error boundary event that catches it.</summary>
public static BpmnProcess ErrorBoundaryCaught(BpmnTestLog log)
@ -168,6 +150,56 @@ internal static class BpmnTestProcesses
return Scope("scope", definition, Blocking("each", log), Immediate("after", log));
}
/// <summary>
/// A sequential multi-instance task: one instance at a time, each blocking until a test finishes it. Used to
/// drive many evaluations of one scope so a persisted blob that is not pruned is observable as unbounded growth.
/// </summary>
public static BpmnProcess SequentialMultiInstanceTask(BpmnTestLog log, int cardinality)
{
var definition = new BpmnProcessBuilder("sequential-multi-instance-task")
.StartEvent("start")
.Task(BpmnElementTypes.Task, "each", bindingRef: BindingRef("each"), loopCharacteristics: new BpmnLoopCharacteristics(isSequential: true, cardinality: cardinality))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "each", "after", "end")
.Build();
return Scope("scope", definition, Blocking("each", log), Immediate("after", log));
}
/// <summary>
/// A parallel split into two branches that both block, and a join. Used to prove a scope suspends with two live
/// units of work outstanding and, once resumed, matches each completion back to its own binding through the
/// rehydrated ledger.
/// </summary>
public static BpmnProcess ParallelSplitAndJoinBlocking(BpmnTestLog log) =>
ParallelSplitAndJoinTopology("parallel-split-and-join-blocking", Blocking("left", log), Blocking("right", log), log);
/// <summary>
/// The start/split/left/right/join/after/end graph shared by <see cref="ParallelSplitAndJoin"/> and
/// <see cref="ParallelSplitAndJoinBlocking"/>, parameterised by the work the two branches run.
/// </summary>
private static BpmnProcess ParallelSplitAndJoinTopology(string processId, IActivity leftWork, IActivity rightWork, BpmnTestLog log)
{
var definition = new BpmnProcessBuilder(processId)
.StartEvent("start")
.ParallelGateway("split")
.Task("left", bindingRef: BindingRef("left"))
.Task("right", bindingRef: BindingRef("right"))
.ParallelGateway("join")
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "split")
.Connect("split", "left")
.Connect("split", "right")
.Connect("left", "join")
.Connect("right", "join")
.ConnectSequence("join", "after", "end")
.Build();
return Scope("scope", definition, leftWork, rightWork, Immediate("after", log));
}
/// <summary>
/// A collection-mode multi-instance task: one instance per item of a container-scoped variable, which the
/// interpreter reads back through <c>IBpmnVariableReader</c> while it evaluates.
@ -250,6 +282,42 @@ internal static class BpmnTestProcesses
return Scope("scope", definition, Scope("sub", body, Immediate("subOnly", log)), Immediate("after", log));
}
/// <summary>
/// A parallel split and join, blocking on both branches, nested inside an embedded subprocess. Used to prove
/// a <em>nested</em> scope's own ledger -- not just a root scope's -- matches each completion back to its
/// binding after a round trip through Elsa's own serializer.
/// </summary>
public static BpmnProcess NestedParallelSplitAndJoinBlocking(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("nested-parallel-split-and-join-body")
.StartEvent("subStart")
.ParallelGateway("subSplit")
.Task("subLeft", bindingRef: BindingRef("subLeft"))
.Task("subRight", bindingRef: BindingRef("subRight"))
.ParallelGateway("subJoin")
.Task("subAfter", bindingRef: BindingRef("subAfter"))
.EndEvent("subEnd")
.ConnectSequence("subStart", "subSplit")
.Connect("subSplit", "subLeft")
.Connect("subSplit", "subRight")
.Connect("subLeft", "subJoin")
.Connect("subRight", "subJoin")
.ConnectSequence("subJoin", "subAfter", "subEnd")
.Build();
var definition = new BpmnProcessBuilder("nested-parallel-split-and-join-blocking")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "sub", "after", "end")
.Build();
var nested = Scope("sub", body, Blocking("subLeft", log), Blocking("subRight", log), Immediate("subAfter", log));
return Scope("scope", definition, nested, Immediate("after", log));
}
/// <summary>A linear process: one task between a start and an end event.</summary>
public static BpmnProcess LinearTask(BpmnTestLog log)
{