diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 143a3c452..3c0b37617 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -59,28 +59,28 @@ jobs: else echo "VERSION=${{env.base_version}}-${PACKAGE_PREFIX}.${{github.run_number}}" >> $GITHUB_ENV fi -# - name: Set up JDK 17 -# uses: actions/setup-java@v2 -# with: -# java-version: '17' -# distribution: 'adopt' + # - name: Set up JDK 17 + # uses: actions/setup-java@v2 + # with: + # java-version: '17' + # distribution: 'adopt' - uses: actions/setup-dotnet@v4 with: dotnet-version: 9.x -# - name: Install SonarScanner for .NET -# run: dotnet tool install --global dotnet-sonarscanner -# - name: Install Coverlet for code coverage -# run: dotnet tool install --global coverlet.console -# - name: Begin SonarCloud analysis -# env: -# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -# run: dotnet sonarscanner begin /k:"elsa-workflows_elsa-core" /o:"elsa-workflows" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.exclusions=**/obj/**,**/*.dll,build/**,samples/**,src/apps/** /d:"sonar.verbose=true" /d:sonar.cs.opencover.reportsPaths=**/testresults/**/coverage.opencover.xml + # - name: Install SonarScanner for .NET + # run: dotnet tool install --global dotnet-sonarscanner + # - name: Install Coverlet for code coverage + # run: dotnet tool install --global coverlet.console + # - name: Begin SonarCloud analysis + # env: + # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + # run: dotnet sonarscanner begin /k:"elsa-workflows_elsa-core" /o:"elsa-workflows" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.exclusions=**/obj/**,**/*.dll,build/**,samples/**,src/apps/** /d:"sonar.verbose=true" /d:sonar.cs.opencover.reportsPaths=**/testresults/**/coverage.opencover.xml - name: Compile+Test+Pack run: ./build.sh Compile+Test+Pack --version ${VERSION} --analyseCode true -# - name: End SonarCloud analysis -# env: -# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -# run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" + # - name: End SonarCloud analysis + # env: + # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + # run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/Directory.Packages.props b/Directory.Packages.props index 24aeb0565..9fd4a9c79 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,199 +1,206 @@ - - true - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index 2818a232f..4035178eb 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -212,7 +212,7 @@ services else if (sqlDatabaseProvider == SqlDatabaseProvider.Citus) ef.UsePostgreSql(citusConnectionString); else if (sqlDatabaseProvider == SqlDatabaseProvider.YugabyteDb) - ef.UsePostgreSql(yugabyteDbConnectionString, configure: dbContextOptions => dbContextOptions.With()); + ef.UsePostgreSql(yugabyteDbConnectionString); #if !NET9_0 else if (sqlDatabaseProvider == SqlDatabaseProvider.MySql) ef.UseMySql(mySqlConnectionString); diff --git a/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj b/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj index f8246f150..ef83a5aee 100644 --- a/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj +++ b/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj @@ -17,4 +17,8 @@ + + + + diff --git a/src/clients/Elsa.Api.Client/Helpers/RefitSettingsHelper.cs b/src/clients/Elsa.Api.Client/Helpers/RefitSettingsHelper.cs index 8a87220a3..446b012ec 100644 --- a/src/clients/Elsa.Api.Client/Helpers/RefitSettingsHelper.cs +++ b/src/clients/Elsa.Api.Client/Helpers/RefitSettingsHelper.cs @@ -15,7 +15,10 @@ public static class RefitSettingsHelper /// public static RefitSettings CreateRefitSettings(IServiceProvider serviceProvider, Action? configureJsonSerializerOptions = null) { - var settings = new RefitSettings { ContentSerializer = new SystemTextJsonContentSerializer(CreateJsonSerializerOptions(serviceProvider, configureJsonSerializerOptions)) }; + var settings = new RefitSettings + { + ContentSerializer = new SystemTextJsonContentSerializer(CreateJsonSerializerOptions(serviceProvider, configureJsonSerializerOptions)) + }; return settings; } diff --git a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationWorkflowInstanceFilter.cs b/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationWorkflowInstanceFilter.cs index 6f4419114..e741c240e 100644 --- a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationWorkflowInstanceFilter.cs +++ b/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationWorkflowInstanceFilter.cs @@ -1,3 +1,4 @@ +using Elsa.Api.Client.Resources.WorkflowInstances.Enums; using Elsa.Api.Client.Shared.Models; namespace Elsa.Api.Client.Resources.Alterations.Models; @@ -7,6 +8,11 @@ namespace Elsa.Api.Client.Resources.Alterations.Models; /// public class AlterationWorkflowInstanceFilter { + /// + /// If the filter is empty, all records are matched. + /// + public bool EmptyFilterSelectsAll { get; set; } + /// /// The IDs of the workflow instances that this plan applies to. /// @@ -16,11 +22,26 @@ public class AlterationWorkflowInstanceFilter /// The correlation IDs of the workflow instances that this plan applies to. /// public IEnumerable? CorrelationIds { get; set; } + + /// + /// A collection of names associated with the workflow instances being filtered. + /// + public ICollection? Names { get; set; } + + /// + /// A search term used to filter workflow instances based on matching criteria. + /// + public string? SearchTerm { get; set; } /// /// A collection of timestamp filters used for filtering data based on specified timestamp columns and operators. /// public IEnumerable? TimestampFilters { get; set; } + + /// + /// The IDs of the workflow definitions that this plan applies to. + /// + public ICollection? DefinitionIds { get; set; } /// /// The IDs of the workflow definitions that this plan applies to. @@ -37,6 +58,16 @@ public class AlterationWorkflowInstanceFilter /// public bool? IsSystem { get; set; } = false; + /// + /// Represents the workflow statuses included in the filter. + /// + public ICollection? Statuses { get; set; } + + /// + /// A collection of sub-statuses used to filter workflow instances by their specific sub-state. + /// + public ICollection? SubStatuses { get; set; } + /// /// Represents a collection of filters for activities. /// diff --git a/src/modules/Elsa.Alterations.Core/Models/AlterationWorkflowInstanceFilter.cs b/src/modules/Elsa.Alterations.Core/Models/AlterationWorkflowInstanceFilter.cs index de83e2c32..51f2cd680 100644 --- a/src/modules/Elsa.Alterations.Core/Models/AlterationWorkflowInstanceFilter.cs +++ b/src/modules/Elsa.Alterations.Core/Models/AlterationWorkflowInstanceFilter.cs @@ -1,3 +1,4 @@ +using Elsa.Workflows; using Elsa.Workflows.Management.Models; using JetBrains.Annotations; @@ -9,6 +10,11 @@ namespace Elsa.Alterations.Core.Models; [UsedImplicitly] public class AlterationWorkflowInstanceFilter { + /// + /// If the filter is empty, all records are matched. + /// + public bool EmptyFilterSelectsAll { get; set; } + /// /// The IDs of the workflow instances that this plan applies to. /// @@ -19,11 +25,26 @@ public class AlterationWorkflowInstanceFilter /// public IEnumerable? CorrelationIds { get; set; } + /// + /// A collection of names associated with the workflow instances being filtered. + /// + public ICollection? Names { get; set; } + + /// + /// A search term used to filter workflow instances based on matching criteria. + /// + public string? SearchTerm { get; set; } + /// /// A collection of timestamp filters used for filtering data based on specified timestamp columns and operators. /// public IEnumerable? TimestampFilters { get; set; } + /// + /// The IDs of the workflow definitions that this plan applies to. + /// + public ICollection? DefinitionIds { get; set; } + /// /// The IDs of the workflow definitions that this plan applies to. /// @@ -38,6 +59,16 @@ public class AlterationWorkflowInstanceFilter /// Whether the workflow instances to match are system workflows. Defaults to false. /// public bool? IsSystem { get; set; } = false; + + /// + /// Represents the workflow statuses included in the filter. + /// + public ICollection? Statuses { get; set; } + + /// + /// A collection of sub-statuses used to filter workflow instances by their specific sub-state. + /// + public ICollection? SubStatuses { get; set; } /// /// Represents a collection of filters for activities. diff --git a/src/modules/Elsa.Alterations.Core/Serialization/AlterationSerializationOptionConfigurator.cs b/src/modules/Elsa.Alterations.Core/Serialization/AlterationSerializationOptionConfigurator.cs index d9de788bf..217331a5f 100644 --- a/src/modules/Elsa.Alterations.Core/Serialization/AlterationSerializationOptionConfigurator.cs +++ b/src/modules/Elsa.Alterations.Core/Serialization/AlterationSerializationOptionConfigurator.cs @@ -34,7 +34,7 @@ public class AlterationSerializationOptionConfigurator(IOptions new ActivityExecutionRecordFilter { @@ -33,9 +37,10 @@ public class WorkflowInstanceFinder(IWorkflowInstanceStore workflowInstanceStore Status = x.Status, }).ToList(); + var emptyFilterSelectsAll = filter.EmptyFilterSelectsAll; var workflowInstanceFilterIsEmpty = WorkflowFilterIsEmpty(workflowInstanceFilter); - var workflowInstanceIds = workflowInstanceFilterIsEmpty + var workflowInstanceIds = workflowInstanceFilterIsEmpty && !emptyFilterSelectsAll ? Enumerable.Empty().ToHashSet() : (await workflowInstanceStore.FindManyIdsAsync(workflowInstanceFilter, cancellationToken)).ToHashSet(); diff --git a/src/modules/Elsa.Alterations/Activities/CompleteAlterationPlan.cs b/src/modules/Elsa.Alterations/Activities/CompleteAlterationPlan.cs index b63cd9715..17ec70101 100644 --- a/src/modules/Elsa.Alterations/Activities/CompleteAlterationPlan.cs +++ b/src/modules/Elsa.Alterations/Activities/CompleteAlterationPlan.cs @@ -13,7 +13,7 @@ namespace Elsa.Alterations.Activities; /// Marks an alteration plan as completed. /// [Browsable(false)] -[Activity("Elsa", "Alterations", "Dispatches jobs for the specified Alteration Plan", Kind = ActivityKind.Job)] +[Activity("Elsa", "Alterations", "Dispatches jobs for the specified Alteration Plan", Kind = ActivityKind.Task)] public class CompleteAlterationPlan : CodeActivity { /// diff --git a/src/modules/Elsa.Alterations/Activities/DispatchAlterationJobs.cs b/src/modules/Elsa.Alterations/Activities/DispatchAlterationJobs.cs index a8c2c6fe5..a22a45652 100644 --- a/src/modules/Elsa.Alterations/Activities/DispatchAlterationJobs.cs +++ b/src/modules/Elsa.Alterations/Activities/DispatchAlterationJobs.cs @@ -15,7 +15,7 @@ namespace Elsa.Alterations.Activities; /// Submits an alteration plan for execution. /// [Browsable(false)] -[Activity("Elsa", "Alterations", "Dispatches jobs for the specified Alteration Plan", Kind = ActivityKind.Job)] +[Activity("Elsa", "Alterations", "Dispatches jobs for the specified Alteration Plan", Kind = ActivityKind.Task)] public class DispatchAlterationJobs : CodeActivity { /// diff --git a/src/modules/Elsa.Alterations/Activities/GenerateAlterationJobs.cs b/src/modules/Elsa.Alterations/Activities/GenerateAlterationJobs.cs index 9abda6ede..be949b0ee 100644 --- a/src/modules/Elsa.Alterations/Activities/GenerateAlterationJobs.cs +++ b/src/modules/Elsa.Alterations/Activities/GenerateAlterationJobs.cs @@ -19,7 +19,7 @@ namespace Elsa.Alterations.Activities; /// Submits an alteration plan for execution. /// [Browsable(false)] -[Activity("Elsa", "Alterations", "Generates jobs for the specified Alteration Plan", Kind = ActivityKind.Job)] +[Activity("Elsa", "Alterations", "Generates jobs for the specified Alteration Plan", Kind = ActivityKind.Task)] public class GenerateAlterationJobs : CodeActivity { /// diff --git a/src/modules/Elsa.Alterations/AlterationHandlers/CancelHandler.cs b/src/modules/Elsa.Alterations/AlterationHandlers/CancelHandler.cs new file mode 100644 index 000000000..38bc32404 --- /dev/null +++ b/src/modules/Elsa.Alterations/AlterationHandlers/CancelHandler.cs @@ -0,0 +1,22 @@ +using Elsa.Alterations.AlterationTypes; +using Elsa.Alterations.Core.Abstractions; +using Elsa.Alterations.Core.Contexts; +using JetBrains.Annotations; + +namespace Elsa.Alterations.AlterationHandlers; + +/// +/// Upgrades the version of the workflow instance. +/// +[UsedImplicitly] +public class CancelHandler : AlterationHandlerBase +{ + /// + protected override ValueTask HandleAsync(AlterationContext context, Cancel alteration) + { + context.WorkflowExecutionContext.Cancel(); + + context.Succeed(); + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Alterations/AlterationTypes/Cancel.cs b/src/modules/Elsa.Alterations/AlterationTypes/Cancel.cs new file mode 100644 index 000000000..c0a5f7fde --- /dev/null +++ b/src/modules/Elsa.Alterations/AlterationTypes/Cancel.cs @@ -0,0 +1,10 @@ +using Elsa.Alterations.Core.Abstractions; +using JetBrains.Annotations; + +namespace Elsa.Alterations.AlterationTypes; + +/// +/// Cancels the workflow instances in an alteration plan. +/// +[UsedImplicitly] +public class Cancel : AlterationBase; \ No newline at end of file diff --git a/src/modules/Elsa.Alterations/Endpoints/Alterations/DryRun/Endpoint.cs b/src/modules/Elsa.Alterations/Endpoints/Alterations/DryRun/Endpoint.cs index cbf0d5471..f40175522 100644 --- a/src/modules/Elsa.Alterations/Endpoints/Alterations/DryRun/Endpoint.cs +++ b/src/modules/Elsa.Alterations/Endpoints/Alterations/DryRun/Endpoint.cs @@ -22,8 +22,6 @@ public class DryRun(IWorkflowInstanceFinder workflowInstanceFinder) : ElsaEndpoi public override async Task HandleAsync(AlterationWorkflowInstanceFilter filter, CancellationToken cancellationToken) { var workflowInstanceIds = await workflowInstanceFinder.FindAsync(filter, cancellationToken); - - // Write response. var response = new Response(workflowInstanceIds.ToList()); await SendOkAsync(response, cancellationToken); } diff --git a/src/modules/Elsa.Alterations/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Alterations/Extensions/ServiceCollectionExtensions.cs index 76a6818b8..b930d2878 100644 --- a/src/modules/Elsa.Alterations/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Alterations/Extensions/ServiceCollectionExtensions.cs @@ -20,6 +20,7 @@ public static class ServiceCollectionExtensions services.AddAlteration(); services.AddAlteration(); services.AddAlteration(); + services.AddAlteration(); services.AddNotificationHandlersFrom(); return services; } diff --git a/src/modules/Elsa.Dapper/Elsa.Dapper.csproj b/src/modules/Elsa.Dapper/Elsa.Dapper.csproj index aaceef3cf..6bdc0eaca 100644 --- a/src/modules/Elsa.Dapper/Elsa.Dapper.csproj +++ b/src/modules/Elsa.Dapper/Elsa.Dapper.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/Extensions/BulkUpsertExtensions.cs b/src/modules/Elsa.EntityFrameworkCore.Common/Extensions/BulkUpsertExtensions.cs new file mode 100644 index 000000000..810b43ac9 --- /dev/null +++ b/src/modules/Elsa.EntityFrameworkCore.Common/Extensions/BulkUpsertExtensions.cs @@ -0,0 +1,403 @@ +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using System.Linq.Expressions; + +// ReSharper disable once CheckNamespace +namespace Elsa.EntityFrameworkCore.Extensions; + +/// +/// Provides extension methods to perform bulk upsert operations for entities +/// in an Entity Framework Core context, supporting multiple database providers. +/// +public static class BulkUpsertExtensions +{ + /// + /// Performs a bulk upsert operation on a list of entities in the specified database context using a key selector. + /// + /// The type of the database context. + /// The type of the entity being upserted. + /// The database context where the bulk upsert operation will be executed. + /// The list of entities to be upserted. + /// An expression used to determine the key for upsert operations. + /// A token to observe while waiting for the operation to complete. + public static async Task BulkUpsertAsync( + this TDbContext dbContext, + IList entities, + Expression> keySelector, + CancellationToken cancellationToken = default) + where TDbContext : DbContext + where TEntity : class, new() + { + await BulkUpsertAsync(dbContext, entities, keySelector, 50, cancellationToken); + } + + /// + /// Performs a bulk upsert operation on a list of entities in the specified database context using a key selector and optional batch size. + /// + /// The type of the database context. + /// The type of the entity being upserted. + /// The database context where the bulk upsert operation will be executed. + /// The list of entities to be upserted. + /// An expression used to determine the key for upsert operations. + /// The size of each batch for processing the upsert operation. Defaults to 50. + /// A token to observe while waiting for the operation to complete. + /// Thrown if the database provider for the context is not supported. + public static async Task BulkUpsertAsync( + this TDbContext dbContext, + IList entities, + Expression> keySelector, + int batchSize = 50, + CancellationToken cancellationToken = default) + where TDbContext : DbContext + where TEntity : class, new() + { + if (entities.Count == 0) + return; + + // Identify the current provider (e.g., "Microsoft.EntityFrameworkCore.SqlServer") + var providerName = dbContext.Database.ProviderName?.ToLowerInvariant() ?? string.Empty; + + // Determine the method for generating SQL based on the provider + Func, Expression>, (string, object[])> generateSql = providerName switch + { + var pn when pn.Contains("sqlserver") => GenerateSqlServerUpsert, + var pn when pn.Contains("sqlite") => GenerateSqliteUpsert, + var pn when pn.Contains("postgres") => GeneratePostgresUpsert, + var pn when pn.Contains("mysql") => GenerateMySqlUpsert, + var pn when pn.Contains("oracle") => GenerateOracleUpsert, + _ => throw new NotSupportedException($"Provider '{providerName}' is not supported.") + }; + + // Loop through batched entities + foreach (var batch in entities.Chunk(batchSize)) + { + // Generate SQL and parameters + var (sql, parameters) = generateSql(dbContext, batch, keySelector); + + await dbContext.Database.ExecuteSqlRawAsync(sql, parameters, cancellationToken); + } + } + + private static (string, object[]) GenerateSqlServerUpsert( + DbContext dbContext, + IList entities, + Expression> keySelector) + where TEntity : class + { + var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!; + var tableName = $"[{entityType.GetSchema()}].[{entityType.GetTableName()}]"; + var storeObject = StoreObjectIdentifier.Table(entityType.GetTableName()!, entityType.GetSchema()); + + // Include shadow properties + var props = entityType.GetProperties().ToList(); + + var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!; + var keyColumnName = $"[{keyProp.GetColumnName(storeObject)}]"; + var columnNames = props + .Select(p => $"[{p.GetColumnName(storeObject)}]") + .ToList(); + + var mergeSql = new StringBuilder(); + mergeSql.AppendLine($"MERGE {tableName} AS Target"); + mergeSql.AppendLine("USING (VALUES"); + + var parameters = new List(); + var parameterCount = 0; + + for (var i = 0; i < entities.Count; i++) + { + var entity = entities[i]; + var values = new List(); + + foreach (var property in props) + { + var paramName = $"{{{parameterCount++}}}"; + + // If it's a shadow property, retrieve value via Entry(..).Property(..) + object? value = property.IsShadowProperty() + ? dbContext.Entry(entity).Property(property.Name).CurrentValue + : property.PropertyInfo?.GetValue(entity); + + var converter = property.GetTypeMapping().Converter; + if (converter != null) + value = converter.ConvertToProvider(value); + + values.Add(paramName); + parameters.Add(value); + } + + var line = $"({string.Join(", ", values)}){(i < entities.Count - 1 ? "," : string.Empty)}"; + mergeSql.AppendLine(line); + } + + mergeSql.AppendLine($") AS Source ({string.Join(", ", columnNames)})"); + mergeSql.AppendLine($"ON Target.{keyColumnName} = Source.{keyColumnName}"); + mergeSql.AppendLine("WHEN MATCHED THEN"); + mergeSql.AppendLine($" UPDATE SET {string.Join(", ", columnNames.Where(c => c != keyColumnName).Select(c => $"Target.{c} = Source.{c}"))}"); + mergeSql.AppendLine("WHEN NOT MATCHED THEN"); + mergeSql.AppendLine($" INSERT ({string.Join(", ", columnNames)})"); + mergeSql.AppendLine($" VALUES ({string.Join(", ", columnNames.Select(c => $"Source.{c}"))});"); + + return (mergeSql.ToString(), parameters.ToArray()); + } + + private static (string, object[]) GenerateSqliteUpsert( + DbContext dbContext, + IList entities, + Expression> keySelector) + where TEntity : class + { + var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!; + var tableName = entityType.GetTableName(); + var storeObject = StoreObjectIdentifier.Table(tableName!, entityType.GetSchema()); + + var props = entityType.GetProperties().ToList(); + + var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!; + var keyColumnName = keyProp.GetColumnName(storeObject); + var columnNames = props + .Select(p => p.GetColumnName(storeObject)!) + .ToList(); + + var sb = new StringBuilder(); + var parameters = new List(); + var parameterCount = 0; + + sb.Append($"INSERT INTO \"{tableName}\" ({string.Join(", ", columnNames.Select(c => $"\"{c}\""))}) VALUES "); + + for (var i = 0; i < entities.Count; i++) + { + var entity = entities[i]; + var placeholders = new List(); + + foreach (var property in props) + { + var paramName = $"{{{parameterCount++}}}"; + + object? value = property.IsShadowProperty() + ? dbContext.Entry(entity).Property(property.Name).CurrentValue + : property.PropertyInfo?.GetValue(entity); + + var converter = property.GetTypeMapping().Converter; + if (converter != null) + value = converter.ConvertToProvider(value); + + placeholders.Add(paramName); + parameters.Add(value); + } + + sb.Append($"({string.Join(", ", placeholders)})"); + if (i < entities.Count - 1) + sb.Append(", "); + } + + sb.AppendLine(); + sb.AppendLine($"ON CONFLICT(\"{keyColumnName}\") DO UPDATE SET"); + + var updateAssignments = columnNames + .Where(c => c != keyColumnName) + .Select(c => $"\"{c}\"=excluded.\"{c}\""); + + sb.AppendLine(string.Join(", ", updateAssignments) + ";"); + + return (sb.ToString(), parameters.ToArray()); + } + + private static (string, object[]) GeneratePostgresUpsert( + DbContext dbContext, + IList entities, + Expression> keySelector) + where TEntity : class + { + var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!; + var tableName = entityType.GetTableName(); + var storeObject = StoreObjectIdentifier.Table(tableName!, entityType.GetSchema()); + + var props = entityType.GetProperties().ToList(); + + var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!; + var keyColumnName = keyProp.GetColumnName(storeObject); + var columnNames = props + .Select(p => p.GetColumnName(storeObject)!) + .ToList(); + + var sb = new StringBuilder(); + var parameters = new List(); + var parameterCount = 0; + + sb.Append($"INSERT INTO \"{storeObject.Schema}\".\"{storeObject.Name}\" ({string.Join(", ", columnNames.Select(c => $"\"{c}\""))}) VALUES "); + + for (var i = 0; i < entities.Count; i++) + { + var entity = entities[i]; + var placeholders = new List(); + + foreach (var property in props) + { + var paramName = $"{{{parameterCount++}}}"; + + object? value = property.IsShadowProperty() + ? dbContext.Entry(entity).Property(property.Name).CurrentValue + : property.PropertyInfo?.GetValue(entity); + + var converter = property.GetTypeMapping().Converter; + if (converter != null) + value = converter.ConvertToProvider(value); + + placeholders.Add(paramName); + parameters.Add(value); + } + + sb.Append($"({string.Join(", ", placeholders)})"); + if (i < entities.Count - 1) + sb.Append(", "); + } + + sb.AppendLine(); + sb.AppendLine($"ON CONFLICT (\"{keyColumnName}\") DO UPDATE SET"); + + var updateAssignments = columnNames + .Where(c => c != keyColumnName) + .Select(c => $"\"{c}\" = EXCLUDED.\"{c}\""); + + sb.AppendLine(string.Join(", ", updateAssignments) + ";"); + + return (sb.ToString(), parameters.ToArray()); + } + + private static (string, object[]) GenerateMySqlUpsert( + DbContext dbContext, + IList entities, + Expression> keySelector) + where TEntity : class + { + var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!; + var tableName = entityType.GetTableName(); + var storeObject = StoreObjectIdentifier.Table(tableName!, entityType.GetSchema()); + + var props = entityType.GetProperties().ToList(); + + var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!; + var keyColumnName = keyProp.GetColumnName(storeObject); + var columnNames = props + .Select(p => p.GetColumnName(storeObject)!) + .ToList(); + + var sb = new StringBuilder(); + var parameters = new List(); + var parameterCount = 0; + + sb.Append($"INSERT INTO `{tableName}` ({string.Join(", ", columnNames.Select(c => $"`{c}`"))}) VALUES "); + + for (var i = 0; i < entities.Count; i++) + { + var entity = entities[i]; + var placeholders = new List(); + + foreach (var property in props) + { + var paramName = $"{{{parameterCount++}}}"; + + object? value = property.IsShadowProperty() + ? dbContext.Entry(entity).Property(property.Name).CurrentValue + : property.PropertyInfo?.GetValue(entity); + + var converter = property.GetTypeMapping().Converter; + if (converter != null) + value = converter.ConvertToProvider(value); + + placeholders.Add(paramName); + parameters.Add(value); + } + + sb.Append($"({string.Join(", ", placeholders)})"); + if (i < entities.Count - 1) + sb.Append(", "); + } + + sb.AppendLine(); + sb.AppendLine("ON DUPLICATE KEY UPDATE"); + + var updateAssignments = columnNames + .Where(c => c != keyColumnName) + .Select(c => $"`{c}` = VALUES(`{c}`)"); + + sb.AppendLine(string.Join(", ", updateAssignments) + ";"); + + return (sb.ToString(), parameters.ToArray()); + } + + private static (string, object[]) GenerateOracleUpsert( + DbContext dbContext, + IList entities, + Expression> keySelector) + where TEntity : class + { + var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!; + var schema = entityType.GetSchema(); + var tableName = entityType.GetTableName(); + var storeObject = StoreObjectIdentifier.Table(tableName!, schema); + var fullName = !string.IsNullOrEmpty(schema) ? $"{schema}.{tableName}" : tableName; + + var props = entityType.GetProperties().ToList(); + + var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!; + var keyColumnName = keyProp.GetColumnName(storeObject); + + var columnNames = props + .Select(p => p.GetColumnName(storeObject)!) + .ToList(); + + var sb = new StringBuilder(); + var parameters = new List(); + var parameterCount = 0; + + sb.AppendLine($"MERGE INTO {fullName} Target"); + sb.AppendLine("USING (SELECT"); + + for (var i = 0; i < entities.Count; i++) + { + var entity = entities[i]; + var lineParts = new List(); + + foreach (var property in props) + { + var paramName = $"{{{parameterCount++}}}"; + + object? value = property.IsShadowProperty() + ? dbContext.Entry(entity).Property(property.Name).CurrentValue + : property.PropertyInfo?.GetValue(entity); + + var converter = property.GetTypeMapping().Converter; + if (converter != null) + value = converter.ConvertToProvider(value); + + parameters.Add(value); + + // Oracle aliases must match the column name + var alias = property.GetColumnName(storeObject); + lineParts.Add($"{paramName} AS {alias}"); + } + + // Comma if not last + var suffix = (i < entities.Count - 1) ? " FROM DUAL UNION ALL SELECT" : " FROM DUAL"; + sb.AppendLine(string.Join(", ", lineParts) + suffix); + } + + sb.AppendLine($") Source ON (Target.{keyColumnName} = Source.{keyColumnName})"); + sb.AppendLine("WHEN MATCHED THEN UPDATE SET"); + + var updateSetClauses = columnNames + .Where(c => c != keyColumnName) + .Select(c => $"Target.{c} = Source.{c}"); + + sb.AppendLine(string.Join(", ", updateSetClauses)); + sb.AppendLine("WHEN NOT MATCHED THEN"); + sb.AppendLine($"INSERT ({string.Join(", ", columnNames)})"); + sb.AppendLine($"VALUES ({string.Join(", ", columnNames.Select(c => $"Source.{c}"))});"); + + return (sb.ToString(), parameters.ToArray()); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/Extensions/QueryableExtensions.cs b/src/modules/Elsa.EntityFrameworkCore.Common/Extensions/QueryableExtensions.cs index 191378a4b..11171ace7 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Common/Extensions/QueryableExtensions.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Common/Extensions/QueryableExtensions.cs @@ -12,31 +12,6 @@ namespace Elsa.EntityFrameworkCore.Extensions; [PublicAPI] public static class QueryableExtensions { - /// - /// Inserts or updates a list of entities in bulk. - /// - public static async Task BulkUpsertAsync(this TDbContext dbContext, IList entities, Expression> keySelector, CancellationToken cancellationToken = default) where TDbContext : DbContext where TEntity : class, new() - { - var set = dbContext.Set(); - var compiledKeySelector = keySelector.Compile(); - var containsLambda = entities.Any() ? keySelector.BuildContainsExpression(entities) : default; - var existingEntitiesQuery = set.AsNoTracking(); - - if (containsLambda != null) - existingEntitiesQuery = existingEntitiesQuery.Where(containsLambda); - - var existingEntities = await existingEntitiesQuery.ToListAsync(cancellationToken); - var entitiesToUpdate = entities.IntersectBy(existingEntities.Select(compiledKeySelector), compiledKeySelector).ToList(); - var entitiesToInsert = entities.Except(entitiesToUpdate).ToList(); - - if (entitiesToUpdate.Any()) - set.UpdateRange(entitiesToUpdate); - if (entitiesToInsert.Any()) - await set.AddRangeAsync(entitiesToInsert, cancellationToken); - - await dbContext.SaveChangesAsync(cancellationToken); - } - /// /// Inserts a list of entities in bulk. /// diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs index 8c877c9b5..f46d7ee9c 100644 --- a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs +++ b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Http.Headers; using Elsa.Extensions; using Elsa.Http.ContentWriters; @@ -7,6 +8,7 @@ using Elsa.Workflows.Attributes; using Elsa.Workflows.UIHints; using Elsa.Workflows.Models; using Microsoft.Extensions.Logging; +using Polly; namespace Elsa.Http; @@ -89,6 +91,11 @@ public abstract class SendHttpRequestBase(string? source = default, int? line = )] public Input RequestHeaders { get; set; } = new(new HttpHeaders()); + /// + /// Indicates whether resiliency mechanisms should be enabled for the HTTP request. + /// + public Input EnableResiliency { get; set; } = default!; + /// /// The HTTP response status code /// @@ -130,15 +137,15 @@ public abstract class SendHttpRequestBase(string? source = default, int? line = private async Task TrySendAsync(ActivityExecutionContext context) { - var request = PrepareRequest(context); var logger = (ILogger)context.GetRequiredService(typeof(ILogger<>).MakeGenericType(GetType())); var httpClientFactory = context.GetRequiredService(); var httpClient = httpClientFactory.CreateClient(nameof(SendHttpRequestBase)); var cancellationToken = context.CancellationToken; + var resiliencyEnabled = EnableResiliency.GetOrDefault(context, () => false); try { - var response = await httpClient.SendAsync(request, cancellationToken); + var response = await SendRequestAsync(); var parsedContent = await ParseContentAsync(context, response); var statusCode = (int)response.StatusCode; var responseHeaders = new HttpHeaders(response.Headers); @@ -155,7 +162,7 @@ public abstract class SendHttpRequestBase(string? source = default, int? line = logger.LogWarning(e, "An error occurred while sending an HTTP request"); context.AddExecutionLogEntry("Error", e.Message, payload: new { - StackTrace = e.StackTrace + e.StackTrace }); context.JournalData.Add("Error", e.Message); await HandleRequestExceptionAsync(context, e); @@ -165,11 +172,30 @@ public abstract class SendHttpRequestBase(string? source = default, int? line = logger.LogWarning(e, "An error occurred while sending an HTTP request"); context.AddExecutionLogEntry("Error", e.Message, payload: new { - StackTrace = e.StackTrace + e.StackTrace }); context.JournalData.Add("Cancelled", true); await HandleTaskCanceledExceptionAsync(context, e); } + + return; + + async Task SendRequestAsync() + { + if (resiliencyEnabled) + { + var pipeline = BuildResiliencyPipeline(context); + return await pipeline.ExecuteAsync(async ct => await SendRequestAsyncCore(ct), cancellationToken); + } + + return await SendRequestAsyncCore(); + } + + async Task SendRequestAsyncCore(CancellationToken ct = default) + { + var request = PrepareRequest(context); + return await httpClient.SendAsync(request, ct); + } } private async Task ParseContentAsync(ActivityExecutionContext context, HttpResponseMessage httpResponse) @@ -238,4 +264,46 @@ public abstract class SendHttpRequestBase(string? source = default, int? line = var parsedContentType = new System.Net.Mime.ContentType(contentType); return factories.FirstOrDefault(httpContentFactory => httpContentFactory.SupportedContentTypes.Any(c => c == parsedContentType.MediaType)) ?? new JsonContentFactory(); } + + private ResiliencePipeline BuildResiliencyPipeline(ActivityExecutionContext context) + { + // Docs: https://www.pollydocs.org/strategies/retry + var pipelineBuilder = new ResiliencePipelineBuilder() + .AddRetry(new() + { + ShouldHandle = new PredicateBuilder() + .Handle() // Specific timeout exception + .Handle() // Any HTTP exception + .HandleResult(response => IsTransientStatusCode(response.StatusCode)), + MaxRetryAttempts = 8, + UseJitter = false, // If enabled, adds a random value between -25% and +25% of the calculated Delay, except if BackoffType is Exponential, where a DecorrelatedJitterBackoffV2 formula is used for jitter calculation. That formula is based on Polly.Contrib.WaitAndRetry. + Delay = TimeSpan.FromSeconds(1), + BackoffType = DelayBackoffType.Exponential // Delay * 2^AttemptNumber, e.g. [ 2s, 4s, 8s, 16s ]. Total secs: 2 + 4 + 8 + 16 = 30 + // If BackoffType is Exponential, then the calculated Delay is multiplied by a random value between -25% and +25% of the calculated Delay, except if BackoffType is Exponential, where a DecorrelatedJitterBackoffV2 formula is used for jitter calculation. That formula is based on Polly.Contrib.WaitAndRetry. + }); + + return pipelineBuilder.Build(); + } + + // Helper method to identify transient status codes. + private static bool IsTransientStatusCode(HttpStatusCode? statusCode) + { + if (statusCode is null) + { + // No status code -> Assume network failure, worth retrying. + return true; + } + + return statusCode.Value switch + { + HttpStatusCode.RequestTimeout => true, // 408 + HttpStatusCode.TooManyRequests => true, // 429 (if no Retry-After header is respected) + HttpStatusCode.InternalServerError => true, // 500 + HttpStatusCode.BadGateway => true, // 502 + HttpStatusCode.ServiceUnavailable => true, // 503 + HttpStatusCode.GatewayTimeout => true, // 504 + HttpStatusCode.Conflict => true, // 409 - Can be transient in concurrency cases + _ => false // Other errors are not transient + }; + } } \ No newline at end of file diff --git a/src/modules/Elsa.Http/Elsa.Http.csproj b/src/modules/Elsa.Http/Elsa.Http.csproj index aaa37a25c..f190f061d 100644 --- a/src/modules/Elsa.Http/Elsa.Http.csproj +++ b/src/modules/Elsa.Http/Elsa.Http.csproj @@ -8,16 +8,18 @@ - + + + - - - - - - + + + + + + diff --git a/src/modules/Elsa.Quartz.EntityFrameworkCore.PostgreSql/Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj b/src/modules/Elsa.Quartz.EntityFrameworkCore.PostgreSql/Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj index 43788dd8e..75d312334 100644 --- a/src/modules/Elsa.Quartz.EntityFrameworkCore.PostgreSql/Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj +++ b/src/modules/Elsa.Quartz.EntityFrameworkCore.PostgreSql/Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.Cancel.cs b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.Cancel.cs index 74a21f7f6..c4f7fe4f6 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.Cancel.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.Cancel.cs @@ -24,7 +24,7 @@ public partial class WorkflowExecutionContext Bookmarks.Clear(); _completionCallbackEntries.Clear(); - if (Status != WorkflowStatus.Running && SubStatus != WorkflowSubStatus.Faulted) + if (!CanTransitionTo(WorkflowSubStatus.Cancelled)) return; AddExecutionLogEntry("Workflow cancelled"); diff --git a/src/modules/Elsa.Workflows.Management/Filters/WorkflowInstanceFilter.cs b/src/modules/Elsa.Workflows.Management/Filters/WorkflowInstanceFilter.cs index 2fa5d1ac2..c225a1b63 100644 --- a/src/modules/Elsa.Workflows.Management/Filters/WorkflowInstanceFilter.cs +++ b/src/modules/Elsa.Workflows.Management/Filters/WorkflowInstanceFilter.cs @@ -116,6 +116,11 @@ public class WorkflowInstanceFilter /// public ICollection? TimestampFilters { get; set; } + /// + /// Filter workflow instances by name. + /// + public List? Names { get; set; } + /// /// Applies the filter to the specified query. /// @@ -134,6 +139,7 @@ public class WorkflowInstanceFilter if (filter.ParentWorkflowInstanceIds != null) query = query.Where(x => x.ParentWorkflowInstanceId != null && filter.ParentWorkflowInstanceIds.Contains(x.ParentWorkflowInstanceId)); if (!string.IsNullOrWhiteSpace(filter.CorrelationId)) query = query.Where(x => x.CorrelationId == filter.CorrelationId); if (filter.CorrelationIds != null) query = query.Where(x => filter.CorrelationIds.Contains(x.CorrelationId!)); + if (filter.Names != null) query = query.Where(x => filter.Names.Contains(x.Name!)); if (filter.WorkflowStatus != null) query = query.Where(x => x.Status == filter.WorkflowStatus); if (filter.WorkflowSubStatus != null) query = query.Where(x => x.SubStatus == filter.WorkflowSubStatus); if (filter.WorkflowStatuses != null) query = query.Where(x => filter.WorkflowStatuses.Contains(x.Status)); diff --git a/src/modules/Elsa.Workflows.Runtime/Bookmarks/ExecuteWorkflowPayload.cs b/src/modules/Elsa.Workflows.Runtime/Bookmarks/ExecuteWorkflowPayload.cs new file mode 100644 index 000000000..c6cbbec68 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Bookmarks/ExecuteWorkflowPayload.cs @@ -0,0 +1,9 @@ +using Elsa.Workflows.Runtime.Activities; + +namespace Elsa.Workflows.Runtime.Bookmarks; + +/// +/// Bookmark payload for the activity. +/// +/// The instance ID of the child workflow that was created by the activity. +public record ExecuteWorkflowPayload(string ChildInstanceId); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDispatcherExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDispatcherExtensions.cs index c8f532767..ff8a14685 100644 --- a/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDispatcherExtensions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDispatcherExtensions.cs @@ -14,7 +14,7 @@ public static class WorkflowDispatcherExtensions /// public static Task DispatchAsync(this IWorkflowDispatcher workflowDispatcher, DispatchWorkflowDefinitionRequest request, CancellationToken cancellationToken = default) { - return workflowDispatcher.DispatchAsync(request, new DispatchWorkflowOptions(), cancellationToken); + return workflowDispatcher.DispatchAsync(request, new(), cancellationToken); } /// @@ -22,7 +22,7 @@ public static class WorkflowDispatcherExtensions /// public static Task DispatchAsync(this IWorkflowDispatcher workflowDispatcher, DispatchWorkflowInstanceRequest request, CancellationToken cancellationToken = default) { - return workflowDispatcher.DispatchAsync(request, new DispatchWorkflowOptions(), cancellationToken); + return workflowDispatcher.DispatchAsync(request, new(), cancellationToken); } /// @@ -30,7 +30,7 @@ public static class WorkflowDispatcherExtensions /// public static Task DispatchAsync(this IWorkflowDispatcher workflowDispatcher, DispatchTriggerWorkflowsRequest request, CancellationToken cancellationToken = default) { - return workflowDispatcher.DispatchAsync(request, new DispatchWorkflowOptions(), cancellationToken); + return workflowDispatcher.DispatchAsync(request, new(), cancellationToken); } /// @@ -38,6 +38,6 @@ public static class WorkflowDispatcherExtensions /// public static Task DispatchAsync(this IWorkflowDispatcher workflowDispatcher, DispatchResumeWorkflowsRequest request, CancellationToken cancellationToken = default) { - return workflowDispatcher.DispatchAsync(request, new DispatchWorkflowOptions(), cancellationToken); + return workflowDispatcher.DispatchAsync(request, new(), cancellationToken); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index 109286bfd..ec5ab8537 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -31,13 +31,8 @@ namespace Elsa.Workflows.Runtime.Features; /// Installs and configures workflow runtime features. /// [DependsOn(typeof(SystemClockFeature))] -public class WorkflowRuntimeFeature : FeatureBase +public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) { - /// - public WorkflowRuntimeFeature(IModule module) : base(module) - { - } - private IDictionary WorkflowDispatcherChannels { get; set; } = new Dictionary(); /// @@ -142,6 +137,24 @@ public class WorkflowRuntimeFeature : FeatureBase /// public Action BookmarkQueuePurgeOptions { get; set; } = _ => { }; + /// + /// Enables the workflow inbox cleanup job. + /// + public WorkflowRuntimeFeature EnableWorkflowInboxCleanupJob() + { + Services.Configure(options => { options.IsEnabled = true; }); + return this; + } + + /// + /// Disables the workflow inbox cleanup job. + /// + public WorkflowRuntimeFeature DisableWorkflowInboxCleanupJob() + { + Services.Configure(options => { options.IsEnabled = false; }); + return this; + } + /// /// Register the specified workflow type. /// diff --git a/src/modules/Elsa.Workflows.Runtime/Options/WorkflowInboxCleanupOptions.cs b/src/modules/Elsa.Workflows.Runtime/Options/WorkflowInboxCleanupOptions.cs index fc694689e..7aa1266d8 100644 --- a/src/modules/Elsa.Workflows.Runtime/Options/WorkflowInboxCleanupOptions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Options/WorkflowInboxCleanupOptions.cs @@ -14,4 +14,9 @@ public class WorkflowInboxCleanupOptions /// The number of messages to clean up per sweep. /// public int BatchSize { get; set; } = 1000; + + /// + /// Whether the workflow inbox cleanup is enabled. + /// + public bool IsEnabled { get; set; } = true; } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Results/WorkflowExecutionResult.cs b/src/modules/Elsa.Workflows.Runtime/Results/WorkflowExecutionResult.cs index 298c7357b..d4c5832e1 100644 --- a/src/modules/Elsa.Workflows.Runtime/Results/WorkflowExecutionResult.cs +++ b/src/modules/Elsa.Workflows.Runtime/Results/WorkflowExecutionResult.cs @@ -2,4 +2,11 @@ using Elsa.Workflows.Models; namespace Elsa.Workflows.Runtime.Results; -public record WorkflowExecutionResult(string WorkflowInstanceId, WorkflowStatus Status, WorkflowSubStatus SubStatus, ICollection Bookmarks, ICollection Incidents, string? TriggeredActivityId = null); \ No newline at end of file +public record WorkflowExecutionResult( + string WorkflowInstanceId, + WorkflowStatus Status, + WorkflowSubStatus SubStatus, + ICollection Bookmarks, + ICollection Incidents, + string? TriggeredActivityId, + IDictionary Output); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs b/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs index aa26779bc..33cafbe82 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs @@ -66,7 +66,7 @@ public class ObsoleteWorkflowRuntime( TriggerActivityId = options?.TriggerActivityId }; var response = await client.CreateAndRunInstanceAsync(createRequest, cancellationToken); - return new(response.WorkflowInstanceId, response.Status, response.SubStatus, response.Bookmarks, response.Incidents); + return new(response.WorkflowInstanceId, response.Status, response.SubStatus, response.Bookmarks, response.Incidents, null, null); } public async Task> StartWorkflowsAsync(string activityTypeName, object bookmarkPayload, TriggerWorkflowsOptions? options = null) @@ -81,7 +81,7 @@ public class ObsoleteWorkflowRuntime( Input = options?.Input }; var result = await stimulusSender.SendAsync(activityTypeName, bookmarkPayload, metadata, cancellationToken); - var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents)).ToList(); + var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, null)).ToList(); return results; } @@ -109,7 +109,7 @@ public class ObsoleteWorkflowRuntime( var response = await workflowClient.RunInstanceAsync(runWorkflowRequest, cancellationToken); - return new(response.WorkflowInstanceId, response.Status, response.SubStatus, response.Bookmarks, response.Incidents); + return new(response.WorkflowInstanceId, response.Status, response.SubStatus, response.Bookmarks, response.Incidents,null, null); } public async Task> ResumeWorkflowsAsync(string activityTypeName, object bookmarkPayload, TriggerWorkflowsOptions? options = null) @@ -124,7 +124,7 @@ public class ObsoleteWorkflowRuntime( Input = options?.Input }; var result = await stimulusSender.SendAsync(activityTypeName, bookmarkPayload, metadata, cancellationToken); - var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents)).ToList(); + var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, null)).ToList(); return results; } @@ -140,7 +140,7 @@ public class ObsoleteWorkflowRuntime( Input = options?.Input }; var result = await stimulusSender.SendAsync(activityTypeName, bookmarkPayload, metadata, cancellationToken); - var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents)).ToList(); + var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, null)).ToList(); return new(results); } diff --git a/src/modules/Elsa.Workflows.Runtime/Services/StimulusProxyWorkflowInbox.cs b/src/modules/Elsa.Workflows.Runtime/Services/StimulusProxyWorkflowInbox.cs index 53f1573c3..e7c79f754 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/StimulusProxyWorkflowInbox.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/StimulusProxyWorkflowInbox.cs @@ -1,4 +1,5 @@ using Elsa.Common; +using Elsa.Extensions; using Elsa.Workflows.Models; using Elsa.Workflows.Runtime.Contracts; using Elsa.Workflows.Runtime.Entities; @@ -180,7 +181,9 @@ public class StimulusProxyWorkflowInbox( response.Status, response.SubStatus, new List(), - response.Incidents) + response.Incidents, + null, + null) ); } } \ No newline at end of file