< Summary

Information
Class: Elsa.Bpmn.Activities.BpmnProcess
Assembly: Elsa.Bpmn
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn/Activities/BpmnProcess.cs
Line coverage
98%
Covered lines: 72
Uncovered lines: 1
Coverable lines: 73
Total lines: 311
Line coverage: 98.6%
Branch coverage
90%
Covered branches: 29
Total branches: 32
Branch coverage: 90.6%
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%
get_Process()100%11100%
get_IsRootScope()100%11100%
set_IsRootScope(...)100%11100%
get_WorkBindings()100%11100%
ScheduleChildrenAsync(...)100%11100%
Elsa.Workflows.ITrigger.GetTriggerPayloadsAsync(...)100%11100%
GetStartTriggerPayloadsAsync()87.5%8890.9%
AddStartTriggerPayload(...)95.83%2424100%
HasEnclosingBpmnScopeAsync()75%44100%
FindWorkActivity(...)50%22100%
OnWorkCompletedAsync(...)100%11100%
OnScopeSignalledAsync(...)100%11100%
OnWorkFaultedAsync(...)100%11100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn/Activities/BpmnProcess.cs

#LineLine coverage
 1using System.Runtime.CompilerServices;
 2using System.Text.Json.Serialization;
 3using System.Xml;
 4using Bpmn.Model;
 5using Elsa.Bpmn.Hosting;
 6using Elsa.Bpmn.Signals;
 7using Elsa.Extensions;
 8using Elsa.Scheduling;
 9using Elsa.Scheduling.Bookmarks;
 10using Elsa.Workflows;
 11using Elsa.Workflows.Activities;
 12using Elsa.Workflows.Attributes;
 13using Elsa.Workflows.Models;
 14using Elsa.Workflows.Runtime;
 15using Elsa.Workflows.Signals;
 16using Microsoft.Extensions.Logging;
 17
 18namespace Elsa.Bpmn.Activities;
 19
 20/// <summary>
 21/// Runs one BPMN process scope, driving the <c>Bpmn.Semantics</c> interpreter and applying what it returns onto this
 22/// activity's execution context.
 23/// </summary>
 24/// <remarks>
 25/// <para>
 26/// A scope owns its own execution state and its own record of the work it started, both held in
 27/// <see cref="ActivityExecutionContext.Properties"/>. A nested BPMN scope — an embedded subprocess, or an event
 28/// subprocess body — is another <see cref="BpmnProcess"/> bound as work, so the scope hierarchy the interpreter has
 29/// no view of is exactly the activity hierarchy Elsa already maintains.
 30/// </para>
 31/// <para>
 32/// Like every container, this one never auto-completes: it completes when the interpreter returns a <c>Complete</c>
 33/// continuation, and its outcome is what a conditional sequence flow in the enclosing scope selects on.
 34/// </para>
 35/// <para>
 36/// Composing this activity into a <c>Flowchart</c>: it completes with only the interpreter's outcome name (e.g.
 37/// <c>BpmnInterpreter.DoneOutcomeName</c>, and <c>CancelledOutcomeName</c> where relevant) — not with
 38/// <c>Outcomes.Default</c>, which an ordinary activity's null result also produces and which additionally matches a
 39/// null-port connection. A <c>Connection</c> built with the default/null-port shorthand will therefore never fire
 40/// from this activity; always target an explicit outcome port.
 41/// </para>
 42/// </remarks>
 43[Activity("Elsa", "BPMN", "Executes a BPMN process scope.")]
 44[System.ComponentModel.Browsable(false)]
 45public class BpmnProcess : Container, ITrigger
 46{
 47    /// <summary>
 48    /// The smallest timer-start interval this process will register.
 49    /// </summary>
 50    /// <remarks>
 51    /// A positive interval below this still rearms in a tight loop: <c>ScheduledRecurringTask.SetupTimer</c> in
 52    /// <c>Elsa.Scheduling</c> substitutes a 1&#160;ms delay for any non-positive delay it computes, and
 53    /// <see cref="Elsa.Scheduling.Options.SchedulingOptions.MinimumPastDueScheduleDelay"/> uses that same 1&#160;ms as
 54    /// its own default floor — so 1&#160;ms is not a round number picked here, it is the scheduler's own resolution.
 55    /// Anything asked for below it collapses to the same repeatedly-firing timer a zero or negative interval
 56    /// produces, which is the failure this floor exists to close. Set the floor any lower and a document can still
 57    /// spin the scheduler; set it higher and a legitimate short-interval timer would be refused for no reason the
 58    /// scheduler can back up.
 59    /// </remarks>
 160    private static readonly TimeSpan MinimumTimerInterval = TimeSpan.FromMilliseconds(1);
 61
 62    /// <inheritdoc />
 6963    public BpmnProcess([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
 64    {
 6965        OnSignalReceived<BpmnScopeSignal>(OnScopeSignalledAsync);
 6966        OnSignalReceived<FaultSignal>(OnWorkFaultedAsync);
 6967    }
 68
 69    /// <summary>
 70    /// The BPMN process definition this scope executes.
 71    /// </summary>
 16272    public BpmnProcessDefinition? Process { get; set; }
 73
 74    /// <summary>
 75    /// Whether this scope is the root BPMN process of its workflow, and may therefore register the start triggers its
 76    /// definition declares.
 77    /// </summary>
 78    /// <remarks>
 79    /// <para>
 80    /// Off unless something says otherwise, which is the answer every nested scope needs: the start events of a
 81    /// subprocess body, of an event subprocess body, and of a process composed into a <c>Flowchart</c> are internal
 82    /// to the graph around them, not ways into the workflow. Root position cannot be recovered from a published
 83    /// activity node — a node knows neither its parent nor how it was imported — so whoever builds the graph says so
 84    /// explicitly and everything that nests a scope leaves it alone.
 85    /// </para>
 86    /// <para>
 87    /// Backed by Elsa's own <see cref="Activity.CanStartWorkflow"/> rather than by a second flag, because
 88    /// <c>TriggerIndexer</c> gates registration on that one: two flags could disagree, and the disagreement would
 89    /// show up as a subprocess quietly registered as an entry point. Reading it here gives the BPMN meaning a name
 90    /// and one place to document it. This is the gate <see cref="Elsa.Workflows.Runtime.TriggerIndexer"/> reads
 91    /// before it ever asks this activity for trigger payloads — see <see cref="GetStartTriggerPayloadsAsync"/>,
 92    /// which re-checks nesting from the graph itself because this flag alone is not enough once a scope can be
 93    /// composed deeper after it is set.
 94    /// </para>
 95    /// </remarks>
 96    [JsonIgnore]
 97    public bool IsRootScope
 98    {
 1499        get => CanStartWorkflow;
 20100        set => CanStartWorkflow = value;
 101    }
 102
 103    /// <summary>
 104    /// Maps each binding ref the definition declares to the id of the activity in <see cref="Container.Activities"/>
 105    /// that runs it.
 106    /// </summary>
 107    /// <remarks>
 108    /// The interpreter never parses a binding ref — it compares and echoes it — so resolving one to an actual timer,
 109    /// work item, HTTP call or nested process is entirely the host's.
 110    /// </remarks>
 501111    public IDictionary<string, string> WorkBindings { get; set; } = new Dictionary<string, string>(StringComparer.Ordina
 112
 113    /// <inheritdoc />
 38114    protected override ValueTask ScheduleChildrenAsync(ActivityExecutionContext context) => BpmnScopeHost.For(context).S
 115
 116    /// <inheritdoc />
 17117    ValueTask<IEnumerable<object>> ITrigger.GetTriggerPayloadsAsync(TriggerIndexingContext context) => GetStartTriggerPa
 118
 119    /// <summary>
 120    /// Walks this process's own event-defined start events and returns one bookmark datum per resolvable message or
 121    /// signal name, and per recurring timer start. <see cref="Elsa.Workflows.Runtime.TriggerIndexer"/> only ever calls 
 122    /// whose <see cref="Activity.CanStartWorkflow"/> already reads <c>true</c> — see <see cref="IsRootScope"/> — but
 123    /// that flag is set once, by whoever last composed this scope, and cannot see composition that happens later.
 124    /// <see cref="HasEnclosingBpmnScopeAsync"/> re-derives entry-point status from the graph itself so a scope that
 125    /// is genuinely nested — directly, or via an intermediate <c>Flowchart</c> — never registers a trigger no matter
 126    /// what the flag says.
 127    /// </summary>
 128    /// <remarks>
 129    /// <para>
 130    /// Only <c>messageEventDefinition</c> and <c>signalEventDefinition</c> start events resolve to a payload here,
 131    /// and both resolve to the same <see cref="Elsa.Workflows.Runtime.Stimuli.EventStimulus"/> that
 132    /// <c>Event</c>/<c>PublishEvent</c> already key their own bookmarks on, keyed on the resolved name alone — the
 133    /// library correlates a message and a signal start the same way, so reusing the stimulus type an external
 134    /// publisher already speaks is what makes a <c>.bpmn</c> file portable rather than BPMN-specific.
 135    /// </para>
 136    /// <para>
 137    /// A <c>timerEventDefinition</c> start resolves only when it is a recurring schedule: an ISO-8601
 138    /// <c>&lt;timeCycle&gt;</c> interval registers through the same <see cref="TimerTriggerPayload"/>/
 139    /// <see cref="SchedulingStimulusNames.Timer"/> path <c>Elsa.Scheduling</c>'s own <c>Timer</c> activity uses, and a
 140    /// cron cycle through <see cref="CronTriggerPayload"/>/<see cref="SchedulingStimulusNames.Cron"/>. A one-shot
 141    /// <c>&lt;timeDate&gt;</c>/<c>&lt;timeDuration&gt;</c> start, and any other event definition, is not represented
 142    /// here at all: the reader already degraded it to a plain start event at import (see
 143    /// <c>BpmnActivityBindingFormat</c>'s sibling, the interchange reader), so there is nothing left to resolve.
 144    /// </para>
 145    /// <para>
 146    /// Each payload is wrapped in a <see cref="NamedTriggerPayload"/> naming its own stimulus, rather than relying on
 147    /// <see cref="TriggerIndexingContext.TriggerName"/>: that property is a single value shared by every payload of
 148    /// the trigger, so a process with both a message/signal start and a recurring timer start would otherwise have
 149    /// the last kind processed claim the name for every row, storing the earlier rows under a hash no publisher
 150    /// would ever compute.
 151    /// </para>
 152    /// <para>
 153    /// Two start events (or two event definitions on one start event) that resolve to the same stimulus name and
 154    /// value are collapsed to a single payload: <c>Elsa.Workflows.Runtime.StimulusSender</c> starts the workflow
 155    /// once per matched <see cref="Elsa.Workflows.Runtime.Entities.StoredTrigger"/> row, so two identical rows would
 156    /// start the workflow twice for one inbound stimulus. Distinct resolved names, and distinct stimulus kinds, are
 157    /// never collapsed into each other.
 158    /// </para>
 159    /// <para>
 160    /// A malformed <c>&lt;timeCycle&gt;</c> interval, and one that parses to a non-positive duration (e.g. <c>PT0S</c>
 161    /// or a negative duration — the scheduler treats a non-positive next execution time as "due immediately", so a
 162    /// recurring trigger on it would rearm continuously), is refused for its own start event only: the offending
 163    /// element is logged and skipped, and every other valid start event on this process still registers. Letting the
 164    /// exception propagate would not make the failure any louder — <c>TriggerIndexer.TryGetTriggerDataAsync</c>
 165    /// catches around the whole <see cref="ITrigger.GetTriggerPayloadsAsync"/> call and only logs a warning, so an
 166    /// unhandled exception here would silently discard every other start on the process, which is the defect this
 167    /// method exists to close.
 168    /// </para>
 169    /// </remarks>
 170    private async ValueTask<IEnumerable<object>> GetStartTriggerPayloadsAsync(TriggerIndexingContext context)
 171    {
 17172        if (Process is not { } process)
 0173            return [];
 174
 17175        if (await HasEnclosingBpmnScopeAsync(context))
 2176            return [];
 177
 15178        var logger = context.ExpressionExecutionContext.GetRequiredService<ILogger<BpmnProcess>>();
 15179        var payloads = new List<object>();
 15180        var registeredStimuli = new HashSet<(string StimulusName, string ResolvedName)>();
 181
 90182        foreach (var element in process.Elements.Where(element => string.Equals(element.ElementType, BpmnElementTypes.St
 183        {
 74184            foreach (var eventDefinition in element.EventDefinitions)
 22185                AddStartTriggerPayload(context, element, eventDefinition, registeredStimuli, payloads, logger);
 186        }
 187
 15188        return payloads;
 17189    }
 190
 191    private static void AddStartTriggerPayload(
 192        TriggerIndexingContext context,
 193        BpmnElement element,
 194        BpmnEventDefinition eventDefinition,
 195        ISet<(string StimulusName, string ResolvedName)> registeredStimuli,
 196        ICollection<object> payloads,
 197        ILogger logger)
 198    {
 22199        switch (eventDefinition.Type)
 200        {
 201            case BpmnEventDefinitionTypes.Message:
 202            case BpmnEventDefinitionTypes.Signal:
 14203                if (eventDefinition.Properties.TryGetValue(BpmnEventDefinitionProperties.Name, out var name)
 14204                    && !string.IsNullOrWhiteSpace(name)
 14205                    && registeredStimuli.Add((RuntimeStimulusNames.Event, name)))
 206                {
 13207                    payloads.Add(new NamedTriggerPayload(RuntimeStimulusNames.Event, context.GetEventStimulus(name)));
 208                }
 13209                break;
 210
 211            case BpmnEventDefinitionTypes.Timer:
 8212                if (eventDefinition.Properties.TryGetValue(BpmnEventDefinitionProperties.Interval, out var isoInterval))
 213                {
 214                    TimeSpan interval;
 215
 216                    try
 217                    {
 7218                        interval = XmlConvert.ToTimeSpan(isoInterval);
 6219                    }
 1220                    catch (Exception exception) when (exception is FormatException or OverflowException or ArgumentNullE
 221                    {
 1222                        logger.LogWarning(
 1223                            exception,
 1224                            "BPMN element '{ElementId}' declares the timer duration '{IsoInterval}', which is not an ISO
 1225                            + "Skipping this start event; the process's other start events still register.",
 1226                            element.ElementId,
 1227                            isoInterval);
 1228                        break;
 229                    }
 230
 6231                    if (interval <= TimeSpan.Zero)
 232                    {
 2233                        logger.LogWarning(
 2234                            "BPMN element '{ElementId}' declares the timer duration '{IsoInterval}', which resolves to a
 2235                            + "Skipping this start event; the process's other start events still register.",
 2236                            element.ElementId,
 2237                            isoInterval);
 2238                        break;
 239                    }
 240
 4241                    if (interval < MinimumTimerInterval)
 242                    {
 1243                        logger.LogWarning(
 1244                            "BPMN element '{ElementId}' declares the timer duration '{IsoInterval}', which resolves to {
 1245                            + "Skipping this start event; the process's other start events still register.",
 1246                            element.ElementId,
 1247                            isoInterval,
 1248                            interval,
 1249                            MinimumTimerInterval);
 1250                        break;
 251                    }
 252
 3253                    if (registeredStimuli.Add((SchedulingStimulusNames.Timer, interval.ToString())))
 3254                        payloads.Add(new NamedTriggerPayload(SchedulingStimulusNames.Timer, context.GetTimerTriggerStimu
 255                }
 1256                else if (eventDefinition.Properties.TryGetValue(BpmnEventDefinitionProperties.Cron, out var cron)
 1257                         && registeredStimuli.Add((SchedulingStimulusNames.Cron, cron)))
 258                {
 1259                    payloads.Add(new NamedTriggerPayload(SchedulingStimulusNames.Cron, new CronTriggerPayload(cron)));
 260                }
 261                break;
 262        }
 3263    }
 264
 265    /// <summary>
 266    /// Whether this scope sits inside another BPMN scope anywhere above it in the published workflow graph —
 267    /// directly, as a subprocess or event-subprocess body, or indirectly, through an intermediate <c>Flowchart</c>
 268    /// (D11 makes composing a <c>BpmnProcess</c> into a <c>Flowchart</c> a first-class shape).
 269    /// </summary>
 270    /// <remarks>
 271    /// <see cref="IsRootScope"/> is only ever set, never inferred, by whoever last constructs or composes this
 272    /// scope. A binder sets it once, on the one top-level scope it produces; nesting it deeper afterwards — for
 273    /// instance by placing that same activity inside a <c>Flowchart</c> that is itself bound as work under another
 274    /// <c>BpmnProcess</c> — leaves the flag untouched, because the composer doing the nesting is not the binder and
 275    /// has no reason to revisit a flag it never set. <see cref="Elsa.Bpmn.Hosting.BpmnCommandApplier"/> already refuses
 276    /// schedule a scope bound <i>directly</i> as another scope's work when that scope claims root position, but it
 277    /// only ever inspects work bound directly to the enclosing <c>BpmnProcess</c>; a scope reached through an
 278    /// intermediate <c>Flowchart</c> is invisible to that check. Re-deriving the answer from the whole workflow graph
 279    /// at indexing time — closer to the thing being protected, registration of a trigger nobody asked for — closes
 280    /// that gap without needing every composer of a <c>BpmnProcess</c> to remember to clear a flag it never set.
 281    /// </remarks>
 282    private async ValueTask<bool> HasEnclosingBpmnScopeAsync(TriggerIndexingContext context)
 283    {
 17284        var activityVisitor = context.ExpressionExecutionContext.GetRequiredService<IActivityVisitor>();
 17285        var root = await activityVisitor.VisitAsync(context.WorkflowIndexingContext.Workflow.Root, context.CancellationT
 20286        var node = ReferenceEquals(root.Activity, this) ? root : root.Descendants().FirstOrDefault(descendant => Referen
 287
 20288        return node is not null && node.Ancestors().Any(ancestor => ancestor.Activity is BpmnProcess);
 17289    }
 290
 291    /// <summary>
 292    /// The activity bound to the given binding ref, or <c>null</c> when the definition declares a binding this
 293    /// activity does not map.
 294    /// </summary>
 295    internal IActivity? FindWorkActivity(string bindingRef) =>
 392296        WorkBindings.TryGetValue(bindingRef, out var activityId)
 778297            ? Activities.FirstOrDefault(activity => string.Equals(activity.Id, activityId, StringComparison.Ordinal))
 392298            : null;
 299
 300    /// <summary>
 301    /// A unit of work completed. Named rather than a lambda, because completion callbacks are rehydrated by method name
 302    /// </summary>
 303    internal ValueTask OnWorkCompletedAsync(ActivityCompletedContext context) =>
 89304        BpmnScopeHost.For(context.TargetContext).OnWorkCompletedAsync(context.ChildContext, context.Result);
 305
 306    private ValueTask OnScopeSignalledAsync(BpmnScopeSignal signal, SignalContext context) =>
 6307        BpmnScopeHost.For(context.ReceiverActivityExecutionContext).OnScopeSignalledAsync(signal, context);
 308
 309    private ValueTask OnWorkFaultedAsync(FaultSignal signal, SignalContext context) =>
 8310        BpmnScopeHost.For(context.ReceiverActivityExecutionContext).OnWorkFaultedAsync(signal, context);
 311}