From 4b3b39f74ea1163ce11aceb0f6e22f2747b6b3f2 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 4 Aug 2025 08:44:42 +0200 Subject: [PATCH 1/2] Refactor activity execution record capturing (#6836) Replaced `CaptureActivityExecutionRecordMiddleware` with a notification-based approach using `ActivityCompleted` and `CaptureActivityExecutionState`. Removed obsolete middleware setup and extensions for better maintainability. --- .../ScheduledChildCallbackBehavior.cs | 2 +- .../ActivityExecutionContext.Complete.cs | 4 +++ .../Notifications/ActivityCompleted.cs | 5 +++ .../Signals/ActivityCompleted.cs | 2 +- .../Features/DistributedRuntimeFeature.cs | 3 +- ...ctivityExecutionContextRecordExtensions.cs | 32 +++++++++---------- .../PipelineWorkflowsFeatureExtensions.cs | 1 - .../Features/WorkflowRuntimeFeature.cs | 1 + .../Handlers/CancelWorkflowsCommandHandler.cs | 2 ++ .../Handlers/CaptureActivityExecutionState.cs | 19 +++++++++++ ...ivityExecutionPipelineBuilderExtensions.cs | 8 ----- ...aptureActivityExecutionRecordMiddleware.cs | 13 -------- 12 files changed, 49 insertions(+), 43 deletions(-) create mode 100644 src/modules/Elsa.Workflows.Core/Notifications/ActivityCompleted.cs create mode 100644 src/modules/Elsa.Workflows.Runtime/Handlers/CaptureActivityExecutionState.cs delete mode 100644 src/modules/Elsa.Workflows.Runtime/Middleware/Activities/CaptureActivityExecutionRecordMiddleware.cs diff --git a/src/modules/Elsa.Workflows.Core/Behaviors/ScheduledChildCallbackBehavior.cs b/src/modules/Elsa.Workflows.Core/Behaviors/ScheduledChildCallbackBehavior.cs index 709f58cfd..b0d53337c 100644 --- a/src/modules/Elsa.Workflows.Core/Behaviors/ScheduledChildCallbackBehavior.cs +++ b/src/modules/Elsa.Workflows.Core/Behaviors/ScheduledChildCallbackBehavior.cs @@ -1,7 +1,7 @@ using Elsa.Mediator.Contracts; using Elsa.Workflows.Notifications; -using Elsa.Workflows.Signals; using JetBrains.Annotations; +using ActivityCompleted = Elsa.Workflows.Signals.ActivityCompleted; namespace Elsa.Workflows.Behaviors; diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.Complete.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.Complete.cs index 5379093ff..faa250d04 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.Complete.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.Complete.cs @@ -1,4 +1,5 @@ using Elsa.Extensions; +using Elsa.Mediator.Contracts; using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Signals; @@ -62,6 +63,9 @@ public partial class ActivityExecutionContext // Update the completed at timestamp. CompletedAt = WorkflowExecutionContext.SystemClock.UtcNow; + + var mediator = GetRequiredService(); + await mediator.SendAsync(new Notifications.ActivityCompleted(this), CancellationToken); } /// diff --git a/src/modules/Elsa.Workflows.Core/Notifications/ActivityCompleted.cs b/src/modules/Elsa.Workflows.Core/Notifications/ActivityCompleted.cs new file mode 100644 index 000000000..0d018f2d2 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Notifications/ActivityCompleted.cs @@ -0,0 +1,5 @@ +using Elsa.Mediator.Contracts; + +namespace Elsa.Workflows.Notifications; + +public record ActivityCompleted(ActivityExecutionContext ActivityExecutionContext) : INotification; \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Signals/ActivityCompleted.cs b/src/modules/Elsa.Workflows.Core/Signals/ActivityCompleted.cs index 894af1256..83b5a78f5 100644 --- a/src/modules/Elsa.Workflows.Core/Signals/ActivityCompleted.cs +++ b/src/modules/Elsa.Workflows.Core/Signals/ActivityCompleted.cs @@ -4,4 +4,4 @@ namespace Elsa.Workflows.Signals; /// Signaled when an activity has completed. /// /// An optional result. -public record ActivityCompleted(object? Result = default); \ No newline at end of file +public record ActivityCompleted(object? Result = null); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime.Distributed/Features/DistributedRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime.Distributed/Features/DistributedRuntimeFeature.cs index 0e05eca4a..5232c4302 100644 --- a/src/modules/Elsa.Workflows.Runtime.Distributed/Features/DistributedRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime.Distributed/Features/DistributedRuntimeFeature.cs @@ -33,7 +33,6 @@ public class DistributedRuntimeFeature : FeatureBase { Services .AddScoped() - .AddScoped() - .AddCommandHandler(); + .AddScoped(); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/ActivityExecutionContextRecordExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/ActivityExecutionContextRecordExtensions.cs index b9a49106c..b0db9f1c5 100644 --- a/src/modules/Elsa.Workflows.Runtime/Extensions/ActivityExecutionContextRecordExtensions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Extensions/ActivityExecutionContextRecordExtensions.cs @@ -8,34 +8,32 @@ namespace Elsa.Extensions; public static class ActivityExecutionContextRecordExtensions { private const string ActivityExecutionRecordKey = "CapturedActivityExecutionRecord"; - + + /// + /// Captures the activity execution record for the provided and stores it in the context's transient properties. + /// public static async Task CaptureActivityExecutionRecordAsync(this ActivityExecutionContext context) { var mapper = context.GetRequiredService(); var record = await mapper.MapAsync(context); context.TransientProperties[ActivityExecutionRecordKey] = record; } - + + /// + /// Retrieves the captured activity execution record from the transient properties of the provided . + /// If the record is not found, it maps and returns a new activity execution record using the service. + /// public static async Task GetOrMapCapturedActivityExecutionRecordAsync(this ActivityExecutionContext context) { + // If the record is already captured in the transient properties, return it, as it will contain the serialized state of the activity execution at the time of capture, rather than the current state. + // This is useful for scenarios where the activity execution state may change after the record is captured, such as referenced workflow variables. + if (context.TransientProperties.TryGetValue(ActivityExecutionRecordKey, out var capturedRecord)) + return (ActivityExecutionRecord)capturedRecord; + + // If the record is not captured, map a new activity execution record using the mapper. var mapper = context.GetRequiredService(); var record = await mapper.MapAsync(context); - if (context.TransientProperties.TryGetValue(ActivityExecutionRecordKey, out var capturedRecord)) - { - var serializedSnapshot = ((ActivityExecutionRecord)capturedRecord).SerializedSnapshot!; - - // Take the existing serialized snapshot. - record.SerializedSnapshot = serializedSnapshot; - - // Update the serialized snapshot with the current record's properties. - // This will reflect the latest state of the activity execution context without losing the existing serialized snapshot representing e.g., variable values at the time of the record capture. - serializedSnapshot.HasBookmarks = record.HasBookmarks; - serializedSnapshot.Status = record.Status; - serializedSnapshot.AggregateFaultCount = record.AggregateFaultCount; - serializedSnapshot.CompletedAt = record.CompletedAt; - } - return record; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/PipelineWorkflowsFeatureExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/PipelineWorkflowsFeatureExtensions.cs index 0224b0fb4..642e449eb 100644 --- a/src/modules/Elsa.Workflows.Runtime/Extensions/PipelineWorkflowsFeatureExtensions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Extensions/PipelineWorkflowsFeatureExtensions.cs @@ -34,7 +34,6 @@ public static class PipelineWorkflowsFeatureExtensions .UseExecutionLogging() .UseNotifications() .UseLogPersistenceModeEvaluation() - .UseActivityExecutionLogCapturing() .UseBackgroundActivityInvoker(); configurePipeline?.Invoke(pipeline); diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index 7566d57a0..d8f57b739 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -345,6 +345,7 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() + .AddNotificationHandler() // Workflow activation strategies. .AddScoped() diff --git a/src/modules/Elsa.Workflows.Runtime/Handlers/CancelWorkflowsCommandHandler.cs b/src/modules/Elsa.Workflows.Runtime/Handlers/CancelWorkflowsCommandHandler.cs index 776db86c9..5be9522e6 100644 --- a/src/modules/Elsa.Workflows.Runtime/Handlers/CancelWorkflowsCommandHandler.cs +++ b/src/modules/Elsa.Workflows.Runtime/Handlers/CancelWorkflowsCommandHandler.cs @@ -1,12 +1,14 @@ using Elsa.Mediator.Contracts; using Elsa.Mediator.Models; using Elsa.Workflows.Runtime.Commands; +using JetBrains.Annotations; namespace Elsa.Workflows.Runtime.Handlers; /// /// Handles the . /// +[UsedImplicitly] public class CancelWorkflowsCommandHandler(IWorkflowRuntime workflowRuntime) : ICommandHandler { /// diff --git a/src/modules/Elsa.Workflows.Runtime/Handlers/CaptureActivityExecutionState.cs b/src/modules/Elsa.Workflows.Runtime/Handlers/CaptureActivityExecutionState.cs new file mode 100644 index 000000000..7b9b8de25 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Handlers/CaptureActivityExecutionState.cs @@ -0,0 +1,19 @@ +using Elsa.Extensions; +using Elsa.Mediator.Contracts; +using Elsa.Workflows.Notifications; +using JetBrains.Annotations; + +namespace Elsa.Workflows.Runtime.Handlers; + +/// +/// Captures the execution state of an activity when it completes. +/// +[UsedImplicitly] +public class CaptureActivityExecutionState : INotificationHandler +{ + public async Task HandleAsync(ActivityCompleted notification, CancellationToken cancellationToken) + { + var context = notification.ActivityExecutionContext; + await context.CaptureActivityExecutionRecordAsync(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/LogPersistence/Extensions/ActivityExecutionPipelineBuilderExtensions.cs b/src/modules/Elsa.Workflows.Runtime/LogPersistence/Extensions/ActivityExecutionPipelineBuilderExtensions.cs index b57f15df0..988b6d87b 100644 --- a/src/modules/Elsa.Workflows.Runtime/LogPersistence/Extensions/ActivityExecutionPipelineBuilderExtensions.cs +++ b/src/modules/Elsa.Workflows.Runtime/LogPersistence/Extensions/ActivityExecutionPipelineBuilderExtensions.cs @@ -20,12 +20,4 @@ public static class ActivityExecutionPipelineBuilderExtensions /// Installs the which evaluates log persistence modes during activity execution. /// public static IActivityExecutionPipelineBuilder UseLogPersistenceModeEvaluation(this IActivityExecutionPipelineBuilder pipelineBuilder) => pipelineBuilder.UseMiddleware(); - - /// - /// Installs the into the activity execution pipeline to capture and map activity execution details. - /// - public static IActivityExecutionPipelineBuilder UseActivityExecutionLogCapturing(this IActivityExecutionPipelineBuilder pipelineBuilder) - { - return pipelineBuilder.UseMiddleware(); - } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/CaptureActivityExecutionRecordMiddleware.cs b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/CaptureActivityExecutionRecordMiddleware.cs deleted file mode 100644 index 986a05ef7..000000000 --- a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/CaptureActivityExecutionRecordMiddleware.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Elsa.Extensions; -using Elsa.Workflows.Pipelines.ActivityExecution; - -namespace Elsa.Workflows.Runtime.Middleware.Activities; - -public class CaptureActivityExecutionRecordMiddleware(ActivityMiddlewareDelegate next) : IActivityExecutionMiddleware -{ - public async ValueTask InvokeAsync(ActivityExecutionContext context) - { - await next(context); - await context.CaptureActivityExecutionRecordAsync(); - } -} \ No newline at end of file From aab80dd30b46c5847cdce1bded256a9e1b05e2c9 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 4 Aug 2025 15:42:46 +0200 Subject: [PATCH 2/2] Add Activity Execution Snapshots ADR Document architecture decision to capture `ActivityExecutionContext` snapshots at execution time. Updated TOC and dependency graph to reference the new ADR. --- Elsa.sln | 1 + doc/adr/0004-activity-execution-snapshots.md | 28 ++++++++++++++++++++ doc/adr/graph.dot | 18 +++++++------ doc/adr/toc.md | 3 ++- 4 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 doc/adr/0004-activity-execution-snapshots.md diff --git a/Elsa.sln b/Elsa.sln index d171f43ce..a5cad563a 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -391,6 +391,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "adr", "adr", "{0A04B1FD-06C doc\adr\toc.md = doc\adr\toc.md doc\adr\graph.dot = doc\adr\graph.dot doc\adr\0003-direct-bookmark-management-in-workflowexecutioncontext.md = doc\adr\0003-direct-bookmark-management-in-workflowexecutioncontext.md + doc\adr\0004-activity-execution-snapshots.md = doc\adr\0004-activity-execution-snapshots.md EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "bounty", "bounty", "{9B80A705-2E31-4012-964A-83963DCDB384}" diff --git a/doc/adr/0004-activity-execution-snapshots.md b/doc/adr/0004-activity-execution-snapshots.md new file mode 100644 index 000000000..f0ff79902 --- /dev/null +++ b/doc/adr/0004-activity-execution-snapshots.md @@ -0,0 +1,28 @@ +# 4. Activity Execution Snapshots + +**Date:** 2025-08-04 +**Status:** Accepted + +## Context + +Today the `ActivityExecutionContext` is persisted only at *commit points*. +If an activity references a workflow variable that changes after it has run—but before the next commit—the value saved is the *later* value, not the one that existed when the activity executed. +As a result, the Workflow Instance Viewer shows misleading data: users expect to see the variable values *at execution time*, not at commit time. + +## Decision + +Capture a **snapshot** of the `ActivityExecutionContext` immediately when an activity executes. +The snapshot must include: + +* All workflow variables and their values at that moment. +* Any other execution-specific metadata required for replay or inspection. + +The snapshot is created by serializing the `ActivityExecutionRecord` to JSON and storing it in the database. +The persistence layer will be updated to handle this new snapshot field, ensuring it is stored alongside the activity execution record. + +## Consequences + +* The Workflow Instance Viewer will now display the exact state that the activity saw, eliminating confusion during debugging and auditing. +* Additional storage will be consumed for each snapshot. We accept this overhead in exchange for correctness and developer experience. +* Existing persistence schemas will require a non-breaking migration to store the snapshot payload. +* Workflow instances before this change will not have snapshots, but they will still be replayable even if the variable values are not accurate at execution time. \ No newline at end of file diff --git a/doc/adr/graph.dot b/doc/adr/graph.dot index 0e66155fa..61e1e6f7c 100644 --- a/doc/adr/graph.dot +++ b/doc/adr/graph.dot @@ -1,10 +1,12 @@ digraph { - node [shape=plaintext]; - subgraph { - _1 [label="1. Record architecture decisions"; URL="0001-record-architecture-decisions.html"]; - _2 [label="2. Fault Propagation from Child to Parent Activities"; URL="0002-fault-propagation-from-child-to-parent-activities.html"]; - _1 -> _2 [style="dotted", weight=1]; - _3 [label="3. Direct Bookmark Management in WorkflowExecutionContext"; URL="0003-direct-bookmark-management-in-workflowexecutioncontext.html"]; - _2 -> _3 [style="dotted", weight=1]; - } +node [shape = plaintext]; +subgraph { +_1 [label = "1. Record architecture decisions"; URL = "0001-record-architecture-decisions.html"]; +_2 [label = "2. Fault Propagation from Child to Parent Activities"; URL ="0002-fault-propagation-from-child-to-parent-activities.html"]; +_1 -> _2 [style= "dotted", weight = 1]; +_3 [label = "3. Direct Bookmark Management in WorkflowExecutionContext"; URL = "0003-direct-bookmark-management-in-workflowexecutioncontext.html"]; +_2 -> _3 [style = "dotted", weight = 1]; +_4 [label ="4. Activity Execution Snapshots"; URL = "0004-activity-execution-snapshots.html"]; +_3 -> _4 [style = "dotted", weight = 1]; } +} \ No newline at end of file diff --git a/doc/adr/toc.md b/doc/adr/toc.md index e5c8dac8d..7eecf8af6 100644 --- a/doc/adr/toc.md +++ b/doc/adr/toc.md @@ -2,4 +2,5 @@ * [1. Record architecture decisions](0001-record-architecture-decisions.md) * [2. Fault Propagation from Child to Parent Activities](0002-fault-propagation-from-child-to-parent-activities.md) -* [3. Direct Bookmark Management in WorkflowExecutionContext](0003-direct-bookmark-management-in-workflowexecutioncontext.md) \ No newline at end of file +* [3. Direct Bookmark Management in WorkflowExecutionContext](0003-direct-bookmark-management-in-workflowexecutioncontext.md) +* [4. Activity Execution Snapshots](0004-activity-execution-snapshots.md) \ No newline at end of file