diff --git a/Elsa.sln b/Elsa.sln index 7527332fa..da59e9a5a 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -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} diff --git a/doc/adr/0001-record-architecture-decisions.md b/doc/adr/0001-record-architecture-decisions.md new file mode 100644 index 000000000..4deea1a9b --- /dev/null +++ b/doc/adr/0001-record-architecture-decisions.md @@ -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). diff --git a/doc/adr/0002-fault-propagation-from-child-to-parent-activities.md b/doc/adr/0002-fault-propagation-from-child-to-parent-activities.md new file mode 100644 index 000000000..3bd184d01 --- /dev/null +++ b/doc/adr/0002-fault-propagation-from-child-to-parent-activities.md @@ -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. diff --git a/doc/adr/0003-direct-bookmark-management-in-workflowexecutioncontext.md b/doc/adr/0003-direct-bookmark-management-in-workflowexecutioncontext.md new file mode 100644 index 000000000..755e5fdd5 --- /dev/null +++ b/doc/adr/0003-direct-bookmark-management-in-workflowexecutioncontext.md @@ -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. diff --git a/doc/adr/graph.dot b/doc/adr/graph.dot new file mode 100644 index 000000000..0e66155fa --- /dev/null +++ b/doc/adr/graph.dot @@ -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]; + } +} diff --git a/doc/adr/toc.md b/doc/adr/toc.md new file mode 100644 index 000000000..e5c8dac8d --- /dev/null +++ b/doc/adr/toc.md @@ -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) \ No newline at end of file diff --git a/docs/bounty-announcement-template.md b/doc/bounty/bounty-announcement-template.md similarity index 100% rename from docs/bounty-announcement-template.md rename to doc/bounty/bounty-announcement-template.md diff --git a/docs/bounty-footer.md b/doc/bounty/bounty-footer.md similarity index 100% rename from docs/bounty-footer.md rename to doc/bounty/bounty-footer.md diff --git a/docs/bounty-quality-standards.md b/doc/bounty/bounty-quality-standards.md similarity index 100% rename from docs/bounty-quality-standards.md rename to doc/bounty/bounty-quality-standards.md diff --git a/src/apps/Elsa.ServerAndStudio.Web/Activities/CustomDelay.cs b/src/apps/Elsa.ServerAndStudio.Web/Activities/CustomDelay.cs new file mode 100644 index 000000000..999848742 --- /dev/null +++ b/src/apps/Elsa.ServerAndStudio.Web/Activities/CustomDelay.cs @@ -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(); + } +} \ No newline at end of file diff --git a/src/apps/Elsa.ServerAndStudio.Web/Activities/CustomEvent.cs b/src/apps/Elsa.ServerAndStudio.Web/Activities/CustomEvent.cs new file mode 100644 index 000000000..e8fe63899 --- /dev/null +++ b/src/apps/Elsa.ServerAndStudio.Web/Activities/CustomEvent.cs @@ -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 +{ + protected override string GetEventName(ExpressionExecutionContext context) + { + return "MyEvent"; + } + + protected override void OnEventReceived(ActivityExecutionContext context, object? eventData) + { + Console.WriteLine("Event received with data: " + eventData); + } +} \ No newline at end of file diff --git a/src/apps/Elsa.ServerAndStudio.Web/Activities/CustomHttpEndpoint.cs b/src/apps/Elsa.ServerAndStudio.Web/Activities/CustomHttpEndpoint.cs new file mode 100644 index 000000000..5bf5d6433 --- /dev/null +++ b/src/apps/Elsa.ServerAndStudio.Web/Activities/CustomHttpEndpoint.cs @@ -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); + } +} \ No newline at end of file diff --git a/src/apps/Elsa.ServerAndStudio.Web/Activities/CustomTimer.cs b/src/apps/Elsa.ServerAndStudio.Web/Activities/CustomTimer.cs new file mode 100644 index 000000000..32336116c --- /dev/null +++ b/src/apps/Elsa.ServerAndStudio.Web/Activities/CustomTimer.cs @@ -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"); + } +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecordSummary.cs b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecordSummary.cs index e92491d04..b8734bc43 100644 --- a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecordSummary.cs +++ b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecordSummary.cs @@ -10,22 +10,22 @@ public class ActivityExecutionRecordSummary : Entity /// /// Gets or sets the workflow instance ID. /// - public string WorkflowInstanceId { get; set; } = default!; + public string WorkflowInstanceId { get; set; } = null!; /// /// Gets or sets the activity ID. /// - public string ActivityId { get; set; } = default!; + public string ActivityId { get; set; } = null!; /// /// Gets or sets the activity node ID. /// - public string ActivityNodeId { get; set; } = default!; + public string ActivityNodeId { get; set; } = null!; /// /// The type of the activity. /// - public string ActivityType { get; set; } = default!; + public string ActivityType { get; set; } = null!; /// /// The version of the activity type. @@ -62,7 +62,7 @@ public class ActivityExecutionRecordSummary : Entity /// public static ActivityExecutionRecordSummary FromRecord(ActivityExecutionRecord record) { - return new ActivityExecutionRecordSummary + return new() { Id = record.Id, WorkflowInstanceId = record.WorkflowInstanceId, diff --git a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionStats.cs b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionStats.cs index 0c282ff40..306078897 100644 --- a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionStats.cs +++ b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionStats.cs @@ -8,12 +8,12 @@ public class ActivityExecutionStats /// /// Gets or sets the ID of the activity. /// - public string ActivityId { get; set; } = default!; + public string ActivityId { get; set; } = null!; /// /// Gets or sets the node ID of the activity. /// - public string ActivityNodeId { get; set; } = default!; + public string ActivityNodeId { get; set; } = null!; /// /// 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. /// public bool IsFaulted { get; set; } + + /// + /// Gets or sets the total count of faults aggregated from the activity execution and its descendants. + /// + public int AggregateFaultCount { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.Dapper.Migrations/Runtime/V3_5.cs b/src/modules/Elsa.Dapper.Migrations/Runtime/V3_5.cs new file mode 100644 index 000000000..8f8ee478c --- /dev/null +++ b/src/modules/Elsa.Dapper.Migrations/Runtime/V3_5.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; +using FluentMigrator; +using JetBrains.Annotations; +using static System.Int32; + +namespace Elsa.Dapper.Migrations.Runtime; + +/// +[Migration(20006, "Elsa:Runtime:V3.5")] +[PublicAPI] +[SuppressMessage("ReSharper", "InconsistentNaming")] +public class V3_5 : Migration +{ + /// + public override void Up() + { + Alter.Table("ActivityExecutionRecords").AddColumn("AggregateFaultCount").AsInt32().NotNullable().WithDefault(0); + } + + /// + public override void Down() + { + Delete.Column("AggregateFaultCount").FromTable("ActivityExecutionRecords"); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20250320220046_V3_5.Designer.cs b/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20250401201339_V3_5.Designer.cs similarity index 99% rename from src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20250320220046_V3_5.Designer.cs rename to src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20250401201339_V3_5.Designer.cs index 45a7d4936..653eed2a9 100644 --- a/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20250320220046_V3_5.Designer.cs +++ b/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20250401201339_V3_5.Designer.cs @@ -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 { /// @@ -68,6 +68,9 @@ namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime b.Property("ActivityTypeVersion") .HasColumnType("int"); + b.Property("AggregateFaultCount") + .HasColumnType("int"); + b.Property("CompletedAt") .HasColumnType("datetime(6)"); diff --git a/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20250320220046_V3_5.cs b/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20250401201339_V3_5.cs similarity index 88% rename from src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20250320220046_V3_5.cs rename to src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20250401201339_V3_5.cs index 093e7d0e8..330933ca6 100644 --- a/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20250320220046_V3_5.cs +++ b/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20250401201339_V3_5.cs @@ -37,6 +37,14 @@ namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime nullable: true) .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.AddColumn( + 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", diff --git a/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs b/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs index 0eb0b18d5..a2a433bd3 100644 --- a/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs @@ -65,6 +65,9 @@ namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime b.Property("ActivityTypeVersion") .HasColumnType("int"); + b.Property("AggregateFaultCount") + .HasColumnType("int"); + b.Property("CompletedAt") .HasColumnType("datetime(6)"); diff --git a/src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/20250320220124_V3_5.Designer.cs b/src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/20250401201436_V3_5.Designer.cs similarity index 99% rename from src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/20250320220124_V3_5.Designer.cs rename to src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/20250401201436_V3_5.Designer.cs index 1f0eb0d72..0e013e890 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/20250320220124_V3_5.Designer.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/20250401201436_V3_5.Designer.cs @@ -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 { /// @@ -68,6 +68,9 @@ namespace Elsa.EntityFrameworkCore.Oracle.Migrations.Runtime b.Property("ActivityTypeVersion") .HasColumnType("NUMBER(10)"); + b.Property("AggregateFaultCount") + .HasColumnType("NUMBER(10)"); + b.Property("CompletedAt") .HasColumnType("TIMESTAMP(7) WITH TIME ZONE"); diff --git a/src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/20250320220124_V3_5.cs b/src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/20250401201436_V3_5.cs similarity index 86% rename from src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/20250320220124_V3_5.cs rename to src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/20250401201436_V3_5.cs index 94787e01b..961ca95c0 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/20250320220124_V3_5.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/20250401201436_V3_5.cs @@ -34,6 +34,14 @@ namespace Elsa.EntityFrameworkCore.Oracle.Migrations.Runtime type: "NVARCHAR2(450)", nullable: true); + migrationBuilder.AddColumn( + 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( name: "Name", schema: _schema.Schema, diff --git a/src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs b/src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs index 7f9e0a3a3..201722eb6 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Oracle/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs @@ -65,6 +65,9 @@ namespace Elsa.EntityFrameworkCore.Oracle.Migrations.Runtime b.Property("ActivityTypeVersion") .HasColumnType("NUMBER(10)"); + b.Property("AggregateFaultCount") + .HasColumnType("NUMBER(10)"); + b.Property("CompletedAt") .HasColumnType("TIMESTAMP(7) WITH TIME ZONE"); diff --git a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20250320220113_V3_5.Designer.cs b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20250401201425_V3_5.Designer.cs similarity index 99% rename from src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20250320220113_V3_5.Designer.cs rename to src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20250401201425_V3_5.Designer.cs index 210e0ba8a..eda09e542 100644 --- a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20250320220113_V3_5.Designer.cs +++ b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20250401201425_V3_5.Designer.cs @@ -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 { /// @@ -68,6 +68,9 @@ namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime b.Property("ActivityTypeVersion") .HasColumnType("integer"); + b.Property("AggregateFaultCount") + .HasColumnType("integer"); + b.Property("CompletedAt") .HasColumnType("timestamp with time zone"); diff --git a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20250320220113_V3_5.cs b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20250401201425_V3_5.cs similarity index 86% rename from src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20250320220113_V3_5.cs rename to src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20250401201425_V3_5.cs index 2aa5ff82c..317b48ac9 100644 --- a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20250320220113_V3_5.cs +++ b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20250401201425_V3_5.cs @@ -34,6 +34,14 @@ namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime type: "text", nullable: true); + migrationBuilder.AddColumn( + 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( name: "Name", schema: _schema.Schema, diff --git a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs index f8396725e..376e01225 100644 --- a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs @@ -65,6 +65,9 @@ namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime b.Property("ActivityTypeVersion") .HasColumnType("integer"); + b.Property("AggregateFaultCount") + .HasColumnType("integer"); + b.Property("CompletedAt") .HasColumnType("timestamp with time zone"); diff --git a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20250320220055_V3_5.Designer.cs b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20250401201357_V3_5.Designer.cs similarity index 99% rename from src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20250320220055_V3_5.Designer.cs rename to src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20250401201357_V3_5.Designer.cs index 083b6b862..9ddb19603 100644 --- a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20250320220055_V3_5.Designer.cs +++ b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20250401201357_V3_5.Designer.cs @@ -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 { /// @@ -68,6 +68,9 @@ namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime b.Property("ActivityTypeVersion") .HasColumnType("int"); + b.Property("AggregateFaultCount") + .HasColumnType("int"); + b.Property("CompletedAt") .HasColumnType("datetimeoffset"); diff --git a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20250320220055_V3_5.cs b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20250401201357_V3_5.cs similarity index 86% rename from src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20250320220055_V3_5.cs rename to src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20250401201357_V3_5.cs index 6c6f8e708..3f7cc0665 100644 --- a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20250320220055_V3_5.cs +++ b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20250401201357_V3_5.cs @@ -34,6 +34,14 @@ namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime type: "nvarchar(450)", nullable: true); + migrationBuilder.AddColumn( + 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( name: "Name", schema: _schema.Schema, diff --git a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs index ec71d5504..18a5a051d 100644 --- a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs @@ -65,6 +65,9 @@ namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime b.Property("ActivityTypeVersion") .HasColumnType("int"); + b.Property("AggregateFaultCount") + .HasColumnType("int"); + b.Property("CompletedAt") .HasColumnType("datetimeoffset"); diff --git a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20250320185742_V3_5.Designer.cs b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20250401201414_V3_5.Designer.cs similarity index 99% rename from src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20250320185742_V3_5.Designer.cs rename to src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20250401201414_V3_5.Designer.cs index e9c2c8fe1..f433f7562 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20250320185742_V3_5.Designer.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20250401201414_V3_5.Designer.cs @@ -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 { /// @@ -64,6 +64,9 @@ namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime b.Property("ActivityTypeVersion") .HasColumnType("INTEGER"); + b.Property("AggregateFaultCount") + .HasColumnType("INTEGER"); + b.Property("CompletedAt") .HasColumnType("TEXT"); diff --git a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20250320185742_V3_5.cs b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20250401201414_V3_5.cs similarity index 86% rename from src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20250320185742_V3_5.cs rename to src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20250401201414_V3_5.cs index d1536b879..3b0796080 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20250320185742_V3_5.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20250401201414_V3_5.cs @@ -34,6 +34,14 @@ namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime type: "TEXT", nullable: true); + migrationBuilder.AddColumn( + 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( name: "Name", schema: _schema.Schema, diff --git a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs index c813e2037..c3410fd5b 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs @@ -61,6 +61,9 @@ namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime b.Property("ActivityTypeVersion") .HasColumnType("INTEGER"); + b.Property("AggregateFaultCount") + .HasColumnType("INTEGER"); + b.Property("CompletedAt") .HasColumnType("TEXT"); diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Report/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Report/Models.cs index 0dd46e9e3..5efa1a69a 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Report/Models.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Report/Models.cs @@ -10,12 +10,12 @@ internal class Request /// /// The ID of the workflow instance to get the execution log for. /// - public string WorkflowInstanceId { get; set; } = default!; + public string WorkflowInstanceId { get; set; } = null!; /// /// The node IDs of the activities to get the execution records for. /// - public ICollection ActivityNodeIds { get; set; } = default!; + public ICollection ActivityNodeIds { get; set; } = null!; } internal class Response diff --git a/src/modules/Elsa.Workflows.Core/Activities/Fault.cs b/src/modules/Elsa.Workflows.Core/Activities/Fault.cs index 336c4ffe1..01ec3fe67 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Fault.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Fault.cs @@ -25,7 +25,7 @@ public class Fault : Activity /// 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. /// [Input(Description = "Code to categorize the fault.")] - public Input Code { get; set; } = default!; + public Input Code { get; set; } = null!; /// /// Category to categorize the fault. Examples: HTTP, Alteration, Azure, etc. /// [Input(Description = "Category to categorize the fault. Examples: HTTP, Alteration, Azure, etc.")] - public Input Category { get; set; } = default!; + public Input Category { get; set; } = null!; /// /// 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 FaultType { get; set; } = default!; + public Input FaultType { get; set; } = null!; /// /// The message to include with the fault. /// [Input(Description = "The message to include with the fault.")] - public Input Message { get; set; } = default!; + public Input Message { get; set; } = null!; /// protected override void Execute(ActivityExecutionContext context) diff --git a/src/modules/Elsa.Workflows.Core/Behaviors/AutoCompleteBehavior.cs b/src/modules/Elsa.Workflows.Core/Behaviors/AutoCompleteBehavior.cs index 838c9ec0e..3b6f4410b 100644 --- a/src/modules/Elsa.Workflows.Core/Behaviors/AutoCompleteBehavior.cs +++ b/src/modules/Elsa.Workflows.Core/Behaviors/AutoCompleteBehavior.cs @@ -1,8 +1,11 @@ +using JetBrains.Annotations; + namespace Elsa.Workflows.Behaviors; /// /// Automatically completes the currently executing activity. /// +[UsedImplicitly] public class AutoCompleteBehavior : Behavior { /// @@ -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(); diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs index f470c6984..c322e8c31 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs @@ -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 _bookmarks = []; private ActivityStatus _status; private Exception? _exception; private long _executionCount; private ActivityExecutionContext? _parentActivityExecutionContext; + + // Bookmarks created during the lifetime of this activity. + private List _newBookmarks = []; /// /// Initializes a new instance of the class. @@ -94,6 +95,11 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable /// public bool IsExecuting { get; set; } + /// + /// The number of faults encountered during the execution of the activity and its descendants. + /// + public int AggregateFaultCount { get; set; } + /// /// The workflow execution context. /// @@ -226,9 +232,14 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable public ISet Children { get; } = new HashSet(); /// - /// A list of bookmarks created by the current activity. + /// A list of bookmarks associated with the current activity. /// - public IReadOnlyCollection Bookmarks => new ReadOnlyCollection(_bookmarks); + public IEnumerable Bookmarks => WorkflowExecutionContext.Bookmarks.Where(x => x.ActivityInstanceId == Id); + + /// + /// A collection of bookmarks created during the execution of the activity. + /// + public IEnumerable NewBookmarks => _newBookmarks.AsReadOnly(); /// /// The number of times this has executed. @@ -418,7 +429,7 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable /// The bookmarks to add. public void AddBookmarks(IEnumerable bookmarks) { - _bookmarks.AddRange(bookmarks); + WorkflowExecutionContext.Bookmarks.AddRange(bookmarks); Taint(); } @@ -428,7 +439,8 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable /// The bookmark to add. 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 /// public void ClearBookmarks() { - _bookmarks.Clear(); + _newBookmarks.Clear(); + WorkflowExecutionContext.Bookmarks.RemoveWhere(x => x.ActivityInstanceId == Id); Taint(); } diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs index f229bee04..a141111ab 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs @@ -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 /// The type of storage driver to use for the variable. /// A callback to configure the memory block. /// The created . - public static Variable CreateVariable(this ActivityExecutionContext context, string name, object? value, Type? storageDriverType = null, Action? configure = null) => - context.ExpressionExecutionContext.CreateVariable(name, value, storageDriverType, configure); + public static Variable CreateVariable(this ActivityExecutionContext context, string name, object? value, Type? storageDriverType = null, Action? configure = null) + { + return context.ExpressionExecutionContext.CreateVariable(name, value, storageDriverType, configure); + } /// /// 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)); } + /// + /// Marks the current activity as faulted, records an exception, and transitions its status to . + /// An incident is logged, and the fault count for the activity and its ancestor activities is incremented. + /// + /// The representing the execution context of the current activity. + /// The exception to be recorded as the cause of the fault. + 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(); + 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); + } + + /// + /// Recovers the current activity from a faulted state by resetting fault counts and transitioning the activity to the running status. + /// + 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); + } + /// /// Returns the first context that is associated with a . /// diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs index e225376bc..a4eb9ba8e 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs @@ -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)) diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs index 60f4e3fec..51d5d8a41 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs @@ -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); - } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs b/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs index 3d3411af2..fb9c48f14 100644 --- a/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs +++ b/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs @@ -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, diff --git a/src/modules/Elsa.Workflows.Core/State/ActivityExecutionContextState.cs b/src/modules/Elsa.Workflows.Core/State/ActivityExecutionContextState.cs index 19a73d6dc..4c34a7d62 100644 --- a/src/modules/Elsa.Workflows.Core/State/ActivityExecutionContextState.cs +++ b/src/modules/Elsa.Workflows.Core/State/ActivityExecutionContextState.cs @@ -61,6 +61,11 @@ public class ActivityExecutionContextState /// public bool IsExecuting { get; set; } + /// + /// The number of faults recorded by this activity for itself and descendants. + /// + public int FaultCount { get; set; } + /// /// The time at which the activity execution began. /// diff --git a/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecord.cs b/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecord.cs index 429516fa8..08befb28b 100644 --- a/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecord.cs +++ b/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecord.cs @@ -12,22 +12,22 @@ public class ActivityExecutionRecord : Entity, ILogRecord /// /// Gets or sets the workflow instance ID. /// - public string WorkflowInstanceId { get; set; } = default!; + public string WorkflowInstanceId { get; set; } = null!; /// /// Gets or sets the activity ID. /// - public string ActivityId { get; set; } = default!; + public string ActivityId { get; set; } = null!; /// /// Gets or sets the activity node ID. /// - public string ActivityNodeId { get; set; } = default!; + public string ActivityNodeId { get; set; } = null!; /// /// The type of the activity. /// - public string ActivityType { get; set; } = default!; + public string ActivityType { get; set; } = null!; /// /// The version of the activity type. @@ -78,6 +78,11 @@ public class ActivityExecutionRecord : Entity, ILogRecord /// Gets or sets the status of the activity. /// public ActivityStatus Status { get; set; } + + /// + /// Gets or sets the aggregated count of faults encountered during the execution of the activity instance and its descendants. + /// + public int AggregateFaultCount { get; set; } /// /// Gets or sets the time at which the activity execution completed. diff --git a/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecordSummary.cs b/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecordSummary.cs index 9803c5bca..963ba31ed 100644 --- a/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecordSummary.cs +++ b/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecordSummary.cs @@ -11,22 +11,22 @@ public class ActivityExecutionRecordSummary : Entity /// /// Gets or sets the workflow instance ID. /// - public string WorkflowInstanceId { get; set; } = default!; + public string WorkflowInstanceId { get; set; } = null!; /// /// Gets or sets the activity ID. /// - public string ActivityId { get; set; } = default!; + public string ActivityId { get; set; } = null!; /// /// Gets or sets the activity node ID. /// - public string ActivityNodeId { get; set; } = default!; + public string ActivityNodeId { get; set; } = null!; /// /// The type of the activity. /// - public string ActivityType { get; set; } = default!; + public string ActivityType { get; set; } = null!; /// /// The version of the activity type. @@ -52,6 +52,11 @@ public class ActivityExecutionRecordSummary : Entity /// Gets or sets the status of the activity. /// public ActivityStatus Status { get; set; } + + /// + /// Gets or sets the aggregated count of faults encountered during the execution of the activity instance and its descendants. + /// + public int AggregateFaultCount { get; set; } /// /// Gets or sets the time at which the activity execution completed. @@ -63,7 +68,7 @@ public class ActivityExecutionRecordSummary : Entity /// 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 /// public static Expression> 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 }; } diff --git a/src/modules/Elsa.Workflows.Runtime/Handlers/DeleteBookmarks.cs b/src/modules/Elsa.Workflows.Runtime/Handlers/DeleteBookmarks.cs index 47a8629c7..ca687f3c3 100644 --- a/src/modules/Elsa.Workflows.Runtime/Handlers/DeleteBookmarks.cs +++ b/src/modules/Elsa.Workflows.Runtime/Handlers/DeleteBookmarks.cs @@ -14,6 +14,9 @@ public class DeleteBookmarks(IBookmarkManager bookmarkManager) : INotificationHa /// 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); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Models/ActivityExecutionStats.cs b/src/modules/Elsa.Workflows.Runtime/Models/ActivityExecutionStats.cs index ebf4dbe98..859efc83f 100644 --- a/src/modules/Elsa.Workflows.Runtime/Models/ActivityExecutionStats.cs +++ b/src/modules/Elsa.Workflows.Runtime/Models/ActivityExecutionStats.cs @@ -8,12 +8,12 @@ public class ActivityExecutionStats /// /// Gets or sets the ID of the activity. /// - public string ActivityId { get; set; } = default!; + public string ActivityId { get; set; } = null!; /// /// Gets or sets the node ID of the activity. /// - public string ActivityNodeId { get; set; } = default!; + public string ActivityNodeId { get; set; } = null!; /// /// 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. /// public bool IsFaulted { get; set; } + + /// + /// Gets or sets the total count of faults aggregated from the activity execution and its descendants. + /// + public int AggregateFaultCount { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/ActivityExecutionService.cs b/src/modules/Elsa.Workflows.Runtime/Services/ActivityExecutionService.cs index d55875e4a..2a57bbfab 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/ActivityExecutionService.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/ActivityExecutionService.cs @@ -26,7 +26,7 @@ public class ActivityExecutionStatsService : IActivityExecutionStatsService ActivityNodeIds = activityNodeIds?.ToList() }; var order = new ActivityExecutionRecordOrder(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 /// public async Task 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 { diff --git a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkPersister.cs b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkPersister.cs index 4531843f1..c7ef47766 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkPersister.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkPersister.cs @@ -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, diff --git a/src/modules/Elsa.Workflows.Runtime/Services/DefaultActivityExecutionMapper.cs b/src/modules/Elsa.Workflows.Runtime/Services/DefaultActivityExecutionMapper.cs index 4457225f6..10ae2747f 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/DefaultActivityExecutionMapper.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/DefaultActivityExecutionMapper.cs @@ -101,6 +101,7 @@ public class DefaultActivityExecutionMapper : IActivityExecutionMapper StartedAt = source.StartedAt, HasBookmarks = source.Bookmarks.Any(), Status = source.Status, + AggregateFaultCount = source.AggregateFaultCount, CompletedAt = source.CompletedAt }; }