< Summary

Information
Class: Elsa.Bpmn.Hosting.BpmnCommandApplier
Assembly: Elsa.Bpmn
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn/Hosting/BpmnCommandApplier.cs
Line coverage
89%
Covered lines: 58
Uncovered lines: 7
Coverable lines: 65
Total lines: 170
Line coverage: 89.2%
Branch coverage
64%
Covered branches: 24
Total branches: 37
Branch coverage: 64.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ApplyAsync()92.85%141493.33%
StartWorkAsync()75%44100%
CancelWorkSubtreeAsync()50%4475%
SignalEnclosingScopeAsync(...)100%11100%
BuildIterationVariables(...)100%22100%
ToClrValue(...)30.76%211363.63%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn/Hosting/BpmnCommandApplier.cs

#LineLine coverage
 1using System.Text.Json;
 2using Bpmn.Model;
 3using Bpmn.Semantics;
 4using Elsa.Bpmn.Activities;
 5using Elsa.Bpmn.Signals;
 6using Elsa.Extensions;
 7using Elsa.Workflows;
 8using Elsa.Workflows.Memory;
 9using Elsa.Workflows.Options;
 10
 11namespace Elsa.Bpmn.Hosting;
 12
 13/// <summary>
 14/// Translates the interpreter's three host commands onto <see cref="ActivityExecutionContext"/>.
 15/// </summary>
 16/// <remarks>
 17/// <list type="table">
 18///   <item><term><c>StartWork</c></term><description><see cref="ActivityExecutionContext.ScheduleActivityAsync(IActivit
 19///   <item><term><c>CancelWorkSubtree</c></term><description><c>CancelActivityAsync</c>, which already walks the child 
 20///   <item><term><c>SignalEnclosingScope</c></term><description><c>SendSignalAsync</c>, which bubbles to ancestors.</de
 21/// </list>
 22/// </remarks>
 13623internal sealed class BpmnCommandApplier(ActivityExecutionContext scopeContext, BpmnProcess process, BpmnScopeMemory mem
 24{
 25    /// <summary>
 26    /// Applies a command list <b>in the order returned</b>.
 27    /// </summary>
 28    /// <remarks>
 29    /// The ordering carries meaning and is not an implementation detail. An interrupting boundary event emits the
 30    /// boundary path's <c>StartWork</c> <i>before</i> the teardown that retires the host it interrupted, and a host
 31    /// that tidied up first would be applying a different process.
 32    /// </remarks>
 33    public async ValueTask ApplyAsync(IReadOnlyList<BpmnHostCommand> commands)
 34    {
 35        // Refused before anything in the batch is applied. Applying commands one at a time and refusing only once
 36        // the offending StartWork is reached would leave earlier commands in the same batch already applied and
 37        // saved via memory.SaveWork() below — and under ContinueWithIncidentsStrategy that throw is absorbed into
 38        // an incident rather than surfaced, so the workflow would carry on with a half-applied batch and half-saved
 39        // memory instead of the refusal stopping it clean.
 48040        foreach (var start in commands.OfType<BpmnHostCommand.StartWork>())
 41        {
 10542            if (process.FindWorkActivity(start.BindingRef) is BpmnProcess { IsRootScope: true } nested)
 43            {
 244                throw new InvalidOperationException(
 245                    $"BPMN element '{start.ElementId}' binds process activity '{nested.Id}' as the work of scope '{proce
 246                    + "A nested scope's start events are internal to the process around it, not workflow entry points, s
 47            }
 48        }
 49
 48650        foreach (var command in commands)
 51        {
 52            switch (command)
 53            {
 54                case BpmnHostCommand.StartWork start:
 10255                    await StartWorkAsync(start);
 10256                    break;
 57                case BpmnHostCommand.CancelWorkSubtree cancel:
 558                    await CancelWorkSubtreeAsync(cancel);
 359                    break;
 60                case BpmnHostCommand.SignalEnclosingScope signal:
 361                    await SignalEnclosingScopeAsync(signal);
 362                    break;
 63                default:
 64                    // The command hierarchy is closed, so this can only be reached by a library version that added a
 65                    // command this host has never heard of. Refusing is the only honest answer: silently skipping it
 66                    // would run a different process than the one the interpreter decided on.
 067                    throw new NotSupportedException($"The BPMN host command '{command.GetType().Name}' is not supported 
 68            }
 69
 10870            memory.SaveWork();
 71        }
 13272    }
 73
 74    private async ValueTask StartWorkAsync(BpmnHostCommand.StartWork start)
 75    {
 10276        var activity = process.FindWorkActivity(start.BindingRef)
 10277                       ?? throw new InvalidOperationException(
 10278                           $"BPMN element '{start.ElementId}' binds work '{start.BindingRef}', which activity '{process.
 79
 80        // The rule that a nested scope registers no start triggers is enforced by ApplyAsync's pre-scan, before any
 81        // command in the batch is applied — not here, where earlier commands in the same batch could already have
 82        // been applied and persisted.
 10283        var workflowExecutionContext = scopeContext.WorkflowExecutionContext;
 84
 85        // The child's context is created up front so that this scope has its id before the child ever runs, and can
 86        // key the unit of work on it. The alternative — recognising the child by ActivityExecutionContext.Tag — is
 87        // unsound across nested scopes, because the completion-callback dispatch rewrites the receiving context's Tag.
 10288        var childContext = await workflowExecutionContext.CreateActivityExecutionContextAsync(activity, new ActivityInvo
 10289        {
 10290            Owner = scopeContext,
 10291            Variables = BuildIterationVariables(start.IterationScope),
 10292            SchedulingActivityExecutionId = scopeContext.Id
 10293        });
 94
 95        // The correlation is opaque interpreter state that must travel with the work and, when the work is a nested
 96        // BPMN process, arrive there as its InvocationCorrelation. It goes on the child's own context rather than on
 97        // the activity instance: an activity object is shared by every concurrent execution of one definition, so
 98        // writing per-invocation state onto it corrupts as soon as two instances run at once.
 10299        BpmnScopeMemory.Write(childContext, BpmnScopeHost.InvocationCorrelationPropertyKey, start.Correlation);
 100
 102101        childContext.Taint();
 102102        workflowExecutionContext.AddActivityExecutionContext(childContext);
 103
 104        // Recorded before scheduling, so the work is live from the moment anything could report against it. This
 105        // never checks for an existing record on the same (BindingRef, IterationId): the port guarantees the
 106        // interpreter never issues a second StartWork for a slot it already holds live, so a duplicate here would be
 107        // an interpreter contract breach, not a host-side race. Were it to happen anyway, Records is append-only and
 108        // keyed by handle rather than by slot, so nothing gets overwritten or stranded — the older record, and the
 109        // context behind it, stay exactly as reachable as before. What would go wrong is the snapshot then reporting
 110        // two live entries for one slot, which is the interpreter's invariant to keep, not this host's to enforce.
 102111        memory.Work.Records.Add(new BpmnWorkRecord
 102112        {
 102113            Handle = memory.Work.NextHandle(),
 102114            BindingRef = start.BindingRef,
 102115            IterationId = start.IterationScope?.IterationId,
 102116            ElementId = start.ElementId,
 102117            ChildContextId = childContext.Id
 102118        });
 119
 120        // A named instance method, not a lambda: completion callbacks are rehydrated by method name.
 102121        await scopeContext.ScheduleActivityAsync(activity, new ScheduleWorkOptions
 102122        {
 102123            CompletionCallback = process.OnWorkCompletedAsync,
 102124            ExistingActivityExecutionContext = childContext,
 102125            SchedulingActivityExecutionId = scopeContext.Id
 102126        });
 102127    }
 128
 129    private async ValueTask CancelWorkSubtreeAsync(BpmnHostCommand.CancelWorkSubtree cancel)
 130    {
 131        // The interpreter only ever names a handle this scope reported as live, so a miss means the work has already
 132        // gone — a race BPMN produces routinely, and one the interpreter absorbs on the way back in.
 5133        if (memory.Work.FindByHandle(cancel.Handle) is not { } record)
 0134            return;
 135
 5136        memory.Work.Remove(record);
 137
 138        // Saved here rather than left to the end-of-command save in ApplyAsync: CancelSubtreeAsync can refuse with a
 139        // NotSupportedException when the subtree still has scheduled-but-not-invoked work, and under the
 140        // continue-with-incidents strategy that throw is absorbed into an incident rather than left to crash the
 141        // process — so the end-of-command save would never run. Saving the removal now, before the possible throw,
 142        // keeps the persisted ledger from claiming work this scope just tore down, and is what lets a completion
 143        // callback that later arrives for the stranded activity find no live record and be discarded (see
 144        // BpmnScopeHost.OnWorkCompletedAsync) instead of being handed to the interpreter as real work.
 5145        memory.SaveWork();
 146
 5147        if (BpmnWorkTeardown.FindContext(scopeContext.WorkflowExecutionContext, record.ChildContextId) is not { } childC
 0148            return;
 149
 5150        await BpmnWorkTeardown.CancelSubtreeAsync(childContext, $"element '{cancel.ElementId}', {cancel.Reason}");
 3151    }
 152
 153    private ValueTask SignalEnclosingScopeAsync(BpmnHostCommand.SignalEnclosingScope signal) =>
 3154        scopeContext.SendSignalAsync(new BpmnScopeSignal(signal.Code, signal.Payload));
 155
 156    private static ICollection<Variable>? BuildIterationVariables(BpmnIterationScope? iterationScope) =>
 122157        iterationScope?.Values.Select(value => new Variable(value.Key, ToClrValue(value.Value))).ToList();
 158
 20159    private static object? ToClrValue(BpmnValue value) => value.Json is not { } json
 20160        ? null
 20161        : json.ValueKind switch
 20162        {
 3163            JsonValueKind.String => json.GetString(),
 17164            JsonValueKind.Number => json.TryGetInt64(out var integer) ? integer : json.GetDouble(),
 0165            JsonValueKind.True => true,
 0166            JsonValueKind.False => false,
 0167            JsonValueKind.Null or JsonValueKind.Undefined => null,
 0168            _ => json.GetRawText()
 20169        };
 170}