elsa-core/src/modules/Elsa.Bpmn/Hosting/BpmnScopeHost.cs
Sipke Schoorstra 952dfa05ff
feat(bpmn): project interpreter diagnostics onto the scope's execution log (#8058)
* feat(bpmn): project interpreter diagnostics onto the scope's execution log

Under Option A only bound work carries an activity id, so gateways, events and
flows had no per-element trace in the journal. BpmnScopeHost now projects each
new BpmnExecutionState.Diagnostics entry onto the scope's own execution log
before Prune() runs, keyed by element id, with a persisted high-water mark so
a resumed scope never re-emits one. The scope's own start and completion stay
out, since they are already journaled as the activity's own lifecycle. Event
names and the payload shape are documented as a public compatibility surface
for elsa-studio#1000 to mirror.

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

* fix(bpmn): project element and flow diagnostics dropped by the FlowId exclusion

The diagnostics exclusion keyed on Kind == TokenEmitted && FlowId is null/empty
also dropped an error or cancel boundary's own token emission, since a
boundary fires without an inbound flow. Narrow the rule to skip only
diagnostics that name neither an element nor a flow -- the scope's own
terminal Completed summary -- so every diagnostic keyed on an element or a
flow, including a start event's and a boundary's, is projected.

Also make DiagnosticSequence resilient: TryParse instead of Parse, logging a
warning and skipping projection for an id that doesn't match diag:N rather
than faulting the evaluation. Add a reflection-based test that keeps
BpmnDiagnosticEventNames in lockstep with BpmnDiagnosticKind, and record the
diagnostics volume measurement in the wiki.

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

* fix(bpmn): require exact diag:N ids and seed the diagnostics cursor from prior state

Reject any diagnostic id that is not the exact "diag:" prefix followed by a
non-negative integer, so a malformed id can no longer poison the durable
projection cursor and cause later, genuinely valid, lower-sequence
diagnostics to be skipped forever.

Also seed a missing cursor from the highest valid sequence in the scope's
prior persisted state instead of treating it as zero, so a scope persisted
before diagnostics projection existed does not replay every retained
historical diagnostic as new on its next evaluation.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:01:06 -07:00

388 lines
20 KiB
C#

using System.Globalization;
using Bpmn.Model;
using Bpmn.Model.State;
using Bpmn.Semantics;
using Elsa.Bpmn.Activities;
using Elsa.Bpmn.Exceptions;
using Elsa.Bpmn.Signals;
using Elsa.Extensions;
using Elsa.Workflows;
using Elsa.Workflows.Activities.Flowchart.Models;
using Elsa.Workflows.Signals;
using Microsoft.Extensions.Logging;
namespace Elsa.Bpmn.Hosting;
/// <summary>
/// The host side of the <c>Bpmn.Semantics</c> port for one BPMN scope: it feeds the interpreter's four entry points
/// and applies what comes back onto the scope's <see cref="ActivityExecutionContext"/>.
/// </summary>
/// <remarks>
/// <para>
/// Every entry point is synchronous and returns a value; the interpreter never calls back. The host's job is to say
/// what its world looks like — a snapshot — and then to do what it is told, in the order it is told.
/// </para>
/// <para>
/// A host instance is a view over one scope's context and is created per call. Everything durable lives in
/// <see cref="BpmnScopeMemory"/>, everything derived lives in the context's transient properties, and everything
/// ordering-related lives in the instance-wide <see cref="BpmnScopeDispatcher"/>.
/// </para>
/// </remarks>
internal sealed class BpmnScopeHost
{
/// <summary>
/// What this host promises it can do.
/// </summary>
/// <remarks>
/// Each capability is a claim, honoured elsewhere in this module: subtree cancellation by
/// <see cref="BpmnWorkTeardown"/>, scope signalling by <see cref="BpmnScopeSignal"/>, iteration scopes by the
/// applier's per-instance variables, and <see cref="BpmnHostCapabilities.ScopeVariables"/> by
/// <see cref="BpmnScopeVariables"/>. Defined in terms of <see cref="BpmnRuntimeCapabilities.Declared"/> — see its
/// remarks for why the set is spelled out there rather than written as <c>BpmnHostCapabilities.Full</c>, and for
/// why that type exists at all. The same set goes to <see cref="BpmnGraph.Build"/> and to every snapshot, which
/// the port requires.
/// </remarks>
public const BpmnHostCapabilities Capabilities = BpmnRuntimeCapabilities.Declared;
/// <summary>
/// The property key under which a nested scope's invocation correlation is carried on its own context.
/// </summary>
public const string InvocationCorrelationPropertyKey = "Bpmn:InvocationCorrelation";
private const string GraphTransientPropertyKey = "Bpmn:Graph";
// The interpreter is a pure function of its request: it holds no per-instance state, so one instance serves the
// whole process. Creating one per evaluation would only re-register the built-in element behaviors.
private static readonly BpmnInterpreter Interpreter = BpmnInterpreter.CreateDefault();
private static readonly object DispatcherKey = new();
private static readonly IReadOnlyDictionary<string, string> NoCorrelation = new Dictionary<string, string>(StringComparer.Ordinal);
private readonly ActivityExecutionContext _context;
private readonly BpmnProcess _process;
private BpmnScopeHost(ActivityExecutionContext context)
{
_context = context;
_process = (BpmnProcess)context.Activity;
}
/// <summary>Returns the host for the given BPMN scope context.</summary>
public static BpmnScopeHost For(ActivityExecutionContext context) => new(context);
/// <summary>The evaluation queue shared by every BPMN scope in this workflow instance.</summary>
public static BpmnScopeDispatcher DispatcherOf(WorkflowExecutionContext context) =>
context.TransientProperties.GetOrAdd(DispatcherKey, () => new BpmnScopeDispatcher());
/// <summary>The built graph for this scope. Derived from the definition, the bound work and the capabilities, none of which vary per instance.</summary>
public BpmnGraph Graph => _context.TransientProperties.GetOrAdd(GraphTransientPropertyKey, BuildGraph);
// --- The interpreter's four entry points ---------------------------------------------------------
/// <summary>The scope is beginning.</summary>
public ValueTask StartAsync() => EvaluateAsync(memory =>
Interpreter.Start(new BpmnStartRequest(Graph, memory.State, Snapshot(memory))));
/// <summary>A unit of work finished, reporting zero or more outcome names.</summary>
public ValueTask OnWorkCompletedAsync(ActivityExecutionContext childContext, object? result) => EvaluateAsync(memory =>
{
// Keyed on the child's own context id. A completion for work this scope no longer holds is absorbed rather
// than faulted: an interrupting boundary tears its host down while the host's work is still in flight, and a
// late completion for work that was torn down is an ordinary BPMN race.
if (memory.Work.FindByChildContextId(childContext.Id) is not { } record)
return null;
// The completing work must ALREADY be gone from LiveWork when the interpreter is asked.
memory.Work.Remove(record);
memory.SaveWork();
var outcomeNames = result is Outcomes outcomes ? outcomes.Names : [];
return Interpreter.OnWorkCompleted(new BpmnWorkCompletedRequest(
Graph, memory.State, Snapshot(memory), record.BindingRef, record.Handle, outcomeNames, record.IterationId));
});
/// <summary>A nested scope this one invoked signalled outward.</summary>
public ValueTask OnScopeSignalledAsync(BpmnScopeSignal signal, SignalContext signalContext)
{
var sender = signalContext.SenderActivityExecutionContext;
// The channel delivers to the sender before walking its ancestors, and a scope never signals itself.
if (string.Equals(sender.Id, _context.Id, StringComparison.Ordinal))
return default;
// A scope signal is for the immediate enclosing scope, which is the one that started the sender's work. Any
// other receiver lets it keep bubbling; that is also how an unrelated container in between composes.
if (BpmnScopeMemory.Load(_context).Work.FindByChildContextId(sender.Id) is not { } signalling)
return default;
signalContext.StopPropagation();
return EvaluateAsync(memory =>
{
// Unlike a completion, the signalling work stays in the ledger: an escalating activity keeps running, and
// removing it makes the interpreter believe it has already gone.
if (memory.Work.FindByHandle(signalling.Handle) is not { } record)
return null;
return Interpreter.OnWorkSignalled(new BpmnWorkSignalledRequest(
Graph, memory.State, Snapshot(memory), record.BindingRef, record.Handle, signal.Code, signal.Payload, record.IterationId));
});
}
/// <summary>
/// A unit of work failed. Rides the <see cref="FaultSignal"/> seam: handle the signal, ask the interpreter what
/// BPMN makes of the fault, and claim it only when a catcher took it.
/// </summary>
/// <remarks>
/// The disposition has to be decided before this handler returns, so the interpreter is asked inline; only the
/// commands are applied through the dispatcher. A <c>Propagated</c> disposition is left strictly alone — no
/// <c>StopPropagation</c>, nothing terminalized — so the fault reaches the enclosing scope, which is how BPMN
/// error propagation crosses a scope boundary, or the incident strategy, which is how it surfaces at the root.
/// </remarks>
public async ValueTask OnWorkFaultedAsync(FaultSignal signal, SignalContext signalContext)
{
var memory = BpmnScopeMemory.Load(_context);
if (ResolveFaultedWork(memory, signal.FaultedContext) is not { } record)
return;
// As with a completion, the failed work must ALREADY be removed before the interpreter is asked.
memory.Work.Remove(record);
memory.SaveWork();
var evaluation = Interpreter.OnWorkFaulted(new BpmnWorkFaultedRequest(
Graph, memory.State, Snapshot(memory), record.BindingRef, record.Handle, signal.Exception.Message));
ProjectDiagnostics(evaluation.State, memory.State);
memory.State = evaluation.State.Prune();
memory.SaveState();
if (evaluation.Disposition is not BpmnErrorDisposition.Caught)
return;
signalContext.StopPropagation();
// A handler that claims a fault owns terminalizing the failed activity, and BPMN terminalizes the whole unit of
// work rather than the one activity that threw: when the failure came from inside a nested scope, this scope's
// failing work is that scope. Cancelling it recursively covers the activity that actually threw. The interpreter
// issues no teardown for failed work — it treats it as already terminal — so this is the host's own doing and
// not a command out of order. RecoverFromFault stays the middleware's alone: it decrements every ancestor's
// fault count, so a second call drives them negative.
if (BpmnWorkTeardown.FindContext(_context.WorkflowExecutionContext, record.ChildContextId) is { } failedWorkContext)
await BpmnWorkTeardown.CancelSubtreeAsync(failedWorkContext, $"element '{record.ElementId}' failed");
await DispatcherOf(_context.WorkflowExecutionContext).PostAsync(() => ApplyAsync(memory, evaluation));
}
// --- Plumbing ------------------------------------------------------------------------------------
private ValueTask EvaluateAsync(Func<BpmnScopeMemory, BpmnEvaluation?> evaluate) =>
DispatcherOf(_context.WorkflowExecutionContext).PostAsync(async () =>
{
var memory = BpmnScopeMemory.Load(_context);
var evaluation = evaluate(memory);
if (evaluation is null)
return;
// Diagnostics are audit-only and capped: projecting from persisted state later would lose whatever
// Prune() already dropped, so this runs on the evaluation's own state, before pruning. memory.State is
// still what was loaded before this evaluation ran -- the prior state -- since it is not overwritten
// until after this call.
ProjectDiagnostics(evaluation.State, memory.State);
// Persist the state before acting on the commands: a command applied against a state that was never
// recorded is how a crash produces work with no token behind it.
memory.State = evaluation.State.Prune();
memory.SaveState();
await ApplyAsync(memory, evaluation);
});
private async ValueTask ApplyAsync(BpmnScopeMemory memory, BpmnEvaluation evaluation)
{
await new BpmnCommandApplier(_context, _process, memory).ApplyAsync(evaluation.Commands);
switch (evaluation.Continuation)
{
case BpmnContinuation.Complete complete:
// The scope completes because the interpreter said so, never because it ran out of children.
await _context.CompleteActivityAsync(new Outcomes(complete.Outcome));
break;
case BpmnContinuation.Defer:
break;
case BpmnContinuation.Fault fault:
throw new BpmnScopeFaultException(fault.Code, fault.Message);
default:
throw new NotSupportedException($"The BPMN continuation '{evaluation.Continuation.GetType().Name}' is not supported by this host.");
}
}
/// <summary>
/// Projects every diagnostic the interpreter has appended since the last evaluation onto this scope's own
/// execution log, keyed by element id. Under Option A only bound work has an activity id, so a gateway, an
/// intermediate event or a sequence flow has nothing else in the journal to say where a token went; this is
/// write-only and never read back by the interpreter or this host.
/// </summary>
/// <remarks>
/// Runs on <see cref="_context"/> — this scope's own context — and never a child's: the diagnostic describes
/// this scope's decision about a child, and the child may already be torn down by the time this runs. Called
/// with the evaluation's own <see cref="BpmnEvaluation.State"/>, before <c>Prune()</c> caps
/// <see cref="BpmnExecutionState.Diagnostics"/> at 200 entries, because projecting from what was actually
/// persisted would lose whatever pruning already dropped. The last diagnostic id it has projected is kept in
/// <see cref="BpmnScopeMemory.DiagnosticsCursorPropertyKey"/> so a resumed scope does not re-emit one a
/// previous evaluation already turned into a journal entry.
/// </remarks>
/// <param name="state">The evaluation's own state, not yet pruned.</param>
/// <param name="priorState">
/// The state this scope had persisted before this evaluation ran, or <c>null</c> for a scope that has never
/// been evaluated before. When the cursor property is absent -- a scope persisted before diagnostics projection
/// existed -- its diagnostics are already accounted for, not new: the cursor is seeded from the highest valid
/// sequence among <paramref name="priorState"/>'s own diagnostics before anything is projected, so only what
/// this evaluation produced gets journaled. A genuinely new scope has no prior state and still starts at zero.
/// </param>
private void ProjectDiagnostics(BpmnExecutionState state, BpmnExecutionState? priorState)
{
var storedCursor = BpmnScopeMemory.Read<BpmnDiagnosticsCursor>(_context, BpmnScopeMemory.DiagnosticsCursorPropertyKey)?.LastSequence;
var lastProjectedSequence = storedCursor ?? SeedCursorFrom(priorState);
var highWaterMark = lastProjectedSequence;
foreach (var diagnostic in state.Diagnostics)
{
if (!TryGetDiagnosticSequence(diagnostic.DiagnosticId, out var sequence))
{
_context.GetRequiredService<ILogger<BpmnScopeHost>>()
.LogWarning("BPMN diagnostic id '{DiagnosticId}' is not in the expected 'diag:N' format and was skipped for projection.", diagnostic.DiagnosticId);
continue;
}
if (sequence <= lastProjectedSequence)
continue;
highWaterMark = Math.Max(highWaterMark, sequence);
// Only a diagnostic that names neither an element nor a flow is scope-level and already journaled as the
// activity's own lifecycle: the terminal "Completed" summary. Everything else -- including a start
// event's own token emission, and a boundary event's token emission when it has no inbound flow (an
// error or cancel boundary fires without one) -- names an element or a flow and is projected.
if (string.IsNullOrEmpty(diagnostic.ElementId) && string.IsNullOrEmpty(diagnostic.FlowId))
continue;
var payload = new BpmnDiagnosticLogPayload(
diagnostic.DiagnosticId,
diagnostic.ElementId,
diagnostic.FlowId,
diagnostic.TokenId,
diagnostic.Kind.ToString(),
diagnostic.Details);
_context.AddExecutionLogEntry(diagnostic.Kind.ToString(), diagnostic.Message, BpmnDiagnosticEventNames.Source, payload);
}
if (highWaterMark != lastProjectedSequence)
BpmnScopeMemory.Write(_context, BpmnScopeMemory.DiagnosticsCursorPropertyKey, new BpmnDiagnosticsCursor(highWaterMark));
}
/// <summary>
/// The starting cursor for a scope that has no <see cref="BpmnScopeMemory.DiagnosticsCursorPropertyKey"/> yet:
/// the highest valid sequence already present in <paramref name="priorState"/>'s diagnostics, or zero when there
/// is no prior state at all. A scope persisted before diagnostics projection existed has diagnostics in its
/// state but no cursor; treating that absence as zero would make its next evaluation journal every one of those
/// already-historical diagnostics as if they were new.
/// </summary>
private static int SeedCursorFrom(BpmnExecutionState? priorState)
{
var highest = 0;
if (priorState is null)
return highest;
foreach (var diagnostic in priorState.Diagnostics)
{
if (TryGetDiagnosticSequence(diagnostic.DiagnosticId, out var sequence) && sequence > highest)
highest = sequence;
}
return highest;
}
/// <summary>
/// The numeric ordinal in a diagnostic id (<c>diag:N</c>) — a pure function of the interpreter's own
/// mutation-order sequence, so it sorts the same as arrival order. Never throws: an id that does not match the
/// expected format fails to parse rather than faulting the evaluation, since this runs on every evaluation.
/// </summary>
/// <remarks>
/// Requires the exact ordinal prefix <c>diag:</c> and a non-negative integer suffix with no leading sign, digit
/// grouping or surrounding whitespace: a malformed id that happened to parse as a large number would poison the
/// durable cursor and silently drop every later, genuinely valid, lower-sequence diagnostic forever.
/// </remarks>
internal static bool TryGetDiagnosticSequence(string diagnosticId, out int sequence)
{
const string prefix = "diag:";
if (diagnosticId.StartsWith(prefix, StringComparison.Ordinal))
return int.TryParse(diagnosticId.AsSpan(prefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out sequence);
sequence = 0;
return false;
}
/// <summary>
/// Finds the unit of work this scope started that the failing activity belongs to, walking outward from the
/// failure.
/// </summary>
/// <remarks>
/// A fault raised deep inside a nested scope is, to this scope, its own subprocess work failing. The nested scope
/// sees the signal first and claims it if it has a catcher; if it does not, the signal arrives here and this walk
/// is what turns "some activity failed" into "the work I started failed", which is exactly what BPMN error
/// propagation across a scope boundary means.
/// </remarks>
private BpmnWorkRecord? ResolveFaultedWork(BpmnScopeMemory memory, ActivityExecutionContext faultedContext)
{
for (var current = faultedContext; current is not null && !string.Equals(current.Id, _context.Id, StringComparison.Ordinal); current = current.ParentActivityExecutionContext)
{
if (memory.Work.FindByChildContextId(current.Id) is { } record)
return record;
}
return null;
}
private BpmnHostSnapshot Snapshot(BpmnScopeMemory memory)
{
var invocationCorrelation = InvocationCorrelation;
return new BpmnHostSnapshot(
ScopeInstanceId: _context.Id,
// A scope has an enclosing one exactly when another scope started it, which is what the carried
// correlation records. A root process has none, so an unhandled escalation is a documented no-op.
HasEnclosingScope: invocationCorrelation.Count > 0,
LiveWork: memory.Work.ToLiveWork(),
InvocationCorrelation: invocationCorrelation,
Variables: new BpmnScopeVariables(_context),
Capabilities: Capabilities);
}
/// <summary>
/// The correlation of the work that started this scope. It belongs to the scope and is fixed for its lifetime;
/// a completing unit of work's correlation is never written here, because the event-subprocess start hint is read
/// from this same dictionary.
/// </summary>
private IReadOnlyDictionary<string, string> InvocationCorrelation =>
BpmnScopeMemory.Read<Dictionary<string, string>>(_context, InvocationCorrelationPropertyKey) ?? NoCorrelation;
private BpmnGraph BuildGraph()
{
var definition = _process.Process
?? throw new InvalidOperationException($"BPMN process activity '{_process.Id}' has no process definition to execute.");
// Every binding the definition declares, with the nested definition attached where the bound activity is
// itself a BPMN scope. The graph validator reads that for an event subprocess body's start trigger.
var boundWork = BpmnBoundWork.Derive(definition, bindingRef => (_process.FindWorkActivity(bindingRef) as BpmnProcess)?.Process);
return BpmnGraph.Build(definition, boundWork, Capabilities);
}
}