< 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: 76
Uncovered lines: 1
Coverable lines: 77
Total lines: 334
Line coverage: 98.7%
Branch coverage
91%
Covered branches: 31
Total branches: 34
Branch coverage: 91.1%
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()90%101092.85%
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 Bpmn.Semantics;
 6using Elsa.Bpmn.Hosting;
 7using Elsa.Bpmn.Signals;
 8using Elsa.Extensions;
 9using Elsa.Scheduling;
 10using Elsa.Scheduling.Bookmarks;
 11using Elsa.Workflows;
 12using Elsa.Workflows.Activities;
 13using Elsa.Workflows.Activities.Flowchart.Attributes;
 14using Elsa.Workflows.Attributes;
 15using Elsa.Workflows.Models;
 16using Elsa.Workflows.Runtime;
 17using Elsa.Workflows.Signals;
 18using Microsoft.Extensions.Logging;
 19
 20namespace Elsa.Bpmn.Activities;
 21
 22/// <summary>
 23/// Runs one BPMN process scope, driving the <c>Bpmn.Semantics</c> interpreter and applying what it returns onto this
 24/// activity's execution context.
 25/// </summary>
 26/// <remarks>
 27/// <para>
 28/// A scope owns its own execution state and its own record of the work it started, both held in
 29/// <see cref="ActivityExecutionContext.Properties"/>. A nested BPMN scope — an embedded subprocess, or an event
 30/// subprocess body — is another <see cref="BpmnProcess"/> bound as work, so the scope hierarchy the interpreter has
 31/// no view of is exactly the activity hierarchy Elsa already maintains.
 32/// </para>
 33/// <para>
 34/// Like every container, this one never auto-completes: it completes when the interpreter returns a <c>Complete</c>
 35/// continuation, and its outcome is what a conditional sequence flow in the enclosing scope selects on.
 36/// </para>
 37/// <para>
 38/// Composing this activity into a <c>Flowchart</c>: it completes with only the interpreter's outcome name —
 39/// <see cref="BpmnInterpreter.DoneOutcomeName"/> normally, or <see cref="BpmnInterpreter.CancelledOutcomeName"/>
 40/// when a cancel end event cancelled a transaction — never with <c>Outcomes.Default</c>, which an ordinary
 41/// activity's null result also produces and which additionally matches a null-port connection. Both outcomes are
 42/// declared flow ports, so a <c>Connection</c> targets one of them explicitly; the default/null-port shorthand will
 43/// never fire from this activity.
 44/// </para>
 45/// </remarks>
 46[FlowNode(BpmnInterpreter.DoneOutcomeName, BpmnInterpreter.CancelledOutcomeName)]
 47[Activity("Elsa", "BPMN", "Executes a BPMN process scope.")]
 48[System.ComponentModel.Browsable(false)]
 49public class BpmnProcess : Container, ITrigger
 50{
 51    /// <summary>
 52    /// The smallest timer-start interval this process will register.
 53    /// </summary>
 54    /// <remarks>
 55    /// A positive interval below this still rearms in a tight loop: <c>ScheduledRecurringTask.SetupTimer</c> in
 56    /// <c>Elsa.Scheduling</c> substitutes a 1&#160;ms delay for any non-positive delay it computes, and
 57    /// <see cref="Elsa.Scheduling.Options.SchedulingOptions.MinimumPastDueScheduleDelay"/> uses that same 1&#160;ms as
 58    /// its own default floor — so 1&#160;ms is not a round number picked here, it is the scheduler's own resolution.
 59    /// Anything asked for below it collapses to the same repeatedly-firing timer a zero or negative interval
 60    /// produces, which is the failure this floor exists to close. Set the floor any lower and a document can still
 61    /// spin the scheduler; set it higher and a legitimate short-interval timer would be refused for no reason the
 62    /// scheduler can back up.
 63    /// </remarks>
 164    private static readonly TimeSpan MinimumTimerInterval = TimeSpan.FromMilliseconds(1);
 65
 66    /// <inheritdoc />
 10867    public BpmnProcess([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
 68    {
 10869        OnSignalReceived<BpmnScopeSignal>(OnScopeSignalledAsync);
 10870        OnSignalReceived<FaultSignal>(OnWorkFaultedAsync);
 10871    }
 72
 73    /// <summary>
 74    /// The BPMN process definition this scope executes.
 75    /// </summary>
 27476    public BpmnProcessDefinition? Process { get; set; }
 77
 78    /// <summary>
 79    /// Whether this scope is the root BPMN process of its workflow, and may therefore register the start triggers its
 80    /// definition declares.
 81    /// </summary>
 82    /// <remarks>
 83    /// <para>
 84    /// Off unless something says otherwise, which is the answer every nested scope needs: the start events of a
 85    /// subprocess body, of an event subprocess body, and of a process composed into a <c>Flowchart</c> are internal
 86    /// to the graph around them, not ways into the workflow. Root position cannot be recovered from a published
 87    /// activity node — a node knows neither its parent nor how it was imported — so whoever builds the graph says so
 88    /// explicitly and everything that nests a scope leaves it alone.
 89    /// </para>
 90    /// <para>
 91    /// Backed by Elsa's own <see cref="Activity.CanStartWorkflow"/> rather than by a second flag, because
 92    /// <c>TriggerIndexer</c> gates registration on that one: two flags could disagree, and the disagreement would
 93    /// show up as a subprocess quietly registered as an entry point. Reading it here gives the BPMN meaning a name
 94    /// and one place to document it. This is the gate <see cref="Elsa.Workflows.Runtime.TriggerIndexer"/> reads
 95    /// before it ever asks this activity for trigger payloads — see <see cref="GetStartTriggerPayloadsAsync"/>,
 96    /// which re-checks nesting from the graph itself because this flag alone is not enough once a scope can be
 97    /// composed deeper after it is set.
 98    /// </para>
 99    /// </remarks>
 100    [JsonIgnore]
 101    public bool IsRootScope
 102    {
 23103        get => CanStartWorkflow;
 25104        set => CanStartWorkflow = value;
 105    }
 106
 107    /// <summary>
 108    /// Maps each binding ref the definition declares to the id of the activity in <see cref="Container.Activities"/>
 109    /// that runs it.
 110    /// </summary>
 111    /// <remarks>
 112    /// The interpreter never parses a binding ref — it compares and echoes it — so resolving one to an actual timer,
 113    /// work item, HTTP call or nested process is entirely the host's.
 114    /// </remarks>
 743115    public IDictionary<string, string> WorkBindings { get; set; } = new Dictionary<string, string>(StringComparer.Ordina
 116
 117    /// <inheritdoc />
 64118    protected override ValueTask ScheduleChildrenAsync(ActivityExecutionContext context) => BpmnScopeHost.For(context).S
 119
 120    /// <inheritdoc />
 22121    ValueTask<IEnumerable<object>> ITrigger.GetTriggerPayloadsAsync(TriggerIndexingContext context) => GetStartTriggerPa
 122
 123    /// <summary>
 124    /// Walks this process's own event-defined start events and returns one bookmark datum per resolvable message or
 125    /// signal name, and per recurring timer start. <see cref="Elsa.Workflows.Runtime.TriggerIndexer"/> only ever calls 
 126    /// whose <see cref="Activity.CanStartWorkflow"/> already reads <c>true</c> — see <see cref="IsRootScope"/> — but
 127    /// that flag is set once, by whoever last composed this scope, and cannot see composition that happens later.
 128    /// <see cref="HasEnclosingBpmnScopeAsync"/> re-derives entry-point status from the graph itself so a scope that
 129    /// is genuinely nested — directly, or via an intermediate <c>Flowchart</c> — never registers a trigger no matter
 130    /// what the flag says.
 131    /// </summary>
 132    /// <remarks>
 133    /// <para>
 134    /// Only <c>messageEventDefinition</c> and <c>signalEventDefinition</c> start events resolve to a payload here,
 135    /// and both resolve to the same <see cref="Elsa.Workflows.Runtime.Stimuli.EventStimulus"/> that
 136    /// <c>Event</c>/<c>PublishEvent</c> already key their own bookmarks on, keyed on the resolved name alone — the
 137    /// library correlates a message and a signal start the same way, so reusing the stimulus type an external
 138    /// publisher already speaks is what makes a <c>.bpmn</c> file portable rather than BPMN-specific.
 139    /// </para>
 140    /// <para>
 141    /// A <c>timerEventDefinition</c> start resolves only when it is a recurring schedule: an ISO-8601
 142    /// <c>&lt;timeCycle&gt;</c> interval registers through the same <see cref="TimerTriggerPayload"/>/
 143    /// <see cref="SchedulingStimulusNames.Timer"/> path <c>Elsa.Scheduling</c>'s own <c>Timer</c> activity uses, and a
 144    /// cron cycle through <see cref="CronTriggerPayload"/>/<see cref="SchedulingStimulusNames.Cron"/>. A one-shot
 145    /// <c>&lt;timeDate&gt;</c>/<c>&lt;timeDuration&gt;</c> start, and any other event definition, is not represented
 146    /// here at all: the reader already degraded it to a plain start event at import (see
 147    /// <c>BpmnActivityBindingFormat</c>'s sibling, the interchange reader), so there is nothing left to resolve.
 148    /// </para>
 149    /// <para>
 150    /// A root scope none of whose start events carries an event definition — plain start events only, or no start
 151    /// event at all — is started directly, through the workflow execution API, never by a stimulus. It has nothing to r
 152    /// deliberately, so it sets <see cref="TriggerIndexingContext.RegistersNoTriggers"/> and the indexer stores no row
 153    /// for it. Without that declaration the indexer stores a <c>null</c>-payload placeholder row, which
 154    /// <c>ValidateWorkflowRequestHandler</c> reports as a trigger without a payload and which therefore refuses
 155    /// publication. The declaration is made in that case only. A start event that does carry an event definition but
 156    /// resolves to nothing here — a blank name, or a timer refused below — is a declared start that failed, not a plain
 157    /// start, so it still leaves the placeholder that validation reports instead of publishing as though it had never
 158    /// been declared. A nested scope returns before this check and is left exactly as it was.
 159    /// </para>
 160    /// <para>
 161    /// Each payload is wrapped in a <see cref="NamedTriggerPayload"/> naming its own stimulus, rather than relying on
 162    /// <see cref="TriggerIndexingContext.TriggerName"/>: that property is a single value shared by every payload of
 163    /// the trigger, so a process with both a message/signal start and a recurring timer start would otherwise have
 164    /// the last kind processed claim the name for every row, storing the earlier rows under a hash no publisher
 165    /// would ever compute.
 166    /// </para>
 167    /// <para>
 168    /// Two start events (or two event definitions on one start event) that resolve to the same stimulus name and
 169    /// value are collapsed to a single payload: <c>Elsa.Workflows.Runtime.StimulusSender</c> starts the workflow
 170    /// once per matched <see cref="Elsa.Workflows.Runtime.Entities.StoredTrigger"/> row, so two identical rows would
 171    /// start the workflow twice for one inbound stimulus. Distinct resolved names, and distinct stimulus kinds, are
 172    /// never collapsed into each other.
 173    /// </para>
 174    /// <para>
 175    /// A malformed <c>&lt;timeCycle&gt;</c> interval, and one that parses to a non-positive duration (e.g. <c>PT0S</c>
 176    /// or a negative duration — the scheduler treats a non-positive next execution time as "due immediately", so a
 177    /// recurring trigger on it would rearm continuously), is refused for its own start event only: the offending
 178    /// element is logged and skipped, and every other valid start event on this process still registers. Letting the
 179    /// exception propagate would not make the failure any louder — <c>TriggerIndexer.TryGetTriggerDataAsync</c>
 180    /// catches around the whole <see cref="ITrigger.GetTriggerPayloadsAsync"/> call and only logs a warning, so an
 181    /// unhandled exception here would silently discard every other start on the process, which is the defect this
 182    /// method exists to close.
 183    /// </para>
 184    /// </remarks>
 185    private async ValueTask<IEnumerable<object>> GetStartTriggerPayloadsAsync(TriggerIndexingContext context)
 186    {
 22187        if (Process is not { } process)
 0188            return [];
 189
 22190        if (await HasEnclosingBpmnScopeAsync(context))
 3191            return [];
 192
 58193        var startEvents = process.Elements.Where(element => string.Equals(element.ElementType, BpmnElementTypes.StartEve
 194
 39195        if (startEvents.All(element => element.EventDefinitions.Count == 0))
 196        {
 1197            context.RegistersNoTriggers = true;
 1198            return [];
 199        }
 200
 18201        var logger = context.ExpressionExecutionContext.GetRequiredService<ILogger<BpmnProcess>>();
 18202        var payloads = new List<object>();
 18203        var registeredStimuli = new HashSet<(string StimulusName, string ResolvedName)>();
 204
 74205        foreach (var element in startEvents)
 206        {
 88207            foreach (var eventDefinition in element.EventDefinitions)
 25208                AddStartTriggerPayload(context, element, eventDefinition, registeredStimuli, payloads, logger);
 209        }
 210
 18211        return payloads;
 22212    }
 213
 214    private static void AddStartTriggerPayload(
 215        TriggerIndexingContext context,
 216        BpmnElement element,
 217        BpmnEventDefinition eventDefinition,
 218        ISet<(string StimulusName, string ResolvedName)> registeredStimuli,
 219        ICollection<object> payloads,
 220        ILogger logger)
 221    {
 25222        switch (eventDefinition.Type)
 223        {
 224            case BpmnEventDefinitionTypes.Message:
 225            case BpmnEventDefinitionTypes.Signal:
 16226                if (eventDefinition.Properties.TryGetValue(BpmnEventDefinitionProperties.Name, out var name)
 16227                    && !string.IsNullOrWhiteSpace(name)
 16228                    && registeredStimuli.Add((RuntimeStimulusNames.Event, name)))
 229                {
 15230                    payloads.Add(new NamedTriggerPayload(RuntimeStimulusNames.Event, context.GetEventStimulus(name)));
 231                }
 15232                break;
 233
 234            case BpmnEventDefinitionTypes.Timer:
 9235                if (eventDefinition.Properties.TryGetValue(BpmnEventDefinitionProperties.Interval, out var isoInterval))
 236                {
 237                    TimeSpan interval;
 238
 239                    try
 240                    {
 8241                        interval = XmlConvert.ToTimeSpan(isoInterval);
 6242                    }
 2243                    catch (Exception exception) when (exception is FormatException or OverflowException or ArgumentNullE
 244                    {
 2245                        logger.LogWarning(
 2246                            exception,
 2247                            "BPMN element '{ElementId}' declares the timer duration '{IsoInterval}', which is not an ISO
 2248                            + "Skipping this start event; the process's other start events still register.",
 2249                            element.ElementId,
 2250                            isoInterval);
 2251                        break;
 252                    }
 253
 6254                    if (interval <= TimeSpan.Zero)
 255                    {
 2256                        logger.LogWarning(
 2257                            "BPMN element '{ElementId}' declares the timer duration '{IsoInterval}', which resolves to a
 2258                            + "Skipping this start event; the process's other start events still register.",
 2259                            element.ElementId,
 2260                            isoInterval);
 2261                        break;
 262                    }
 263
 4264                    if (interval < MinimumTimerInterval)
 265                    {
 1266                        logger.LogWarning(
 1267                            "BPMN element '{ElementId}' declares the timer duration '{IsoInterval}', which resolves to {
 1268                            + "Skipping this start event; the process's other start events still register.",
 1269                            element.ElementId,
 1270                            isoInterval,
 1271                            interval,
 1272                            MinimumTimerInterval);
 1273                        break;
 274                    }
 275
 3276                    if (registeredStimuli.Add((SchedulingStimulusNames.Timer, interval.ToString())))
 3277                        payloads.Add(new NamedTriggerPayload(SchedulingStimulusNames.Timer, context.GetTimerTriggerStimu
 278                }
 1279                else if (eventDefinition.Properties.TryGetValue(BpmnEventDefinitionProperties.Cron, out var cron)
 1280                         && registeredStimuli.Add((SchedulingStimulusNames.Cron, cron)))
 281                {
 1282                    payloads.Add(new NamedTriggerPayload(SchedulingStimulusNames.Cron, new CronTriggerPayload(cron)));
 283                }
 284                break;
 285        }
 4286    }
 287
 288    /// <summary>
 289    /// Whether this scope sits inside another BPMN scope anywhere above it in the published workflow graph —
 290    /// directly, as a subprocess or event-subprocess body, or indirectly, through an intermediate <c>Flowchart</c>
 291    /// (D11 makes composing a <c>BpmnProcess</c> into a <c>Flowchart</c> a first-class shape).
 292    /// </summary>
 293    /// <remarks>
 294    /// <see cref="IsRootScope"/> is only ever set, never inferred, by whoever last constructs or composes this
 295    /// scope. A binder sets it once, on the one top-level scope it produces; nesting it deeper afterwards — for
 296    /// instance by placing that same activity inside a <c>Flowchart</c> that is itself bound as work under another
 297    /// <c>BpmnProcess</c> — leaves the flag untouched, because the composer doing the nesting is not the binder and
 298    /// has no reason to revisit a flag it never set. <see cref="Elsa.Bpmn.Hosting.BpmnCommandApplier"/> already refuses
 299    /// schedule a scope bound <i>directly</i> as another scope's work when that scope claims root position, but it
 300    /// only ever inspects work bound directly to the enclosing <c>BpmnProcess</c>; a scope reached through an
 301    /// intermediate <c>Flowchart</c> is invisible to that check. Re-deriving the answer from the whole workflow graph
 302    /// at indexing time — closer to the thing being protected, registration of a trigger nobody asked for — closes
 303    /// that gap without needing every composer of a <c>BpmnProcess</c> to remember to clear a flag it never set.
 304    /// </remarks>
 305    private async ValueTask<bool> HasEnclosingBpmnScopeAsync(TriggerIndexingContext context)
 306    {
 22307        var activityVisitor = context.ExpressionExecutionContext.GetRequiredService<IActivityVisitor>();
 22308        var root = await activityVisitor.VisitAsync(context.WorkflowIndexingContext.Workflow.Root, context.CancellationT
 26309        var node = ReferenceEquals(root.Activity, this) ? root : root.Descendants().FirstOrDefault(descendant => Referen
 310
 26311        return node is not null && node.Ancestors().Any(ancestor => ancestor.Activity is BpmnProcess);
 22312    }
 313
 314    /// <summary>
 315    /// The activity bound to the given binding ref, or <c>null</c> when the definition declares a binding this
 316    /// activity does not map.
 317    /// </summary>
 318    internal IActivity? FindWorkActivity(string bindingRef) =>
 562319        WorkBindings.TryGetValue(bindingRef, out var activityId)
 1067320            ? Activities.FirstOrDefault(activity => string.Equals(activity.Id, activityId, StringComparison.Ordinal))
 562321            : null;
 322
 323    /// <summary>
 324    /// A unit of work completed. Named rather than a lambda, because completion callbacks are rehydrated by method name
 325    /// </summary>
 326    internal ValueTask OnWorkCompletedAsync(ActivityCompletedContext context) =>
 124327        BpmnScopeHost.For(context.TargetContext).OnWorkCompletedAsync(context.ChildContext, context.Result);
 328
 329    private ValueTask OnScopeSignalledAsync(BpmnScopeSignal signal, SignalContext context) =>
 8330        BpmnScopeHost.For(context.ReceiverActivityExecutionContext).OnScopeSignalledAsync(signal, context);
 331
 332    private ValueTask OnWorkFaultedAsync(FaultSignal signal, SignalContext context) =>
 15333        BpmnScopeHost.For(context.ReceiverActivityExecutionContext).OnWorkFaultedAsync(signal, context);
 334}