Refactor Fault Propagation and Simplify Bookmark Management (#6545)

* Refactor bookmark management and add new features

Streamlined bookmark handling by eliminating temporary storage in `ActivityExecutionContext` and directly managing bookmarks in `WorkflowExecutionContext`. Documented architectural decisions using ADRs.

* Regenerate EF Core migrations

* Refactor fault tracking to use AggregatedFaultCount property.

Replaces FaultCount with AggregatedFaultCount across the codebase to improve clarity and consistency in fault tracking. Updates related methods, properties, and data mappings to align with the new terminology. Fixes initialization issue with nullable inputs in Fault class.

* Add migration to track fault counts in runtime (V3.5)

This migration adds a new column, "AggregatedFaultCount," to the "ActivityExecutionRecords" table. The column is an integer, non-nullable, with a default value of 0, and enables tracking aggregated fault occurrences. The migration also includes a rollback to remove this column if needed.

* Fix typo in ADR 0004 regarding bookmark management convention

Corrected a spelling mistake in the ADR documentation by changing "determins" to "determines." This ensures clarity and maintains the professionalism of the document. No functional changes were made.

* Refine fault propagation logic for child-parent activities

Replace automatic fault transitions of parent activities with an aggregate fault count for descendant activities. This avoids premature state changes while still indicating child activity faults, improving workflow resilience and accuracy.

* Remove signal-driven fault propagation ADR and renumber bookmarks ADR

The ADR for signal-driven fault propagation was deleted, and the direct bookmark management ADR was renamed and renumbered accordingly. Related references in the table of contents, graph, and solution file were updated to reflect these changes.

* Refactor DeleteBookmarks to improve readability.

Reformatted the BookmarkFilter initialization for better clarity and maintainability. This change ensures the code is more aligned with modern C# conventions and improves overall readability. No behavior or functionality has been altered.

* Refactor naming for "AggregatedFaultCount" to "AggregateFaultCount"

Standardized the terminology across the codebase and migrations by renaming all references of "AggregatedFaultCount" to "AggregateFaultCount" for improved consistency and readability. Updated relevant logic, models, migrations, and database contexts accordingly.
This commit is contained in:
Sipke Schoorstra 2025-04-10 15:01:49 +02:00 committed by GitHub
parent 006ed54a92
commit 4266421a4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 471 additions and 71 deletions

View file

@ -20,10 +20,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "solution", "solution", "{7D
README.md = README.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{0354F050-3992-4DD4-B0EE-5FBA04AC72B6}"
ProjectSection(SolutionItems) = preProject
docs\bounty-footer.md = docs\bounty-footer.md
EndProjectSection
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "doc", "doc", "{0354F050-3992-4DD4-B0EE-5FBA04AC72B6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "modules", "modules", "{5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}"
EndProject
@ -384,6 +381,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Connections.Persistenc
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Connections.Persistence.EntityFrameworkCore.Sqlite", "src\modules\Elsa.Connections.Persistence.EntityFrameworkCore.Sqlite\Elsa.Connections.Persistence.EntityFrameworkCore.Sqlite.csproj", "{DB20B168-CD90-41F2-BE08-3D8C6F142459}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "adr", "adr", "{0A04B1FD-06C0-4271-A910-A08C263DBC44}"
ProjectSection(SolutionItems) = preProject
doc\adr\0001-record-architecture-decisions.md = doc\adr\0001-record-architecture-decisions.md
doc\adr\0002-fault-propagation-from-child-to-parent-activities.md = doc\adr\0002-fault-propagation-from-child-to-parent-activities.md
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
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "bounty", "bounty", "{9B80A705-2E31-4012-964A-83963DCDB384}"
ProjectSection(SolutionItems) = preProject
doc\bounty\bounty-announcement-template.md = doc\bounty\bounty-announcement-template.md
doc\bounty\bounty-footer.md = doc\bounty\bounty-footer.md
doc\bounty\bounty-quality-standards.md = doc\bounty\bounty-quality-standards.md
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -951,6 +964,8 @@ Global
{DF0CA7E2-F5DF-40ED-91B6-748767084FC7} = {9DF81143-2F39-4CFB-9DA3-902279010622}
{DB20B168-CD90-41F2-BE08-3D8C6F142459} = {9DF81143-2F39-4CFB-9DA3-902279010622}
{8A050229-DB79-4E0B-9AFF-7565E87F2954} = {B08B4E00-C2AB-48F3-8389-449F42AEF179}
{0A04B1FD-06C0-4271-A910-A08C263DBC44} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6}
{9B80A705-2E31-4012-964A-83963DCDB384} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -0,0 +1,19 @@
# 1. Record architecture decisions
Date: 2025-04-01
## Status
Accepted
## Context
We need to record the architectural decisions made on this project.
## Decision
We will use Architecture Decision Records, as [described by Michael Nygard](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions).
## Consequences
See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's [adr-tools](https://github.com/npryce/adr-tools).

View file

@ -0,0 +1,23 @@
# 2. Fault Propagation from Child to Parent Activities
Date: 2025-04-01
## Status
Accepted
## Context
When an activity throws an exception during execution, the `ExceptionHandlingMiddleware` is responsible for handling the fault. In addition to logging the exception and recording an incident, it also explicitly faults all ancestor activities using the `GetAncestors()` method and calling `TransitionTo(ActivityStatus.Faulted)` on each one.
At the time of implementing this behavior, the rationale was likely to bubble up the fault status to allow Elsa Studio to display that a given activity has one or more faulting descendant activities.
However, this behavior causes unintended side effects when the faulted activity is later resumed and completes successfully. Since the parent (e.g., a `Flowchart`) was faulted earlier, it remains in a `Faulted` state, unaware that the child it depends on has now succeeded. As a result, the Flowchart itself never reaches `Completed`, and the entire workflow instance is stuck in `Faulted` or `Running`, depending on the selected fault strategy.
## Decision
A new field will be introduced that represents an aggregate count of the faults occurring in descendant activities.
## Consequences
- Allows composite activities to reflect a failed state immediately and consistently when one of their children fails.
## Alternatives Considered
- Query descendants at runtime to get an aggregate fault count instead. However, this is an expensive operation that can be time consuming.

View file

@ -0,0 +1,40 @@
# 3. Direct Bookmark Management in WorkflowExecutionContext
Date: 2025-04-01
## Status
Accepted
## Context
Currently, `ActivityExecutionContext` maintains a temporary list of bookmarks created by the activity during its execution. After the activity completes, a middleware is responsible for copying these bookmarks into the `WorkflowExecutionContext.Bookmarks` list, which is then persisted to the database.
This architecture leads to a subtle issue upon resumption:
- When a workflow is resumed from a persisted state, only the `WorkflowExecutionContext` and its `Bookmarks` are restored from the database.
- The `ActivityExecutionContext`'s temporary `Bookmarks` list remains empty.
- As a result, if the resumed activity attempts to cancel (or modify) its previously created bookmarks, it cannot, because it has no access to them—changes are not tracked.
However, there is an existing convention that determines that if an activity created a bookmark during its execution, the activity will not automatically complete.
Specifically, this is implemented in the `AutoCompleteBehavior`, which is installed by the `CodeActivity` and possibly by custom activities.
To keep this behavior in tact, we will still maintain a private list of bookmarks, one that is explicitly purposed for maintaining *new** bookmarks, temporarily for the lifetime of the ActivityExecutionContext in memory.
## Decision
Eliminate the temporary `Bookmarks` list in `ActivityExecutionContext`. Instead, have activities add bookmarks directly to the `WorkflowExecutionContext.Bookmarks` list.
This is possible and safe because each `Bookmark` already contains:
- A reference to the originating `ActivityId`
- A reference to the originating `ActivityInstanceId`
These references allow accurate tracking and filtering of bookmarks, even when multiple activities are active.
Additionally, maintain a new temporary bookmarks list that explicitly stores newly created bookmarks. This list does not have to be copied into the WorkflowExecutionContext's Bookmarks property, given that this property will now be updated directly.
## Consequences
- Simplifies the architecture by removing the need for middleware to copy bookmarks post-execution.
- Fixes a bug where resumed activities could not cancel their previously created bookmarks.
- Reduces the chance of inconsistencies between temporary and persisted bookmark state.
- Ensures bookmark changes are always applied to the persisted, canonical source of truth.
## Alternatives Considered
- Keep the existing temporary list and enhance the system to hydrate the `ActivityExecutionContext.Bookmarks` list on resume. Rejected due to added complexity and duplication of state.

10
doc/adr/graph.dot Normal file
View file

@ -0,0 +1,10 @@
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];
}
}

5
doc/adr/toc.md Normal file
View file

@ -0,0 +1,5 @@
# Architecture Decision Records
* [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)

View file

@ -0,0 +1,26 @@
using Elsa.Extensions;
using Elsa.Workflows;
namespace Elsa.ServerAndStudio.Web.Activities;
public class CustomDelay : Activity
{
protected override ValueTask ExecuteAsync(ActivityExecutionContext context)
{
context.DelayFor(TimeSpan.FromSeconds(5), FailAsync);
context.DelayFor(TimeSpan.FromSeconds(500), OnDelayElapsedAsync);
return default;
}
private ValueTask FailAsync(ActivityExecutionContext context)
{
Console.WriteLine("Failing!");
throw new("Failing!");
}
private async ValueTask OnDelayElapsedAsync(ActivityExecutionContext context)
{
Console.WriteLine("Delay elapsed");
await context.CompleteActivityAsync();
}
}

View file

@ -0,0 +1,18 @@
using Elsa.Expressions.Models;
using Elsa.Workflows;
using Elsa.Workflows.Runtime.Activities;
namespace Elsa.ServerAndStudio.Web.Activities;
public class CustomEvent : EventBase<object>
{
protected override string GetEventName(ExpressionExecutionContext context)
{
return "MyEvent";
}
protected override void OnEventReceived(ActivityExecutionContext context, object? eventData)
{
Console.WriteLine("Event received with data: " + eventData);
}
}

View file

@ -0,0 +1,22 @@
using Elsa.Http;
using Elsa.Workflows;
namespace Elsa.ServerAndStudio.Web.Activities;
public class CustomHttpEndpoint : HttpEndpointBase
{
protected override HttpEndpointOptions GetOptions()
{
return new()
{
Path = "my-path",
Methods = [HttpMethods.Get],
};
}
protected override async ValueTask OnHttpRequestReceivedAsync(ActivityExecutionContext context, HttpContext httpContext)
{
httpContext.Response.StatusCode = 200;
await httpContext.Response.WriteAsync("Hello World", context.CancellationToken);
}
}

View file

@ -0,0 +1,18 @@
using Elsa.Expressions.Models;
using Elsa.Scheduling.Activities;
using Elsa.Workflows;
namespace Elsa.ServerAndStudio.Web.Activities;
public class CustomTimer : TimerBase
{
protected override TimeSpan GetInterval(ExpressionExecutionContext context)
{
return TimeSpan.FromSeconds(5);
}
protected override void OnTimerElapsed(ActivityExecutionContext context)
{
Console.WriteLine("Timer elapsed");
}
}

View file

@ -10,22 +10,22 @@ public class ActivityExecutionRecordSummary : Entity
/// <summary>
/// Gets or sets the workflow instance ID.
/// </summary>
public string WorkflowInstanceId { get; set; } = default!;
public string WorkflowInstanceId { get; set; } = null!;
/// <summary>
/// Gets or sets the activity ID.
/// </summary>
public string ActivityId { get; set; } = default!;
public string ActivityId { get; set; } = null!;
/// <summary>
/// Gets or sets the activity node ID.
/// </summary>
public string ActivityNodeId { get; set; } = default!;
public string ActivityNodeId { get; set; } = null!;
/// <summary>
/// The type of the activity.
/// </summary>
public string ActivityType { get; set; } = default!;
public string ActivityType { get; set; } = null!;
/// <summary>
/// The version of the activity type.
@ -62,7 +62,7 @@ public class ActivityExecutionRecordSummary : Entity
/// </summary>
public static ActivityExecutionRecordSummary FromRecord(ActivityExecutionRecord record)
{
return new ActivityExecutionRecordSummary
return new()
{
Id = record.Id,
WorkflowInstanceId = record.WorkflowInstanceId,

View file

@ -8,12 +8,12 @@ public class ActivityExecutionStats
/// <summary>
/// Gets or sets the ID of the activity.
/// </summary>
public string ActivityId { get; set; } = default!;
public string ActivityId { get; set; } = null!;
/// <summary>
/// Gets or sets the node ID of the activity.
/// </summary>
public string ActivityNodeId { get; set; } = default!;
public string ActivityNodeId { get; set; } = null!;
/// <summary>
/// Gets or sets the number of uncompleted executions.
@ -39,4 +39,9 @@ public class ActivityExecutionStats
/// Gets or sets a value whether this activity is faulted.
/// </summary>
public bool IsFaulted { get; set; }
/// <summary>
/// Gets or sets the total count of faults aggregated from the activity execution and its descendants.
/// </summary>
public int AggregateFaultCount { get; set; }
}

View file

@ -0,0 +1,25 @@
using System.Diagnostics.CodeAnalysis;
using FluentMigrator;
using JetBrains.Annotations;
using static System.Int32;
namespace Elsa.Dapper.Migrations.Runtime;
/// <inheritdoc />
[Migration(20006, "Elsa:Runtime:V3.5")]
[PublicAPI]
[SuppressMessage("ReSharper", "InconsistentNaming")]
public class V3_5 : Migration
{
/// <inheritdoc />
public override void Up()
{
Alter.Table("ActivityExecutionRecords").AddColumn("AggregateFaultCount").AsInt32().NotNullable().WithDefault(0);
}
/// <inheritdoc />
public override void Down()
{
Delete.Column("AggregateFaultCount").FromTable("ActivityExecutionRecords");
}
}

View file

@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime
{
[DbContext(typeof(RuntimeElsaDbContext))]
[Migration("20250320220046_V3_5")]
[Migration("20250401201339_V3_5")]
partial class V3_5
{
/// <inheritdoc />
@ -68,6 +68,9 @@ namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime
b.Property<int>("ActivityTypeVersion")
.HasColumnType("int");
b.Property<int>("AggregateFaultCount")
.HasColumnType("int");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("datetime(6)");

View file

@ -37,6 +37,14 @@ namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<int>(
name: "AggregateFaultCount",
schema: _schema.Schema,
table: "ActivityExecutionRecords",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_StoredBookmark_Name",
schema: _schema.Schema,
@ -79,6 +87,11 @@ namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime
schema: _schema.Schema,
table: "Bookmarks");
migrationBuilder.DropColumn(
name: "AggregateFaultCount",
schema: _schema.Schema,
table: "ActivityExecutionRecords");
migrationBuilder.UpdateData(
schema: _schema.Schema,
table: "Triggers",

View file

@ -65,6 +65,9 @@ namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime
b.Property<int>("ActivityTypeVersion")
.HasColumnType("int");
b.Property<int>("AggregateFaultCount")
.HasColumnType("int");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("datetime(6)");

View file

@ -12,7 +12,7 @@ using Oracle.EntityFrameworkCore.Metadata;
namespace Elsa.EntityFrameworkCore.Oracle.Migrations.Runtime
{
[DbContext(typeof(RuntimeElsaDbContext))]
[Migration("20250320220124_V3_5")]
[Migration("20250401201436_V3_5")]
partial class V3_5
{
/// <inheritdoc />
@ -68,6 +68,9 @@ namespace Elsa.EntityFrameworkCore.Oracle.Migrations.Runtime
b.Property<int>("ActivityTypeVersion")
.HasColumnType("NUMBER(10)");
b.Property<int>("AggregateFaultCount")
.HasColumnType("NUMBER(10)");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("TIMESTAMP(7) WITH TIME ZONE");

View file

@ -34,6 +34,14 @@ namespace Elsa.EntityFrameworkCore.Oracle.Migrations.Runtime
type: "NVARCHAR2(450)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "AggregateFaultCount",
schema: _schema.Schema,
table: "ActivityExecutionRecords",
type: "NUMBER(10)",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_StoredBookmark_Name",
schema: _schema.Schema,
@ -76,6 +84,11 @@ namespace Elsa.EntityFrameworkCore.Oracle.Migrations.Runtime
schema: _schema.Schema,
table: "Bookmarks");
migrationBuilder.DropColumn(
name: "AggregateFaultCount",
schema: _schema.Schema,
table: "ActivityExecutionRecords");
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: _schema.Schema,

View file

@ -65,6 +65,9 @@ namespace Elsa.EntityFrameworkCore.Oracle.Migrations.Runtime
b.Property<int>("ActivityTypeVersion")
.HasColumnType("NUMBER(10)");
b.Property<int>("AggregateFaultCount")
.HasColumnType("NUMBER(10)");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("TIMESTAMP(7) WITH TIME ZONE");

View file

@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime
{
[DbContext(typeof(RuntimeElsaDbContext))]
[Migration("20250320220113_V3_5")]
[Migration("20250401201425_V3_5")]
partial class V3_5
{
/// <inheritdoc />
@ -68,6 +68,9 @@ namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime
b.Property<int>("ActivityTypeVersion")
.HasColumnType("integer");
b.Property<int>("AggregateFaultCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");

View file

@ -34,6 +34,14 @@ namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "AggregateFaultCount",
schema: _schema.Schema,
table: "ActivityExecutionRecords",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_StoredBookmark_Name",
schema: _schema.Schema,
@ -76,6 +84,11 @@ namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime
schema: _schema.Schema,
table: "Bookmarks");
migrationBuilder.DropColumn(
name: "AggregateFaultCount",
schema: _schema.Schema,
table: "ActivityExecutionRecords");
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: _schema.Schema,

View file

@ -65,6 +65,9 @@ namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime
b.Property<int>("ActivityTypeVersion")
.HasColumnType("integer");
b.Property<int>("AggregateFaultCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");

View file

@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime
{
[DbContext(typeof(RuntimeElsaDbContext))]
[Migration("20250320220055_V3_5")]
[Migration("20250401201357_V3_5")]
partial class V3_5
{
/// <inheritdoc />
@ -68,6 +68,9 @@ namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime
b.Property<int>("ActivityTypeVersion")
.HasColumnType("int");
b.Property<int>("AggregateFaultCount")
.HasColumnType("int");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("datetimeoffset");

View file

@ -34,6 +34,14 @@ namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime
type: "nvarchar(450)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "AggregateFaultCount",
schema: _schema.Schema,
table: "ActivityExecutionRecords",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_StoredBookmark_Name",
schema: _schema.Schema,
@ -76,6 +84,11 @@ namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime
schema: _schema.Schema,
table: "Bookmarks");
migrationBuilder.DropColumn(
name: "AggregateFaultCount",
schema: _schema.Schema,
table: "ActivityExecutionRecords");
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: _schema.Schema,

View file

@ -65,6 +65,9 @@ namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime
b.Property<int>("ActivityTypeVersion")
.HasColumnType("int");
b.Property<int>("AggregateFaultCount")
.HasColumnType("int");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("datetimeoffset");

View file

@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime
{
[DbContext(typeof(RuntimeElsaDbContext))]
[Migration("20250320185742_V3_5")]
[Migration("20250401201414_V3_5")]
partial class V3_5
{
/// <inheritdoc />
@ -64,6 +64,9 @@ namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime
b.Property<int>("ActivityTypeVersion")
.HasColumnType("INTEGER");
b.Property<int>("AggregateFaultCount")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("TEXT");

View file

@ -34,6 +34,14 @@ namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "AggregateFaultCount",
schema: _schema.Schema,
table: "ActivityExecutionRecords",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_StoredBookmark_Name",
schema: _schema.Schema,
@ -76,6 +84,11 @@ namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime
schema: _schema.Schema,
table: "Bookmarks");
migrationBuilder.DropColumn(
name: "AggregateFaultCount",
schema: _schema.Schema,
table: "ActivityExecutionRecords");
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: _schema.Schema,

View file

@ -61,6 +61,9 @@ namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime
b.Property<int>("ActivityTypeVersion")
.HasColumnType("INTEGER");
b.Property<int>("AggregateFaultCount")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("TEXT");

View file

@ -10,12 +10,12 @@ internal class Request
/// <summary>
/// The ID of the workflow instance to get the execution log for.
/// </summary>
public string WorkflowInstanceId { get; set; } = default!;
public string WorkflowInstanceId { get; set; } = null!;
/// <summary>
/// The node IDs of the activities to get the execution records for.
/// </summary>
public ICollection<string> ActivityNodeIds { get; set; } = default!;
public ICollection<string> ActivityNodeIds { get; set; } = null!;
}
internal class Response

View file

@ -25,7 +25,7 @@ public class Fault : Activity
/// </summary>
public static Fault Create(string code, string category, string type, string? message = null, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null)
{
return new Fault(source, line)
return new(source, line)
{
Code = new(code),
Message = new(message),
@ -38,13 +38,13 @@ public class Fault : Activity
/// Code to categorize the fault.
/// </summary>
[Input(Description = "Code to categorize the fault.")]
public Input<string> Code { get; set; } = default!;
public Input<string> Code { get; set; } = null!;
/// <summary>
/// Category to categorize the fault. Examples: HTTP, Alteration, Azure, etc.
/// </summary>
[Input(Description = "Category to categorize the fault. Examples: HTTP, Alteration, Azure, etc.")]
public Input<string> Category { get; set; } = default!;
public Input<string> Category { get; set; } = null!;
/// <summary>
/// The type of fault. Examples: System, Business, Integration, etc.
@ -53,13 +53,13 @@ public class Fault : Activity
DisplayName = "Type",
Description = "The type of fault. Examples: System, Business, Integration, etc."
)]
public Input<string> FaultType { get; set; } = default!;
public Input<string> FaultType { get; set; } = null!;
/// <summary>
/// The message to include with the fault.
/// </summary>
[Input(Description = "The message to include with the fault.")]
public Input<string?> Message { get; set; } = default!;
public Input<string?> Message { get; set; } = null!;
/// <inheritdoc />
protected override void Execute(ActivityExecutionContext context)

View file

@ -1,8 +1,11 @@
using JetBrains.Annotations;
namespace Elsa.Workflows.Behaviors;
/// <summary>
/// Automatically completes the currently executing activity.
/// </summary>
[UsedImplicitly]
public class AutoCompleteBehavior : Behavior
{
/// <inheritdoc />
@ -14,7 +17,7 @@ public class AutoCompleteBehavior : Behavior
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
// If the activity created any bookmarks, do not complete.
if (context.Bookmarks.Any(x => x.ActivityId == context.Activity.Id))
if (context.NewBookmarks.Any(x => x.ActivityId == context.Activity.Id))
return;
await context.CompleteActivityAsync();

View file

@ -1,4 +1,3 @@
using System.Collections.ObjectModel;
using System.Runtime.CompilerServices;
using Elsa.Common;
using Elsa.Expressions.Helpers;
@ -19,11 +18,13 @@ namespace Elsa.Workflows;
public partial class ActivityExecutionContext : IExecutionContext, IDisposable
{
private readonly ISystemClock _systemClock;
private readonly List<Bookmark> _bookmarks = [];
private ActivityStatus _status;
private Exception? _exception;
private long _executionCount;
private ActivityExecutionContext? _parentActivityExecutionContext;
// Bookmarks created during the lifetime of this activity.
private List<Bookmark> _newBookmarks = [];
/// <summary>
/// Initializes a new instance of the <see cref="ActivityExecutionContext"/> class.
@ -94,6 +95,11 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable
/// </remarks>
public bool IsExecuting { get; set; }
/// <summary>
/// The number of faults encountered during the execution of the activity and its descendants.
/// </summary>
public int AggregateFaultCount { get; set; }
/// <summary>
/// The workflow execution context.
/// </summary>
@ -226,9 +232,14 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable
public ISet<ActivityExecutionContext> Children { get; } = new HashSet<ActivityExecutionContext>();
/// <summary>
/// A list of bookmarks created by the current activity.
/// A list of bookmarks associated with the current activity.
/// </summary>
public IReadOnlyCollection<Bookmark> Bookmarks => new ReadOnlyCollection<Bookmark>(_bookmarks);
public IEnumerable<Bookmark> Bookmarks => WorkflowExecutionContext.Bookmarks.Where(x => x.ActivityInstanceId == Id);
/// <summary>
/// A collection of bookmarks created during the execution of the activity.
/// </summary>
public IEnumerable<Bookmark> NewBookmarks => _newBookmarks.AsReadOnly();
/// <summary>
/// The number of times this <see cref="ActivityExecutionContext"/> has executed.
@ -418,7 +429,7 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable
/// <param name="bookmarks">The bookmarks to add.</param>
public void AddBookmarks(IEnumerable<Bookmark> bookmarks)
{
_bookmarks.AddRange(bookmarks);
WorkflowExecutionContext.Bookmarks.AddRange(bookmarks);
Taint();
}
@ -428,7 +439,8 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable
/// <param name="bookmark">The bookmark to add.</param>
public void AddBookmark(Bookmark bookmark)
{
_bookmarks.Add(bookmark);
_newBookmarks.Add(bookmark);
WorkflowExecutionContext.Bookmarks.Add(bookmark);
Taint();
}
@ -536,7 +548,8 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable
/// </summary>
public void ClearBookmarks()
{
_bookmarks.Clear();
_newBookmarks.Clear();
WorkflowExecutionContext.Bookmarks.RemoveWhere(x => x.ActivityInstanceId == Id);
Taint();
}

View file

@ -1,5 +1,6 @@
using System.Reflection;
using System.Text.Json;
using Elsa.Common;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Helpers;
using Elsa.Expressions.Models;
@ -10,6 +11,7 @@ using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
using Elsa.Workflows.Notifications;
using Elsa.Workflows.Signals;
using Elsa.Workflows.State;
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
@ -79,8 +81,10 @@ public static partial class ActivityExecutionContextExtensions
/// <param name="storageDriverType">The type of storage driver to use for the variable.</param>
/// <param name="configure">A callback to configure the memory block.</param>
/// <returns>The created <see cref="Variable"/>.</returns>
public static Variable CreateVariable(this ActivityExecutionContext context, string name, object? value, Type? storageDriverType = null, Action<MemoryBlock>? configure = null) =>
context.ExpressionExecutionContext.CreateVariable(name, value, storageDriverType, configure);
public static Variable CreateVariable(this ActivityExecutionContext context, string name, object? value, Type? storageDriverType = null, Action<MemoryBlock>? configure = null)
{
return context.ExpressionExecutionContext.CreateVariable(name, value, storageDriverType, configure);
}
/// <summary>
/// Sets a workflow variable by name.
@ -103,7 +107,7 @@ public static partial class ActivityExecutionContextExtensions
context.SetVariable(name, value);
return predefinedVariable;
}
// No predefined variable exists, so we will add a dynamic variable to the current container in scope.
var container = context.FindParentWithVariableContainer();
@ -300,6 +304,47 @@ public static partial class ActivityExecutionContextExtensions
await publisher.SendAsync(new ActivityCancelled(context));
}
/// <summary>
/// Marks the current activity as faulted, records an exception, and transitions its status to <see cref="ActivityStatus.Faulted"/>.
/// An incident is logged, and the fault count for the activity and its ancestor activities is incremented.
/// </summary>
/// <param name="context">The <see cref="ActivityExecutionContext"/> representing the execution context of the current activity.</param>
/// <param name="e">The exception to be recorded as the cause of the fault.</param>
public static void Fault(this ActivityExecutionContext context, Exception e)
{
context.Exception = e;
context.TransitionTo(ActivityStatus.Faulted);
var activity = context.Activity;
var exceptionState = ExceptionState.FromException(e);
var systemClock = context.GetRequiredService<ISystemClock>();
var now = systemClock.UtcNow;
var incident = new ActivityIncident(activity.Id, activity.Type, e.Message, exceptionState, now);
context.WorkflowExecutionContext.Incidents.Add(incident);
context.AggregateFaultCount++;
var ancestors = context.GetAncestors();
foreach (var ancestor in ancestors)
ancestor.AggregateFaultCount++;
context.TransitionTo(ActivityStatus.Faulted);
}
/// <summary>
/// Recovers the current activity from a faulted state by resetting fault counts and transitioning the activity to the running status.
/// </summary>
public static void RecoverFromFault(this ActivityExecutionContext context)
{
context.AggregateFaultCount = 0;
var ancestors = context.GetAncestors();
foreach (var ancestor in ancestors)
ancestor.AggregateFaultCount--;
context.TransitionTo(ActivityStatus.Running);
}
/// <summary>
/// Returns the first context that is associated with a <see cref="IVariableContainer"/>.
/// </summary>

View file

@ -82,13 +82,13 @@ public class DefaultActivityInvokerMiddleware(ActivityMiddlewareDelegate next, I
// Invoke next middleware.
await next(context);
// If the activity created any bookmarks, copy them into the workflow execution context.
if (context.Bookmarks.Any())
{
// Store bookmarks.
workflowExecutionContext.Bookmarks.AddRange(context.Bookmarks);
logger.LogDebug("Added {BookmarkCount} bookmarks to the workflow execution context", context.Bookmarks.Count);
}
// // If the activity created any bookmarks, copy them into the workflow execution context.
// if (context.Bookmarks.Any())
// {
// // Store bookmarks.
// workflowExecutionContext.Bookmarks.AddRange(context.Bookmarks);
// logger.LogDebug("Added {BookmarkCount} bookmarks to the workflow execution context", context.Bookmarks.Count);
// }
// Conditionally commit the workflow state.
if (ShouldCommit(context, ActivityLifetimeEvent.ActivityExecuted))

View file

@ -1,8 +1,6 @@
using Elsa.Common;
using Elsa.Extensions;
using Elsa.Workflows.Models;
using Elsa.Workflows.Pipelines.ActivityExecution;
using Elsa.Workflows.State;
using Microsoft.Extensions.Logging;
namespace Elsa.Workflows.Middleware.Activities;
@ -34,8 +32,7 @@ public class ExceptionHandlingMiddleware(ActivityMiddlewareDelegate next, IIncid
catch (Exception e)
{
logger.LogWarning(e, "An exception was caught from a downstream middleware component");
LogExceptionAndTransition(context, e);
FaultAncestors(context);
context.Fault(e);
await HandleIncidentAsync(context);
}
}
@ -56,12 +53,4 @@ public class ExceptionHandlingMiddleware(ActivityMiddlewareDelegate next, IIncid
var strategy = await incidentStrategyResolver.ResolveStrategyAsync(context);
strategy.HandleIncident(context);
}
private static void FaultAncestors(ActivityExecutionContext context)
{
var ancestors = context.GetAncestors();
foreach (var ancestor in ancestors)
ancestor.TransitionTo(ActivityStatus.Faulted);
}
}

View file

@ -150,6 +150,7 @@ public class WorkflowStateExtractor : IWorkflowStateExtractor
activityExecutionContext.TransitionTo(activityExecutionContextState.Status);
activityExecutionContext.IsExecuting = activityExecutionContextState.IsExecuting;
activityExecutionContext.AggregateFaultCount = activityExecutionContextState.FaultCount;
activityExecutionContext.StartedAt = activityExecutionContextState.StartedAt;
activityExecutionContext.CompletedAt = activityExecutionContextState.CompletedAt;
activityExecutionContext.Tag = activityExecutionContextState.Tag;
@ -238,6 +239,7 @@ public class WorkflowStateExtractor : IWorkflowStateExtractor
ActivityState = activityExecutionContext.ActivityState,
Status = activityExecutionContext.Status,
IsExecuting = activityExecutionContext.IsExecuting,
FaultCount = activityExecutionContext.AggregateFaultCount,
StartedAt = activityExecutionContext.StartedAt,
CompletedAt = activityExecutionContext.CompletedAt,
Tag = activityExecutionContext.Tag,

View file

@ -61,6 +61,11 @@ public class ActivityExecutionContextState
/// </summary>
public bool IsExecuting { get; set; }
/// <summary>
/// The number of faults recorded by this activity for itself and descendants.
/// </summary>
public int FaultCount { get; set; }
/// <summary>
/// The time at which the activity execution began.
/// </summary>

View file

@ -12,22 +12,22 @@ public class ActivityExecutionRecord : Entity, ILogRecord
/// <summary>
/// Gets or sets the workflow instance ID.
/// </summary>
public string WorkflowInstanceId { get; set; } = default!;
public string WorkflowInstanceId { get; set; } = null!;
/// <summary>
/// Gets or sets the activity ID.
/// </summary>
public string ActivityId { get; set; } = default!;
public string ActivityId { get; set; } = null!;
/// <summary>
/// Gets or sets the activity node ID.
/// </summary>
public string ActivityNodeId { get; set; } = default!;
public string ActivityNodeId { get; set; } = null!;
/// <summary>
/// The type of the activity.
/// </summary>
public string ActivityType { get; set; } = default!;
public string ActivityType { get; set; } = null!;
/// <summary>
/// The version of the activity type.
@ -78,6 +78,11 @@ public class ActivityExecutionRecord : Entity, ILogRecord
/// Gets or sets the status of the activity.
/// </summary>
public ActivityStatus Status { get; set; }
/// <summary>
/// Gets or sets the aggregated count of faults encountered during the execution of the activity instance and its descendants.
/// </summary>
public int AggregateFaultCount { get; set; }
/// <summary>
/// Gets or sets the time at which the activity execution completed.

View file

@ -11,22 +11,22 @@ public class ActivityExecutionRecordSummary : Entity
/// <summary>
/// Gets or sets the workflow instance ID.
/// </summary>
public string WorkflowInstanceId { get; set; } = default!;
public string WorkflowInstanceId { get; set; } = null!;
/// <summary>
/// Gets or sets the activity ID.
/// </summary>
public string ActivityId { get; set; } = default!;
public string ActivityId { get; set; } = null!;
/// <summary>
/// Gets or sets the activity node ID.
/// </summary>
public string ActivityNodeId { get; set; } = default!;
public string ActivityNodeId { get; set; } = null!;
/// <summary>
/// The type of the activity.
/// </summary>
public string ActivityType { get; set; } = default!;
public string ActivityType { get; set; } = null!;
/// <summary>
/// The version of the activity type.
@ -52,6 +52,11 @@ public class ActivityExecutionRecordSummary : Entity
/// Gets or sets the status of the activity.
/// </summary>
public ActivityStatus Status { get; set; }
/// <summary>
/// Gets or sets the aggregated count of faults encountered during the execution of the activity instance and its descendants.
/// </summary>
public int AggregateFaultCount { get; set; }
/// <summary>
/// Gets or sets the time at which the activity execution completed.
@ -63,7 +68,7 @@ public class ActivityExecutionRecordSummary : Entity
/// </summary>
public static ActivityExecutionRecordSummary FromRecord(ActivityExecutionRecord record)
{
return new ActivityExecutionRecordSummary
return new()
{
Id = record.Id,
WorkflowInstanceId = record.WorkflowInstanceId,
@ -75,7 +80,8 @@ public class ActivityExecutionRecordSummary : Entity
StartedAt = record.StartedAt,
HasBookmarks = record.HasBookmarks,
Status = record.Status,
CompletedAt = record.CompletedAt
AggregateFaultCount = record.AggregateFaultCount,
CompletedAt = record.CompletedAt,
};
}
@ -84,7 +90,7 @@ public class ActivityExecutionRecordSummary : Entity
/// </summary>
public static Expression<Func<ActivityExecutionRecord, ActivityExecutionRecordSummary>> FromRecordExpression()
{
return record => new ActivityExecutionRecordSummary
return record => new()
{
Id = record.Id,
WorkflowInstanceId = record.WorkflowInstanceId,
@ -96,6 +102,7 @@ public class ActivityExecutionRecordSummary : Entity
StartedAt = record.StartedAt,
HasBookmarks = record.HasBookmarks,
Status = record.Status,
AggregateFaultCount = record.AggregateFaultCount,
CompletedAt = record.CompletedAt
};
}

View file

@ -14,6 +14,9 @@ public class DeleteBookmarks(IBookmarkManager bookmarkManager) : INotificationHa
/// <inheritdoc />
public async Task HandleAsync(WorkflowInstancesDeleting notification, CancellationToken cancellationToken)
{
await bookmarkManager.DeleteManyAsync(new BookmarkFilter { WorkflowInstanceIds = notification.Ids }, cancellationToken);
await bookmarkManager.DeleteManyAsync(new()
{
WorkflowInstanceIds = notification.Ids
}, cancellationToken);
}
}

View file

@ -8,12 +8,12 @@ public class ActivityExecutionStats
/// <summary>
/// Gets or sets the ID of the activity.
/// </summary>
public string ActivityId { get; set; } = default!;
public string ActivityId { get; set; } = null!;
/// <summary>
/// Gets or sets the node ID of the activity.
/// </summary>
public string ActivityNodeId { get; set; } = default!;
public string ActivityNodeId { get; set; } = null!;
/// <summary>
/// Gets or sets the number of uncompleted executions.
@ -39,4 +39,9 @@ public class ActivityExecutionStats
/// Gets or sets a value whether this activity is faulted.
/// </summary>
public bool IsFaulted { get; set; }
/// <summary>
/// Gets or sets the total count of faults aggregated from the activity execution and its descendants.
/// </summary>
public int AggregateFaultCount { get; set; }
}

View file

@ -26,7 +26,7 @@ public class ActivityExecutionStatsService : IActivityExecutionStatsService
ActivityNodeIds = activityNodeIds?.ToList()
};
var order = new ActivityExecutionRecordOrder<DateTimeOffset>(x => x.StartedAt, OrderDirection.Ascending);
var records = (await _store.FindManyAsync(filter, order, cancellationToken)).ToList();
var records = (await _store.FindManySummariesAsync(filter, order, cancellationToken)).ToList();
var groupedRecords = records.GroupBy(x => x.ActivityNodeId).ToList();
var stats = groupedRecords.Select(grouping => new ActivityExecutionStats
{
@ -36,7 +36,8 @@ public class ActivityExecutionStatsService : IActivityExecutionStatsService
CompletedCount = grouping.Count(x => x.CompletedAt != null),
UncompletedCount = grouping.Count(x => x.CompletedAt == null),
IsBlocked = grouping.Any(x => x.HasBookmarks),
IsFaulted = grouping.Any(x => x.Status == ActivityStatus.Faulted)
IsFaulted = grouping.Any(x => x.Status == ActivityStatus.Faulted),
AggregateFaultCount = grouping.Last().AggregateFaultCount
}).ToList();
return stats;
@ -45,7 +46,7 @@ public class ActivityExecutionStatsService : IActivityExecutionStatsService
/// <inheritdoc />
public async Task<ActivityExecutionStats> GetStatsAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default)
{
var stats = (await GetStatsAsync(workflowInstanceId, new[] { activityNodeId }, cancellationToken)).FirstOrDefault();
var stats = (await GetStatsAsync(workflowInstanceId, [activityNodeId], cancellationToken)).FirstOrDefault();
return stats ?? new ActivityExecutionStats
{

View file

@ -13,7 +13,7 @@ public class BookmarksPersister(IBookmarkUpdater bookmarkUpdater, INotificationS
await bookmarkUpdater.UpdateBookmarksAsync(updateBookmarksRequest);
// Publish domain event.
await notificationSender.SendAsync(new WorkflowBookmarksIndexed(new IndexedWorkflowBookmarks(
await notificationSender.SendAsync(new WorkflowBookmarksIndexed(new(
updateBookmarksRequest.WorkflowExecutionContext,
updateBookmarksRequest.Diff.Added,
updateBookmarksRequest.Diff.Removed,

View file

@ -101,6 +101,7 @@ public class DefaultActivityExecutionMapper : IActivityExecutionMapper
StartedAt = source.StartedAt,
HasBookmarks = source.Bookmarks.Any(),
Status = source.Status,
AggregateFaultCount = source.AggregateFaultCount,
CompletedAt = source.CompletedAt
};
}