< Summary

Information
Class: Elsa.Bpmn.Hosting.BpmnScopeHost
Assembly: Elsa.Bpmn
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn/Hosting/BpmnScopeHost.cs
Line coverage
97%
Covered lines: 99
Uncovered lines: 3
Coverable lines: 102
Total lines: 267
Line coverage: 97%
Branch coverage
84%
Covered branches: 27
Total branches: 32
Branch coverage: 84.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
For(...)100%11100%
DispatcherOf(...)100%11100%
get_Graph()100%11100%
StartAsync()100%11100%
OnWorkCompletedAsync(...)75%4494.11%
OnScopeSignalledAsync(...)66.66%6687.5%
OnWorkFaultedAsync()100%66100%
EvaluateAsync(...)100%1193.33%
ApplyAsync()83.33%6685.71%
ResolveFaultedWork(...)100%66100%
Snapshot(...)100%11100%
get_InvocationCorrelation()100%22100%
BuildGraph()50%22100%

File(s)

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

#LineLine coverage
 1using Bpmn.Model;
 2using Bpmn.Semantics;
 3using Elsa.Bpmn.Activities;
 4using Elsa.Bpmn.Exceptions;
 5using Elsa.Bpmn.Signals;
 6using Elsa.Extensions;
 7using Elsa.Workflows;
 8using Elsa.Workflows.Activities.Flowchart.Models;
 9using Elsa.Workflows.Signals;
 10
 11namespace Elsa.Bpmn.Hosting;
 12
 13/// <summary>
 14/// The host side of the <c>Bpmn.Semantics</c> port for one BPMN scope: it feeds the interpreter's four entry points
 15/// and applies what comes back onto the scope's <see cref="ActivityExecutionContext"/>.
 16/// </summary>
 17/// <remarks>
 18/// <para>
 19/// Every entry point is synchronous and returns a value; the interpreter never calls back. The host's job is to say
 20/// what its world looks like — a snapshot — and then to do what it is told, in the order it is told.
 21/// </para>
 22/// <para>
 23/// A host instance is a view over one scope's context and is created per call. Everything durable lives in
 24/// <see cref="BpmnScopeMemory"/>, everything derived lives in the context's transient properties, and everything
 25/// ordering-related lives in the instance-wide <see cref="BpmnScopeDispatcher"/>.
 26/// </para>
 27/// </remarks>
 28internal sealed class BpmnScopeHost
 29{
 30    /// <summary>
 31    /// What this host promises it can do.
 32    /// </summary>
 33    /// <remarks>
 34    /// Each capability is a claim, honoured elsewhere in this module: subtree cancellation by
 35    /// <see cref="BpmnWorkTeardown"/>, scope signalling by <see cref="BpmnScopeSignal"/>, iteration scopes by the
 36    /// applier's per-instance variables, and <see cref="BpmnHostCapabilities.ScopeVariables"/> by
 37    /// <see cref="BpmnScopeVariables"/>. Defined in terms of <see cref="BpmnRuntimeCapabilities.Declared"/> — see its
 38    /// remarks for why the set is spelled out there rather than written as <c>BpmnHostCapabilities.Full</c>, and for
 39    /// why that type exists at all. The same set goes to <see cref="BpmnGraph.Build"/> and to every snapshot, which
 40    /// the port requires.
 41    /// </remarks>
 42    public const BpmnHostCapabilities Capabilities = BpmnRuntimeCapabilities.Declared;
 43
 44    /// <summary>
 45    /// The property key under which a nested scope's invocation correlation is carried on its own context.
 46    /// </summary>
 47    public const string InvocationCorrelationPropertyKey = "Bpmn:InvocationCorrelation";
 48
 49    private const string GraphTransientPropertyKey = "Bpmn:Graph";
 50
 51    // The interpreter is a pure function of its request: it holds no per-instance state, so one instance serves the
 52    // whole process. Creating one per evaluation would only re-register the built-in element behaviors.
 253    private static readonly BpmnInterpreter Interpreter = BpmnInterpreter.CreateDefault();
 54
 255    private static readonly object DispatcherKey = new();
 256    private static readonly IReadOnlyDictionary<string, string> NoCorrelation = new Dictionary<string, string>(StringCom
 57
 58    private readonly ActivityExecutionContext _context;
 59    private readonly BpmnProcess _process;
 60
 14261    private BpmnScopeHost(ActivityExecutionContext context)
 62    {
 14263        _context = context;
 14264        _process = (BpmnProcess)context.Activity;
 14265    }
 66
 67    /// <summary>Returns the host for the given BPMN scope context.</summary>
 14268    public static BpmnScopeHost For(ActivityExecutionContext context) => new(context);
 69
 70    /// <summary>The evaluation queue shared by every BPMN scope in this workflow instance.</summary>
 71    public static BpmnScopeDispatcher DispatcherOf(WorkflowExecutionContext context) =>
 18972        context.TransientProperties.GetOrAdd(DispatcherKey, () => new BpmnScopeDispatcher());
 73
 74    /// <summary>The built graph for this scope. Derived from the definition, the bound work and the capabilities, none 
 13575    public BpmnGraph Graph => _context.TransientProperties.GetOrAdd(GraphTransientPropertyKey, BuildGraph);
 76
 77    // --- The interpreter's four entry points ---------------------------------------------------------
 78
 79    /// <summary>The scope is beginning.</summary>
 3880    public ValueTask StartAsync() => EvaluateAsync(memory =>
 7681        Interpreter.Start(new BpmnStartRequest(Graph, memory.State, Snapshot(memory))));
 82
 83    /// <summary>A unit of work finished, reporting zero or more outcome names.</summary>
 9084    public ValueTask OnWorkCompletedAsync(ActivityExecutionContext childContext, object? result) => EvaluateAsync(memory
 9085    {
 9086        // Keyed on the child's own context id. A completion for work this scope no longer holds is absorbed rather
 9087        // than faulted: an interrupting boundary tears its host down while the host's work is still in flight, and a
 9088        // late completion for work that was torn down is an ordinary BPMN race.
 9089        if (memory.Work.FindByChildContextId(childContext.Id) is not { } record)
 190            return null;
 9091
 9092        // The completing work must ALREADY be gone from LiveWork when the interpreter is asked.
 8993        memory.Work.Remove(record);
 8994        memory.SaveWork();
 9095
 8996        var outcomeNames = result is Outcomes outcomes ? outcomes.Names : [];
 9097
 8998        return Interpreter.OnWorkCompleted(new BpmnWorkCompletedRequest(
 8999            Graph, memory.State, Snapshot(memory), record.BindingRef, record.Handle, outcomeNames, record.IterationId));
 90100    });
 101
 102    /// <summary>A nested scope this one invoked signalled outward.</summary>
 103    public ValueTask OnScopeSignalledAsync(BpmnScopeSignal signal, SignalContext signalContext)
 104    {
 6105        var sender = signalContext.SenderActivityExecutionContext;
 106
 107        // The channel delivers to the sender before walking its ancestors, and a scope never signals itself.
 6108        if (string.Equals(sender.Id, _context.Id, StringComparison.Ordinal))
 3109            return default;
 110
 111        // A scope signal is for the immediate enclosing scope, which is the one that started the sender's work. Any
 112        // other receiver lets it keep bubbling; that is also how an unrelated container in between composes.
 3113        if (BpmnScopeMemory.Load(_context).Work.FindByChildContextId(sender.Id) is not { } signalling)
 0114            return default;
 115
 3116        signalContext.StopPropagation();
 117
 3118        return EvaluateAsync(memory =>
 3119        {
 3120            // Unlike a completion, the signalling work stays in the ledger: an escalating activity keeps running, and
 3121            // removing it makes the interpreter believe it has already gone.
 3122            if (memory.Work.FindByHandle(signalling.Handle) is not { } record)
 0123                return null;
 3124
 3125            return Interpreter.OnWorkSignalled(new BpmnWorkSignalledRequest(
 3126                Graph, memory.State, Snapshot(memory), record.BindingRef, record.Handle, signal.Code, signal.Payload, re
 3127        });
 128    }
 129
 130    /// <summary>
 131    /// A unit of work failed. Rides the <see cref="FaultSignal"/> seam: handle the signal, ask the interpreter what
 132    /// BPMN makes of the fault, and claim it only when a catcher took it.
 133    /// </summary>
 134    /// <remarks>
 135    /// The disposition has to be decided before this handler returns, so the interpreter is asked inline; only the
 136    /// commands are applied through the dispatcher. A <c>Propagated</c> disposition is left strictly alone — no
 137    /// <c>StopPropagation</c>, nothing terminalized — so the fault reaches the enclosing scope, which is how BPMN
 138    /// error propagation crosses a scope boundary, or the incident strategy, which is how it surfaces at the root.
 139    /// </remarks>
 140    public async ValueTask OnWorkFaultedAsync(FaultSignal signal, SignalContext signalContext)
 141    {
 8142        var memory = BpmnScopeMemory.Load(_context);
 143
 8144        if (ResolveFaultedWork(memory, signal.FaultedContext) is not { } record)
 3145            return;
 146
 147        // As with a completion, the failed work must ALREADY be removed before the interpreter is asked.
 5148        memory.Work.Remove(record);
 5149        memory.SaveWork();
 150
 5151        var evaluation = Interpreter.OnWorkFaulted(new BpmnWorkFaultedRequest(
 5152            Graph, memory.State, Snapshot(memory), record.BindingRef, record.Handle, signal.Exception.Message));
 153
 5154        memory.State = evaluation.State.Prune();
 5155        memory.SaveState();
 156
 5157        if (evaluation.Disposition is not BpmnErrorDisposition.Caught)
 2158            return;
 159
 3160        signalContext.StopPropagation();
 161
 162        // A handler that claims a fault owns terminalizing the failed activity, and BPMN terminalizes the whole unit of
 163        // work rather than the one activity that threw: when the failure came from inside a nested scope, this scope's
 164        // failing work is that scope. Cancelling it recursively covers the activity that actually threw. The interprete
 165        // issues no teardown for failed work — it treats it as already terminal — so this is the host's own doing and
 166        // not a command out of order. RecoverFromFault stays the middleware's alone: it decrements every ancestor's
 167        // fault count, so a second call drives them negative.
 3168        if (BpmnWorkTeardown.FindContext(_context.WorkflowExecutionContext, record.ChildContextId) is { } failedWorkCont
 3169            await BpmnWorkTeardown.CancelSubtreeAsync(failedWorkContext, $"element '{record.ElementId}' failed");
 170
 6171        await DispatcherOf(_context.WorkflowExecutionContext).PostAsync(() => ApplyAsync(memory, evaluation));
 8172    }
 173
 174    // --- Plumbing ------------------------------------------------------------------------------------
 175
 176    private ValueTask EvaluateAsync(Func<BpmnScopeMemory, BpmnEvaluation?> evaluate) =>
 131177        DispatcherOf(_context.WorkflowExecutionContext).PostAsync(async () =>
 131178        {
 131179            var memory = BpmnScopeMemory.Load(_context);
 131180            var evaluation = evaluate(memory);
 131181
 131182            if (evaluation is null)
 1183                return;
 131184
 131185            // Persist the state before acting on the commands: a command applied against a state that was never
 131186            // recorded is how a crash produces work with no token behind it.
 130187            memory.State = evaluation.State.Prune();
 130188            memory.SaveState();
 131189
 130190            await ApplyAsync(memory, evaluation);
 260191        });
 192
 193    private async ValueTask ApplyAsync(BpmnScopeMemory memory, BpmnEvaluation evaluation)
 194    {
 133195        await new BpmnCommandApplier(_context, _process, memory).ApplyAsync(evaluation.Commands);
 196
 132197        switch (evaluation.Continuation)
 198        {
 199            case BpmnContinuation.Complete complete:
 200                // The scope completes because the interpreter said so, never because it ran out of children.
 30201                await _context.CompleteActivityAsync(new Outcomes(complete.Outcome));
 30202                break;
 203            case BpmnContinuation.Defer:
 204                break;
 205            case BpmnContinuation.Fault fault:
 1206                throw new BpmnScopeFaultException(fault.Code, fault.Message);
 207            default:
 0208                throw new NotSupportedException($"The BPMN continuation '{evaluation.Continuation.GetType().Name}' is no
 209        }
 131210    }
 211
 212    /// <summary>
 213    /// Finds the unit of work this scope started that the failing activity belongs to, walking outward from the
 214    /// failure.
 215    /// </summary>
 216    /// <remarks>
 217    /// A fault raised deep inside a nested scope is, to this scope, its own subprocess work failing. The nested scope
 218    /// sees the signal first and claims it if it has a catcher; if it does not, the signal arrives here and this walk
 219    /// is what turns "some activity failed" into "the work I started failed", which is exactly what BPMN error
 220    /// propagation across a scope boundary means.
 221    /// </remarks>
 222    private BpmnWorkRecord? ResolveFaultedWork(BpmnScopeMemory memory, ActivityExecutionContext faultedContext)
 223    {
 24224        for (var current = faultedContext; current is not null && !string.Equals(current.Id, _context.Id, StringComparis
 225        {
 9226            if (memory.Work.FindByChildContextId(current.Id) is { } record)
 5227                return record;
 228        }
 229
 3230        return null;
 231    }
 232
 233    private BpmnHostSnapshot Snapshot(BpmnScopeMemory memory)
 234    {
 135235        var invocationCorrelation = InvocationCorrelation;
 236
 135237        return new BpmnHostSnapshot(
 135238            ScopeInstanceId: _context.Id,
 135239            // A scope has an enclosing one exactly when another scope started it, which is what the carried
 135240            // correlation records. A root process has none, so an unhandled escalation is a documented no-op.
 135241            HasEnclosingScope: invocationCorrelation.Count > 0,
 135242            LiveWork: memory.Work.ToLiveWork(),
 135243            InvocationCorrelation: invocationCorrelation,
 135244            Variables: new BpmnScopeVariables(_context),
 135245            Capabilities: Capabilities);
 246    }
 247
 248    /// <summary>
 249    /// The correlation of the work that started this scope. It belongs to the scope and is fixed for its lifetime;
 250    /// a completing unit of work's correlation is never written here, because the event-subprocess start hint is read
 251    /// from this same dictionary.
 252    /// </summary>
 253    private IReadOnlyDictionary<string, string> InvocationCorrelation =>
 135254        BpmnScopeMemory.Read<Dictionary<string, string>>(_context, InvocationCorrelationPropertyKey) ?? NoCorrelation;
 255
 256    private BpmnGraph BuildGraph()
 257    {
 71258        var definition = _process.Process
 71259                         ?? throw new InvalidOperationException($"BPMN process activity '{_process.Id}' has no process d
 260
 261        // Every binding the definition declares, with the nested definition attached where the bound activity is
 262        // itself a BPMN scope. The graph validator reads that for an event subprocess body's start trigger.
 256263        var boundWork = BpmnBoundWork.Derive(definition, bindingRef => (_process.FindWorkActivity(bindingRef) as BpmnPro
 264
 71265        return BpmnGraph.Build(definition, boundWork, Capabilities);
 266    }
 267}