From 3874c0bebe1b42486d14aa6299fd033add37b32c Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 2 Apr 2025 16:11:38 +0200 Subject: [PATCH 01/13] Update Docker image tags to v3.4.0-preview Upgraded Docker image references in workflows to v3.4.0-preview from v3.3.0-preview. This ensures the workflows use the latest version for building and pushing images. --- .github/workflows/elsa-server-and-studio.yml | 2 +- .github/workflows/elsa-server.yml | 2 +- .github/workflows/elsa-studio.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/elsa-server-and-studio.yml b/.github/workflows/elsa-server-and-studio.yml index 301c18a4a..9c23cc7c9 100644 --- a/.github/workflows/elsa-server-and-studio.yml +++ b/.github/workflows/elsa-server-and-studio.yml @@ -29,7 +29,7 @@ jobs: with: # list of Docker images to use as base name for tags images: | - elsaworkflows/elsa-server-and-studio-v3-3-0-preview + elsaworkflows/elsa-server-and-studio-v3-4-0-preview flavor: | latest=true # generate Docker tags based on the following events/attributes diff --git a/.github/workflows/elsa-server.yml b/.github/workflows/elsa-server.yml index 275cf6147..289332ba4 100644 --- a/.github/workflows/elsa-server.yml +++ b/.github/workflows/elsa-server.yml @@ -29,7 +29,7 @@ jobs: with: # list of Docker images to use as base name for tags images: | - elsaworkflows/elsa-server-v3-3-0-preview + elsaworkflows/elsa-server-v3-4-0-preview flavor: | latest=true # generate Docker tags based on the following events/attributes diff --git a/.github/workflows/elsa-studio.yml b/.github/workflows/elsa-studio.yml index 3c5079b5c..f2fe2bff2 100644 --- a/.github/workflows/elsa-studio.yml +++ b/.github/workflows/elsa-studio.yml @@ -29,7 +29,7 @@ jobs: with: # list of Docker images to use as base name for tags images: | - elsaworkflows/elsa-studio-v3-3-0-preview + elsaworkflows/elsa-studio-v3-4-0-preview flavor: | latest=true # generate Docker tags based on the following events/attributes From d4b02385018159890b868836962106bfd3774548 Mon Sep 17 00:00:00 2001 From: Raymond den Haan Date: Thu, 3 Apr 2025 15:30:35 +0200 Subject: [PATCH 02/13] Moved compression to separate feature --- src/apps/Elsa.Server.Web/Program.cs | 2 +- .../Codecs}/GZip.cs | 2 +- .../Codecs}/None.cs | 2 +- .../Codecs}/Zstd.cs | 2 +- .../Contracts/ICompressionCodec.cs | 2 +- .../Contracts/ICompressionCodecResolver.cs | 2 +- src/modules/Elsa.Common/Elsa.Common.csproj | 1 + .../Features/CompressionFeature.cs | 21 +++++++++++++++++++ .../Services/CompressionCodecResolver.cs | 4 ++-- .../Management/WorkflowInstanceStore.cs | 3 ++- .../Runtime/ActivityExecutionLogStore.cs | 4 ++-- .../Features/WorkflowManagementFeature.cs | 10 +++------ 12 files changed, 37 insertions(+), 18 deletions(-) rename src/modules/{Elsa.Workflows.Management/Compression => Elsa.Common/Codecs}/GZip.cs (96%) rename src/modules/{Elsa.Workflows.Management/Compression => Elsa.Common/Codecs}/None.cs (91%) rename src/modules/{Elsa.Workflows.Management/Compression => Elsa.Common/Codecs}/Zstd.cs (95%) rename src/modules/{Elsa.Workflows.Management => Elsa.Common}/Contracts/ICompressionCodec.cs (92%) rename src/modules/{Elsa.Workflows.Management => Elsa.Common}/Contracts/ICompressionCodecResolver.cs (88%) create mode 100644 src/modules/Elsa.Common/Features/CompressionFeature.cs rename src/modules/{Elsa.Workflows.Management => Elsa.Common}/Services/CompressionCodecResolver.cs (86%) diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index edc03cffd..3d9f61f27 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -2,6 +2,7 @@ using System.Text.Encodings.Web; using Elsa.Alterations.Extensions; using Elsa.Alterations.MassTransit.Extensions; using Elsa.Caching.Options; +using Elsa.Common.Codecs; using Elsa.Common.DistributedHosting.DistributedLocks; using Elsa.Common.RecurringTasks; using Elsa.Common.Serialization; @@ -51,7 +52,6 @@ using Elsa.Workflows.CommitStates.Strategies; using Elsa.Workflows.IncidentStrategies; using Elsa.Workflows.LogPersistence; using Elsa.Workflows.Management; -using Elsa.Workflows.Management.Compression; using Elsa.Workflows.Management.Stores; using Elsa.Workflows.Memory; using Elsa.Workflows.Options; diff --git a/src/modules/Elsa.Workflows.Management/Compression/GZip.cs b/src/modules/Elsa.Common/Codecs/GZip.cs similarity index 96% rename from src/modules/Elsa.Workflows.Management/Compression/GZip.cs rename to src/modules/Elsa.Common/Codecs/GZip.cs index d00b5b819..b7b3d1eda 100644 --- a/src/modules/Elsa.Workflows.Management/Compression/GZip.cs +++ b/src/modules/Elsa.Common/Codecs/GZip.cs @@ -1,7 +1,7 @@ using System.IO.Compression; using System.Text; -namespace Elsa.Workflows.Management.Compression; +namespace Elsa.Common.Codecs; /// /// Represents a GZip compression strategy. diff --git a/src/modules/Elsa.Workflows.Management/Compression/None.cs b/src/modules/Elsa.Common/Codecs/None.cs similarity index 91% rename from src/modules/Elsa.Workflows.Management/Compression/None.cs rename to src/modules/Elsa.Common/Codecs/None.cs index 48c41c8e9..5e2dc800b 100644 --- a/src/modules/Elsa.Workflows.Management/Compression/None.cs +++ b/src/modules/Elsa.Common/Codecs/None.cs @@ -1,4 +1,4 @@ -namespace Elsa.Workflows.Management.Compression; +namespace Elsa.Common.Codecs; /// /// Represents a compression strategy that does not compress or decompress the input. diff --git a/src/modules/Elsa.Workflows.Management/Compression/Zstd.cs b/src/modules/Elsa.Common/Codecs/Zstd.cs similarity index 95% rename from src/modules/Elsa.Workflows.Management/Compression/Zstd.cs rename to src/modules/Elsa.Common/Codecs/Zstd.cs index cb5fd9494..11a49c5d6 100644 --- a/src/modules/Elsa.Workflows.Management/Compression/Zstd.cs +++ b/src/modules/Elsa.Common/Codecs/Zstd.cs @@ -1,7 +1,7 @@ using System.Text; using IronCompress; -namespace Elsa.Workflows.Management.Compression; +namespace Elsa.Common.Codecs; /// /// Represents a ZSTD compression strategy. diff --git a/src/modules/Elsa.Workflows.Management/Contracts/ICompressionCodec.cs b/src/modules/Elsa.Common/Contracts/ICompressionCodec.cs similarity index 92% rename from src/modules/Elsa.Workflows.Management/Contracts/ICompressionCodec.cs rename to src/modules/Elsa.Common/Contracts/ICompressionCodec.cs index 82cb7e402..c34a0dc3d 100644 --- a/src/modules/Elsa.Workflows.Management/Contracts/ICompressionCodec.cs +++ b/src/modules/Elsa.Common/Contracts/ICompressionCodec.cs @@ -1,4 +1,4 @@ -namespace Elsa.Workflows.Management; +namespace Elsa.Common; /// /// Represents a compression strategy. diff --git a/src/modules/Elsa.Workflows.Management/Contracts/ICompressionCodecResolver.cs b/src/modules/Elsa.Common/Contracts/ICompressionCodecResolver.cs similarity index 88% rename from src/modules/Elsa.Workflows.Management/Contracts/ICompressionCodecResolver.cs rename to src/modules/Elsa.Common/Contracts/ICompressionCodecResolver.cs index 852b95ab0..86494155c 100644 --- a/src/modules/Elsa.Workflows.Management/Contracts/ICompressionCodecResolver.cs +++ b/src/modules/Elsa.Common/Contracts/ICompressionCodecResolver.cs @@ -1,4 +1,4 @@ -namespace Elsa.Workflows.Management; +namespace Elsa.Common; /// /// Resolves a from its name. diff --git a/src/modules/Elsa.Common/Elsa.Common.csproj b/src/modules/Elsa.Common/Elsa.Common.csproj index 654e988e5..9b5d15779 100644 --- a/src/modules/Elsa.Common/Elsa.Common.csproj +++ b/src/modules/Elsa.Common/Elsa.Common.csproj @@ -16,6 +16,7 @@ + diff --git a/src/modules/Elsa.Common/Features/CompressionFeature.cs b/src/modules/Elsa.Common/Features/CompressionFeature.cs new file mode 100644 index 000000000..f6b5955c1 --- /dev/null +++ b/src/modules/Elsa.Common/Features/CompressionFeature.cs @@ -0,0 +1,21 @@ +using Elsa.Common.Codecs; +using Elsa.Common.Services; +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Common.Features; + +[UsedImplicitly] +public class CompressionFeature(IModule module) : FeatureBase(module) +{ + public override void Apply() + { + Services + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Services/CompressionCodecResolver.cs b/src/modules/Elsa.Common/Services/CompressionCodecResolver.cs similarity index 86% rename from src/modules/Elsa.Workflows.Management/Services/CompressionCodecResolver.cs rename to src/modules/Elsa.Common/Services/CompressionCodecResolver.cs index 77b7bc77e..1cd3a1156 100644 --- a/src/modules/Elsa.Workflows.Management/Services/CompressionCodecResolver.cs +++ b/src/modules/Elsa.Common/Services/CompressionCodecResolver.cs @@ -1,6 +1,6 @@ -using Elsa.Workflows.Management.Compression; +using Elsa.Common.Codecs; -namespace Elsa.Workflows.Management.Services; +namespace Elsa.Common.Services; /// public class CompressionCodecResolver : ICompressionCodecResolver diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Management/WorkflowInstanceStore.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/WorkflowInstanceStore.cs index 79bee6bb7..69ca5afd5 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Management/WorkflowInstanceStore.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/WorkflowInstanceStore.cs @@ -1,10 +1,11 @@ using System.Diagnostics.CodeAnalysis; +using Elsa.Common; +using Elsa.Common.Codecs; using Elsa.Common.Entities; using Elsa.Common.Models; using Elsa.Extensions; using Elsa.Workflows; using Elsa.Workflows.Management; -using Elsa.Workflows.Management.Compression; using Elsa.Workflows.Management.Entities; using Elsa.Workflows.Management.Filters; using Elsa.Workflows.Management.Models; diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs index 08229ccf9..43cb9fec6 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs @@ -1,9 +1,9 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using Elsa.Common; +using Elsa.Common.Codecs; using Elsa.Extensions; using Elsa.Workflows; -using Elsa.Workflows.Management; -using Elsa.Workflows.Management.Compression; using Elsa.Workflows.Management.Options; using Elsa.Workflows.Runtime; using Elsa.Workflows.Runtime.Entities; diff --git a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs index 71bf63fe0..76e53ea19 100644 --- a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; using Elsa.Caching.Features; +using Elsa.Common.Codecs; using Elsa.Common.Features; using Elsa.Expressions.Contracts; using Elsa.Extensions; @@ -14,10 +15,8 @@ using Elsa.Features.Services; using Elsa.Workflows.Features; using Elsa.Workflows.LogPersistence; using Elsa.Workflows.Management.Activities.WorkflowDefinitionActivity; -using Elsa.Workflows.Management.Compression; using Elsa.Workflows.Management.Contracts; using Elsa.Workflows.Management.Entities; -using Elsa.Workflows.Management.Handlers; using Elsa.Workflows.Management.Handlers.Notifications; using Elsa.Workflows.Management.Mappers; using Elsa.Workflows.Management.Materializers; @@ -35,9 +34,10 @@ namespace Elsa.Workflows.Management.Features; /// /// Installs and configures the workflow management feature. /// +[DependsOn(typeof(CompressionFeature))] [DependsOn(typeof(MediatorFeature))] -[DependsOn(typeof(SystemClockFeature))] [DependsOn(typeof(MemoryCacheFeature))] +[DependsOn(typeof(SystemClockFeature))] [DependsOn(typeof(WorkflowsFeature))] [DependsOn(typeof(WorkflowDefinitionsFeature))] [DependsOn(typeof(WorkflowInstancesFeature))] @@ -234,10 +234,6 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module) .AddScoped() .AddSingleton() .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() ; Services From 4bded7e4c5e81cec99397b05fed54084afa6900d Mon Sep 17 00:00:00 2001 From: MariusVuscanNx <96233009+MariusVuscanNx@users.noreply.github.com> Date: Fri, 4 Apr 2025 12:13:54 +0300 Subject: [PATCH 03/13] Enhanced Delete of variable persistence manager to allow drivers filtering with tags (#6558) --- .../Contracts/IVariablePersistenceManager.cs | 10 ++++++++-- .../Services/VariablePersistenceManager.cs | 13 +++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IVariablePersistenceManager.cs b/src/modules/Elsa.Workflows.Core/Contracts/IVariablePersistenceManager.cs index bb61acd67..a08b54a85 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/IVariablePersistenceManager.cs +++ b/src/modules/Elsa.Workflows.Core/Contracts/IVariablePersistenceManager.cs @@ -21,10 +21,16 @@ public interface IVariablePersistenceManager /// /// Deletes the specified variables from the . /// - Task DeleteVariablesAsync(ActivityExecutionContext context); + /// + /// + /// + Task DeleteVariablesAsync(ActivityExecutionContext context, IEnumerable? includeTags = default); /// /// Deletes the specified variables from the . /// - Task DeleteVariablesAsync(WorkflowExecutionContext context); + /// + /// + /// + Task DeleteVariablesAsync(WorkflowExecutionContext context, IEnumerable? includeTags = default); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Services/VariablePersistenceManager.cs b/src/modules/Elsa.Workflows.Core/Services/VariablePersistenceManager.cs index a383253e2..9943b2462 100644 --- a/src/modules/Elsa.Workflows.Core/Services/VariablePersistenceManager.cs +++ b/src/modules/Elsa.Workflows.Core/Services/VariablePersistenceManager.cs @@ -100,11 +100,12 @@ public class VariablePersistenceManager(IStorageDriverManager storageDriverManag } /// - public async Task DeleteVariablesAsync(ActivityExecutionContext context) + public async Task DeleteVariablesAsync(ActivityExecutionContext context, IEnumerable? includeTags = default) { var register = context.ExpressionExecutionContext.Memory; var variableList = GetLocalVariables(context).ToList(); var cancellationToken = context.CancellationToken; + var includeTagsList = includeTags?.ToList(); foreach (var variable in variableList) { @@ -116,6 +117,9 @@ public class VariablePersistenceManager(IStorageDriverManager storageDriverManag if (driver == null) continue; + + if (includeTagsList != null && !driver.Tags.Any(includeTagsList.Contains)) + continue; var id = GetStateId(variable); var storageDriverContext = new StorageDriverContext(context, variable, cancellationToken); @@ -125,13 +129,14 @@ public class VariablePersistenceManager(IStorageDriverManager storageDriverManag } /// - public async Task DeleteVariablesAsync(WorkflowExecutionContext context) + public async Task DeleteVariablesAsync(WorkflowExecutionContext context, IEnumerable? includeTags = default) { var activityContexts = context.ActivityExecutionContexts.ToList(); - + var includeTagsList = includeTags?.ToList(); + foreach (var activityContext in activityContexts) { - await DeleteVariablesAsync(activityContext); + await DeleteVariablesAsync(activityContext, includeTagsList); } } From 286bdb22b24a69f9718418b0bfe720c066387b58 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 4 Apr 2025 12:39:14 +0200 Subject: [PATCH 04/13] Fix TenantId Missing in ActivityExecutionRecords and DB Context Isolation in Multitenancy (#6549) * Enable multi-tenancy support for pooled DbContexts via decoration Introduced a `TenantAwareDbContextFactory` to wrap `IDbContextFactory` and ensure tenant context is applied to `DbContext` instances. Updated dependency injection to decorate `IDbContextFactory` and enabled multi-tenancy across the application. Adjusted method signatures for consistency, simplifying multiple default parameters. * Make ServiceProvider property protected Changed the access modifier of the ServiceProvider property from private to protected. This allows derived classes to access and utilize the ServiceProvider directly while maintaining encapsulation. * Disable multitenancy in Elsa.Server.Web configuration --- .../Elsa.EntityFrameworkCore.Common.csproj | 1 + .../ElsaDbContextBase.cs | 8 --- .../ElsaDbContextOptionsExtension.cs | 1 - .../Extensions/BulkUpsertExtensions.cs | 2 - .../PersistenceFeatureBase.cs | 2 + .../Elsa.EntityFrameworkCore.Common/Store.cs | 52 +++++++++++-------- .../TenantAwareDbContextFactory.cs | 37 +++++++++++++ .../Providers/ClrWorkflowsProvider.cs | 3 +- 8 files changed, 73 insertions(+), 33 deletions(-) create mode 100644 src/modules/Elsa.EntityFrameworkCore.Common/TenantAwareDbContextFactory.cs diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/Elsa.EntityFrameworkCore.Common.csproj b/src/modules/Elsa.EntityFrameworkCore.Common/Elsa.EntityFrameworkCore.Common.csproj index e3f4635d9..3454db95b 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Common/Elsa.EntityFrameworkCore.Common.csproj +++ b/src/modules/Elsa.EntityFrameworkCore.Common/Elsa.EntityFrameworkCore.Common.csproj @@ -14,6 +14,7 @@ + diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/ElsaDbContextBase.cs b/src/modules/Elsa.EntityFrameworkCore.Common/ElsaDbContextBase.cs index 5329a7183..210f7d5a7 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Common/ElsaDbContextBase.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Common/ElsaDbContextBase.cs @@ -1,8 +1,6 @@ using Elsa.Common.Entities; -using Elsa.Common.Multitenancy; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; namespace Elsa.EntityFrameworkCore; @@ -45,12 +43,6 @@ public abstract class ElsaDbContextBase : DbContext, IElsaDbContextSchema // ReSharper disable once VirtualMemberCallInConstructor Schema = !string.IsNullOrWhiteSpace(_elsaDbContextOptions?.SchemaName) ? _elsaDbContextOptions.SchemaName : ElsaSchema; - - var tenantAccessor = serviceProvider.GetService(); - var tenantId = tenantAccessor?.Tenant?.Id; - - if (!string.IsNullOrWhiteSpace(tenantId)) - TenantId = tenantId; } /// diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/ElsaDbContextOptionsExtension.cs b/src/modules/Elsa.EntityFrameworkCore.Common/ElsaDbContextOptionsExtension.cs index e59376a64..3bd867eeb 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Common/ElsaDbContextOptionsExtension.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Common/ElsaDbContextOptionsExtension.cs @@ -33,6 +33,5 @@ public class ElsaDbContextOptionsExtension : IDbContextOptionsExtension /// public void Validate(IDbContextOptions options) { - } } \ No newline at end of file diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/Extensions/BulkUpsertExtensions.cs b/src/modules/Elsa.EntityFrameworkCore.Common/Extensions/BulkUpsertExtensions.cs index b82b3fe6a..076b685d3 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Common/Extensions/BulkUpsertExtensions.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Common/Extensions/BulkUpsertExtensions.cs @@ -154,9 +154,7 @@ public static class BulkUpsertExtensions 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 diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/PersistenceFeatureBase.cs b/src/modules/Elsa.EntityFrameworkCore.Common/PersistenceFeatureBase.cs index 49fa20800..a3012b28d 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Common/PersistenceFeatureBase.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Common/PersistenceFeatureBase.cs @@ -64,6 +64,8 @@ public abstract class PersistenceFeatureBase : FeatureBase Services.AddPooledDbContextFactory(setup); else Services.AddDbContextFactory(setup, DbContextFactoryLifetime); + + Services.Decorate, TenantAwareDbContextFactory>(); } protected virtual void ConfigureMigrations() diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/Store.cs b/src/modules/Elsa.EntityFrameworkCore.Common/Store.cs index 0a353a20e..021072f07 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Common/Store.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Common/Store.cs @@ -1,7 +1,9 @@ using System.Linq.Expressions; using Elsa.Common.Entities; using Elsa.Common.Models; +using Elsa.Common.Multitenancy; using Elsa.EntityFrameworkCore.Extensions; +using Elsa.Extensions; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -76,7 +78,7 @@ public class Store(IDbContextFactory dbContextF /// The cancellation token. public async Task AddManyAsync( IEnumerable entities, - Func? onSaving = default, + Func? onSaving = null, CancellationToken cancellationToken = default) { var entityList = entities.ToList(); @@ -151,7 +153,7 @@ public class Store(IDbContextFactory dbContextF /// The entities to save. /// The key selector to get the primary key property. /// The cancellation token. - public async Task SaveManyAsync(IEnumerable entities, Expression> keySelector, CancellationToken cancellationToken = default) => await SaveManyAsync(entities, keySelector, default, cancellationToken); + public async Task SaveManyAsync(IEnumerable entities, Expression> keySelector, CancellationToken cancellationToken = default) => await SaveManyAsync(entities, keySelector, null, cancellationToken); /// /// Saves the specified entities. @@ -163,7 +165,7 @@ public class Store(IDbContextFactory dbContextF public async Task SaveManyAsync( IEnumerable entities, Expression> keySelector, - Func? onSaving = default, + Func? onSaving = null, CancellationToken cancellationToken = default) { var entityList = entities.ToList(); @@ -179,6 +181,14 @@ public class Store(IDbContextFactory dbContextF await Task.WhenAll(savingTasks); } + // When doing a custom SQL query (Bulk Upsert), none of the installed query filters will be applied. Hence, we are assigning the current tenant ID explicitly. + var tenantId = serviceProvider.GetRequiredService().Tenant?.Id.NullIfEmpty(); + foreach (var entity in entityList) + { + if (entity is Entity entityWithTenant) + entityWithTenant.TenantId = tenantId; + } + try { await dbContext.BulkUpsertAsync(entityList, keySelector, cancellationToken); @@ -237,9 +247,9 @@ public class Store(IDbContextFactory dbContextF await using var dbContext = await CreateDbContextAsync(cancellationToken); dbContext.Attach(entity); - foreach (var property in properties) + foreach (var property in properties) dbContext.Entry(entity).Property(property).IsModified = true; - + await dbContext.SaveChangesAsync(cancellationToken); } @@ -249,7 +259,7 @@ public class Store(IDbContextFactory dbContextF /// The predicate to use. /// The cancellation token. /// The entity if found, otherwise null. - public async Task FindAsync(Expression> predicate, CancellationToken cancellationToken = default) => await FindAsync(predicate, default, cancellationToken); + public async Task FindAsync(Expression> predicate, CancellationToken cancellationToken = default) => await FindAsync(predicate, null, cancellationToken); /// /// Finds the entity matching the specified predicate. @@ -258,7 +268,7 @@ public class Store(IDbContextFactory dbContextF /// A callback to run after the entity is loaded /// The cancellation token. /// - public async Task FindAsync(Expression> predicate, Func? onLoading = default, CancellationToken cancellationToken = default) + public async Task FindAsync(Expression> predicate, Func? onLoading = null, CancellationToken cancellationToken = default) { await using var dbContext = await CreateDbContextAsync(cancellationToken); var set = dbContext.Set().AsNoTracking(); @@ -267,7 +277,7 @@ public class Store(IDbContextFactory dbContextF if (entity == null) return null; - if (onLoading != default) + if (onLoading != null) entity = onLoading.Invoke(dbContext, entity); return entity; @@ -280,7 +290,7 @@ public class Store(IDbContextFactory dbContextF /// A callback to run after the entity is loaded /// The cancellation token /// The entity if found, otherwise null - public async Task FindAsync(Func, IQueryable> query, Func? onLoading = default, CancellationToken cancellationToken = default) + public async Task FindAsync(Func, IQueryable> query, Func? onLoading = null, CancellationToken cancellationToken = default) { return await FindAsync(query, onLoading, false, cancellationToken); } @@ -293,7 +303,7 @@ public class Store(IDbContextFactory dbContextF /// Define is the request should be tenant agnostic or not /// The cancellation token /// The entity if found, otherwise null - public async Task FindAsync(Func, IQueryable> query, Func? onLoading = default, bool tenantAgnostic = false, CancellationToken cancellationToken = default) + public async Task FindAsync(Func, IQueryable> query, Func? onLoading = null, bool tenantAgnostic = false, CancellationToken cancellationToken = default) { return await QueryAsync(query, onLoading, tenantAgnostic, cancellationToken).FirstOrDefault(); } @@ -324,12 +334,12 @@ public class Store(IDbContextFactory dbContextF /// /// Finds a list of entities using a query /// - public async Task> FindManyAsync(Expression> predicate, CancellationToken cancellationToken = default) => await FindManyAsync(predicate, default, cancellationToken); + public async Task> FindManyAsync(Expression> predicate, CancellationToken cancellationToken = default) => await FindManyAsync(predicate, null, cancellationToken); /// /// Finds a list of entities using a query /// - public async Task> FindManyAsync(Expression> predicate, Action? onLoading = default, CancellationToken cancellationToken = default) + public async Task> FindManyAsync(Expression> predicate, Action? onLoading = null, CancellationToken cancellationToken = default) { await using var dbContext = await CreateDbContextAsync(cancellationToken); var set = dbContext.Set().AsNoTracking(); @@ -349,9 +359,9 @@ public class Store(IDbContextFactory dbContextF Expression> predicate, Expression> orderBy, OrderDirection orderDirection = OrderDirection.Ascending, - PageArgs? pageArgs = default, + PageArgs? pageArgs = null, CancellationToken cancellationToken = default) => - await FindManyAsync(predicate, orderBy, orderDirection, pageArgs, default, cancellationToken); + await FindManyAsync(predicate, orderBy, orderDirection, pageArgs, null, cancellationToken); /// /// Returns a list of entities using a query @@ -360,8 +370,8 @@ public class Store(IDbContextFactory dbContextF Expression>? predicate, Expression>? orderBy, OrderDirection orderDirection = OrderDirection.Ascending, - PageArgs? pageArgs = default, - Func? onLoading = default, + PageArgs? pageArgs = null, + Func? onLoading = null, CancellationToken cancellationToken = default) { await using var dbContext = await CreateDbContextAsync(cancellationToken); @@ -394,7 +404,7 @@ public class Store(IDbContextFactory dbContextF return ListAsync(null, cancellationToken); } - public async Task> ListAsync(Action? onLoading = default, CancellationToken cancellationToken = default) + public async Task> ListAsync(Action? onLoading = null, CancellationToken cancellationToken = default) { await using var dbContext = await CreateDbContextAsync(cancellationToken); var set = dbContext.Set().AsNoTracking(); @@ -447,7 +457,7 @@ public class Store(IDbContextFactory dbContextF /// public async Task> QueryAsync(Func, IQueryable> query, CancellationToken cancellationToken = default) { - return await QueryAsync(query, default, false, cancellationToken); + return await QueryAsync(query, null, false, cancellationToken); } /// @@ -455,13 +465,13 @@ public class Store(IDbContextFactory dbContextF /// public async Task> QueryAsync(Func, IQueryable> query, bool tenantAgnostic, CancellationToken cancellationToken = default) { - return await QueryAsync(query, default, tenantAgnostic, cancellationToken); + return await QueryAsync(query, null, tenantAgnostic, cancellationToken); } /// /// Queries the database using a query and a selector. /// - public async Task> QueryAsync(Func, IQueryable> query, Func? onLoading = default, CancellationToken cancellationToken = default) + public async Task> QueryAsync(Func, IQueryable> query, Func? onLoading = null, CancellationToken cancellationToken = default) { return await QueryAsync(query, onLoading, false, cancellationToken); } @@ -469,7 +479,7 @@ public class Store(IDbContextFactory dbContextF /// /// Queries the database using a query and a selector. /// - public async Task> QueryAsync(Func, IQueryable> query, Func? onLoading = default, bool ignoreQueryFilters = false, CancellationToken cancellationToken = default) + public async Task> QueryAsync(Func, IQueryable> query, Func? onLoading = null, bool ignoreQueryFilters = false, CancellationToken cancellationToken = default) { await using var dbContext = await CreateDbContextAsync(cancellationToken); var asNoTracking = onLoading == null; diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/TenantAwareDbContextFactory.cs b/src/modules/Elsa.EntityFrameworkCore.Common/TenantAwareDbContextFactory.cs new file mode 100644 index 000000000..710ae0b2d --- /dev/null +++ b/src/modules/Elsa.EntityFrameworkCore.Common/TenantAwareDbContextFactory.cs @@ -0,0 +1,37 @@ +using Elsa.Common.Multitenancy; +using Elsa.Extensions; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace Elsa.EntityFrameworkCore; + +/// +/// A factory class that wraps and extends the functionality of an existing +/// to provide multi-tenancy support. This ensures that the created DbContext instances are aware of the current tenant context. +/// +[UsedImplicitly] +public class TenantAwareDbContextFactory( + IDbContextFactory decoratedFactory, + ITenantAccessor tenantAccessor) : IDbContextFactory + where TDbContext : DbContext +{ + public TDbContext CreateDbContext() + { + var context = decoratedFactory.CreateDbContext(); + SetTenantId(context); + return context; + } + + public async Task CreateDbContextAsync(CancellationToken cancellationToken = default) + { + var context = await decoratedFactory.CreateDbContextAsync(cancellationToken); + SetTenantId(context); + return context; + } + + private void SetTenantId(TDbContext context) + { + if (context is ElsaDbContextBase elsaContext) + elsaContext.TenantId = tenantAccessor.Tenant?.Id.NullIfEmpty(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Providers/ClrWorkflowsProvider.cs b/src/modules/Elsa.Workflows.Runtime/Providers/ClrWorkflowsProvider.cs index 25dd73e49..e76ffb413 100644 --- a/src/modules/Elsa.Workflows.Runtime/Providers/ClrWorkflowsProvider.cs +++ b/src/modules/Elsa.Workflows.Runtime/Providers/ClrWorkflowsProvider.cs @@ -1,4 +1,5 @@ using Elsa.Common.Multitenancy; +using Elsa.Extensions; using Elsa.Workflows.Management.Materializers; using Elsa.Workflows.Runtime.Features; using Elsa.Workflows.Runtime.Options; @@ -46,7 +47,7 @@ public class ClrWorkflowsProvider( { Id = id, DefinitionId = definitionId, - TenantId = tenantId + TenantId = tenantId?.NullIfEmpty() }; var materializerContext = new ClrWorkflowMaterializerContext(workflowBuilder.GetType()); From e2288d0b343a67d0aad18ac398cb7e0fbcee8eba Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 5 Apr 2025 10:37:05 +0200 Subject: [PATCH 05/13] Fix infinitely waiting Alterations Workflow (#6561) * Refactor TenantId string handling and nullability checks. Moved `StringExtensions` to a common module for reuse. Updated tenant-related logic to utilize null-safe string extensions, enhancing consistency and simplifying nullability handling across the codebase. * Set StrictMode to false by default in ObjectConverter Modified the default value of StrictMode to `false` to enable the original flexible behavior. Developers can opt into strict mode by explicitly setting it to `true`. This change aims to enhance backward compatibility and minimize unexpected strict conversions. * Add tenant ID retrieval to ElsaDbContextBase constructor Retrieve the current tenant ID if available using ITenantAccessor and assign it to the TenantId property. This ensures proper handling of multi-tenancy scenarios in the database context initialization. * Disable DbContext pooling, manual OTEL instrumentation, and strict mode. DbContext pooling is turned off to prevent potential issues with shared context instances. Manual OpenTelemetry instrumentation is disabled to rely on automatic instrumentation instead. Strict mode is also disabled to allow more flexibility in object conversion. * Fix infinitely waiting Alterations Workflow Replaced workflow dispatch logic with BookmarkQueue and StimulusHasher for triggering workflows. This fixes the issue where the Alterations workflow would signal completion while a later step awaits a completion bookmark. The Bookmark Queue now handles this. --- src/apps/Elsa.Server.Web/Program.cs | 6 +++--- .../Handlers/AlterationPlanCompletedHandler.cs | 14 +++++++++----- .../Services/AlterationPlanManager.cs | 2 +- .../Services/DefaultAlterationPlanScheduler.cs | 2 +- .../Extensions/StringExtensions.cs | 0 .../Elsa.Common/Multitenancy/Entities/Tenant.cs | 4 ++-- .../Implementations/DefaultTenantService.cs | 15 ++++++++------- .../Multitenancy/Models/TenantFilter.cs | 4 ++-- .../ElsaDbContextBase.cs | 8 ++++++++ .../EntityHandlers/ApplyTenantId.cs | 3 ++- .../EntityHandlers/SetTenantIdFilter.cs | 4 +++- .../Elsa.Expressions/Helpers/ObjectConverter.cs | 2 +- .../Services/BookmarkQueueSignaler.cs | 2 +- 13 files changed, 41 insertions(+), 25 deletions(-) rename src/modules/{Elsa.Workflows.Core => Elsa.Common}/Extensions/StringExtensions.cs (100%) diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index 3d9f61f27..f88e3edd0 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -86,7 +86,7 @@ using StackExchange.Redis; // ReSharper disable RedundantAssignment const PersistenceProvider persistenceProvider = PersistenceProvider.EntityFrameworkCore; -const bool useDbContextPooling = true; +const bool useDbContextPooling = false; const bool useHangfire = false; const bool useQuartz = true; const bool useMassTransit = true; @@ -105,9 +105,9 @@ const bool useTenantsFromConfiguration = true; const bool useSecrets = false; const bool disableVariableWrappers = false; const bool disableVariableCopying = false; -const bool useManualOtelInstrumentation = true; +const bool useManualOtelInstrumentation = false; -ObjectConverter.StrictMode = true; // Default. +ObjectConverter.StrictMode = false; var builder = WebApplication.CreateBuilder(args); var services = builder.Services; diff --git a/src/modules/Elsa.Alterations/Handlers/AlterationPlanCompletedHandler.cs b/src/modules/Elsa.Alterations/Handlers/AlterationPlanCompletedHandler.cs index 18a210510..f9b605055 100644 --- a/src/modules/Elsa.Alterations/Handlers/AlterationPlanCompletedHandler.cs +++ b/src/modules/Elsa.Alterations/Handlers/AlterationPlanCompletedHandler.cs @@ -1,10 +1,9 @@ using Elsa.Alterations.Bookmarks; using Elsa.Alterations.Core.Notifications; using Elsa.Mediator.Contracts; +using Elsa.Workflows; using Elsa.Workflows.Helpers; using Elsa.Workflows.Runtime; -using Elsa.Workflows.Runtime.Contracts; -using Elsa.Workflows.Runtime.Requests; using JetBrains.Annotations; namespace Elsa.Alterations.Handlers; @@ -13,7 +12,7 @@ namespace Elsa.Alterations.Handlers; /// Handles notifications and triggers any workflows that are waiting for the plan to complete. /// [UsedImplicitly] -public class AlterationPlanCompletedHandler(IWorkflowDispatcher workflowDispatcher) : INotificationHandler +public class AlterationPlanCompletedHandler(IBookmarkQueue bookmarkQueue, IStimulusHasher stimulusHasher) : INotificationHandler { /// public async Task HandleAsync(AlterationPlanCompleted notification, CancellationToken cancellationToken) @@ -21,7 +20,12 @@ public class AlterationPlanCompletedHandler(IWorkflowDispatcher workflowDispatch // Trigger any workflow instances that are waiting for the plan to complete. var planId = notification.Plan.Id; var bookmarkPayload = new AlterationPlanCompletedPayload(planId); - var triggerRequest = new DispatchTriggerWorkflowsRequest(ActivityTypeNameHelper.GenerateTypeName(), bookmarkPayload); - await workflowDispatcher.DispatchAsync(triggerRequest, cancellationToken); + var activityTypeName = ActivityTypeNameHelper.GenerateTypeName(); + var item = new NewBookmarkQueueItem + { + ActivityTypeName = activityTypeName, + StimulusHash = stimulusHasher.Hash(activityTypeName, bookmarkPayload) + }; + await bookmarkQueue.EnqueueAsync(item, cancellationToken); } } \ No newline at end of file diff --git a/src/modules/Elsa.Alterations/Services/AlterationPlanManager.cs b/src/modules/Elsa.Alterations/Services/AlterationPlanManager.cs index eb40d5d7c..23e054216 100644 --- a/src/modules/Elsa.Alterations/Services/AlterationPlanManager.cs +++ b/src/modules/Elsa.Alterations/Services/AlterationPlanManager.cs @@ -25,7 +25,7 @@ public class AlterationPlanManager(IAlterationPlanStore planStore, IAlterationJo var jobFilter = new AlterationJobFilter { PlanId = planId, - Statuses = new[] { AlterationJobStatus.Pending, AlterationJobStatus.Running } + Statuses = [AlterationJobStatus.Pending, AlterationJobStatus.Running] }; return await jobStore.CountAsync(jobFilter, cancellationToken) == 0; diff --git a/src/modules/Elsa.Alterations/Services/DefaultAlterationPlanScheduler.cs b/src/modules/Elsa.Alterations/Services/DefaultAlterationPlanScheduler.cs index fd8275282..4ab30f434 100644 --- a/src/modules/Elsa.Alterations/Services/DefaultAlterationPlanScheduler.cs +++ b/src/modules/Elsa.Alterations/Services/DefaultAlterationPlanScheduler.cs @@ -42,7 +42,7 @@ public class DefaultAlterationPlanScheduler : IAlterationPlanScheduler var workflowGraph = await _workflowDefinitionService.FindWorkflowGraphAsync(definitionId, VersionOptions.Published, cancellationToken); if (workflowGraph == null) - throw new Exception($"Workflow definition with ID '{definitionId}' not found"); + throw new($"Workflow definition with ID '{definitionId}' not found"); var serializedPlan = _jsonSerializer.Serialize(planParams); var request = new DispatchWorkflowDefinitionRequest(workflowGraph.Workflow.Identity.Id) diff --git a/src/modules/Elsa.Workflows.Core/Extensions/StringExtensions.cs b/src/modules/Elsa.Common/Extensions/StringExtensions.cs similarity index 100% rename from src/modules/Elsa.Workflows.Core/Extensions/StringExtensions.cs rename to src/modules/Elsa.Common/Extensions/StringExtensions.cs diff --git a/src/modules/Elsa.Common/Multitenancy/Entities/Tenant.cs b/src/modules/Elsa.Common/Multitenancy/Entities/Tenant.cs index a832baa2c..cba4b371b 100644 --- a/src/modules/Elsa.Common/Multitenancy/Entities/Tenant.cs +++ b/src/modules/Elsa.Common/Multitenancy/Entities/Tenant.cs @@ -13,7 +13,7 @@ public class Tenant : Entity /// /// Gets or sets the name. /// - public string Name { get; set; } = default!; + public string Name { get; set; } = null!; /// /// Gets or sets the configuration. @@ -22,7 +22,7 @@ public class Tenant : Entity public static readonly Tenant Default = new() { - Id = string.Empty, + Id = null!, Name = "Default" }; } diff --git a/src/modules/Elsa.Common/Multitenancy/Implementations/DefaultTenantService.cs b/src/modules/Elsa.Common/Multitenancy/Implementations/DefaultTenantService.cs index fca84ebc9..89298e5ac 100644 --- a/src/modules/Elsa.Common/Multitenancy/Implementations/DefaultTenantService.cs +++ b/src/modules/Elsa.Common/Multitenancy/Implementations/DefaultTenantService.cs @@ -1,3 +1,4 @@ +using Elsa.Extensions; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Common.Multitenancy; @@ -19,7 +20,7 @@ public class DefaultTenantService(IServiceScopeFactory scopeFactory, ITenantScop public async Task FindAsync(string id, CancellationToken cancellationToken = default) { var dictionary = await GetTenantsDictionaryAsync(cancellationToken); - return dictionary.TryGetValue(id, out var tenant) ? tenant : null; + return dictionary.TryGetValue(id.EmptyIfNull(), out var tenant) ? tenant : null; } public async Task FindAsync(TenantFilter filter, CancellationToken cancellationToken = default) @@ -31,7 +32,7 @@ public class DefaultTenantService(IServiceScopeFactory scopeFactory, ITenantScop public async Task GetAsync(string id, CancellationToken cancellationToken = default) { var dictionary = await GetTenantsDictionaryAsync(cancellationToken); - return dictionary[id]; + return dictionary[id.EmptyIfNull()]; } public async Task GetAsync(TenantFilter filter, CancellationToken cancellationToken = default) @@ -76,7 +77,7 @@ public class DefaultTenantService(IServiceScopeFactory scopeFactory, ITenantScop var tenantsProvider = scope.ServiceProvider.GetRequiredService(); var currentTenants = await GetTenantsDictionaryAsync(cancellationToken); var currentTenantIds = currentTenants.Keys; - var newTenants = (await tenantsProvider.ListAsync(cancellationToken)).ToDictionary(x => x.Id); + var newTenants = (await tenantsProvider.ListAsync(cancellationToken)).ToDictionary(x => x.Id.EmptyIfNull()); var newTenantIds = newTenants.Keys; var removedTenantIds = currentTenantIds.Except(newTenantIds).ToArray(); var addedTenantIds = newTenantIds.Except(currentTenantIds).ToArray(); @@ -129,21 +130,21 @@ public class DefaultTenantService(IServiceScopeFactory scopeFactory, ITenantScop private async Task RegisterTenantAsync(Tenant tenant, CancellationToken cancellationToken = default) { var scope = tenantScopeFactory.CreateScope(tenant); - _tenantsDictionary![tenant.Id] = tenant; + _tenantsDictionary![tenant.Id.EmptyIfNull()] = tenant; _tenantScopesDictionary![tenant] = scope; using (tenantAccessor.PushContext(tenant)) - await tenantEvents.TenantActivatedAsync(new TenantActivatedEventArgs(tenant, scope, cancellationToken)); + await tenantEvents.TenantActivatedAsync(new(tenant, scope, cancellationToken)); } private async Task UnregisterTenantAsync(Tenant tenant, CancellationToken cancellationToken = default) { if (_tenantScopesDictionary!.Remove(tenant, out var scope)) { - _tenantsDictionary!.Remove(tenant.Id, out _); + _tenantsDictionary!.Remove(tenant.Id.EmptyIfNull(), out _); using (tenantAccessor.PushContext(tenant)) - await tenantEvents.TenantDeactivatedAsync(new TenantDeactivatedEventArgs(tenant, scope, cancellationToken)); + await tenantEvents.TenantDeactivatedAsync(new(tenant, scope, cancellationToken)); } } } \ No newline at end of file diff --git a/src/modules/Elsa.Common/Multitenancy/Models/TenantFilter.cs b/src/modules/Elsa.Common/Multitenancy/Models/TenantFilter.cs index 1fc1c1fa1..51a88590a 100644 --- a/src/modules/Elsa.Common/Multitenancy/Models/TenantFilter.cs +++ b/src/modules/Elsa.Common/Multitenancy/Models/TenantFilter.cs @@ -11,7 +11,7 @@ public class TenantFilter /// /// Gets or sets the tenant ID to filter for. /// - public string? Id { get; set; } + public string Id { get; set; } = null!; /// /// Applies the filter to the specified queryable. @@ -20,7 +20,7 @@ public class TenantFilter /// The filtered queryable. public IQueryable Apply(IQueryable queryable) { - if (Id != null) queryable = queryable.Where(x => x.Id == Id); + queryable = queryable.Where(x => x.Id == Id); return queryable; } diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/ElsaDbContextBase.cs b/src/modules/Elsa.EntityFrameworkCore.Common/ElsaDbContextBase.cs index 210f7d5a7..2a22f2a7d 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Common/ElsaDbContextBase.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Common/ElsaDbContextBase.cs @@ -1,4 +1,6 @@ using Elsa.Common.Entities; +using Elsa.Common.Multitenancy; +using Elsa.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.Extensions.DependencyInjection; @@ -43,6 +45,12 @@ public abstract class ElsaDbContextBase : DbContext, IElsaDbContextSchema // ReSharper disable once VirtualMemberCallInConstructor Schema = !string.IsNullOrWhiteSpace(_elsaDbContextOptions?.SchemaName) ? _elsaDbContextOptions.SchemaName : ElsaSchema; + + var tenantAccessor = serviceProvider.GetService(); + var tenantId = tenantAccessor?.Tenant?.Id; + + if (!string.IsNullOrWhiteSpace(tenantId)) + TenantId = tenantId.NullIfEmpty(); } /// diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/EntityHandlers/ApplyTenantId.cs b/src/modules/Elsa.EntityFrameworkCore.Common/EntityHandlers/ApplyTenantId.cs index 7167f46d6..b55f9af65 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Common/EntityHandlers/ApplyTenantId.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Common/EntityHandlers/ApplyTenantId.cs @@ -1,4 +1,5 @@ using Elsa.Common.Entities; +using Elsa.Extensions; using Microsoft.EntityFrameworkCore.ChangeTracking; namespace Elsa.EntityFrameworkCore.EntityHandlers; @@ -12,7 +13,7 @@ public class ApplyTenantId : IEntitySavingHandler public ValueTask HandleAsync(ElsaDbContextBase dbContext, EntityEntry entry, CancellationToken cancellationToken = default) { if (entry.Entity is Entity entity) - entity.TenantId = dbContext.TenantId; + entity.TenantId = dbContext.TenantId.NullIfEmpty(); return default; } diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/EntityHandlers/SetTenantIdFilter.cs b/src/modules/Elsa.EntityFrameworkCore.Common/EntityHandlers/SetTenantIdFilter.cs index 0a352aabe..0e5bb9d9d 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Common/EntityHandlers/SetTenantIdFilter.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Common/EntityHandlers/SetTenantIdFilter.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using Elsa.Common.Entities; +using Elsa.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Query; @@ -17,8 +18,9 @@ public class SetTenantIdFilter : IEntityModelCreatingHandler if (!entityType.ClrType.IsAssignableTo(typeof(Entity))) return; + var tenantId = dbContext.TenantId.NullIfEmpty(); var parameter = Expression.Parameter(entityType.ClrType); - Expression> filterExpr = entity => dbContext.TenantId == entity.TenantId; + Expression> filterExpr = entity => entity.TenantId == tenantId; var body = ReplacingExpressionVisitor.Replace(filterExpr.Parameters[0], parameter, filterExpr.Body); var lambdaExpression = Expression.Lambda(body, parameter); diff --git a/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs b/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs index 0714ca238..698e1412c 100644 --- a/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs +++ b/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs @@ -27,7 +27,7 @@ public record ObjectConverterOptions(JsonSerializerOptions? SerializerOptions = /// public static class ObjectConverter { - public static bool StrictMode = true; // Set to false to revert to original flexible behavior. + public static bool StrictMode = false; // Set to true to opt into strict mode. /// /// Attempts to convert the source value into the destination type. diff --git a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkQueueSignaler.cs b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkQueueSignaler.cs index d97b76cd1..3ab6af9cc 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkQueueSignaler.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkQueueSignaler.cs @@ -37,7 +37,7 @@ public class BookmarkQueueSignaler : IBookmarkQueueSignaler lock (_lock) { // Reset the TCS for the next wait - _tcs = new TaskCompletionSource(); + _tcs = new(); } } } \ No newline at end of file From 455f206d365a1bf5c0468f9af9b4eb83a6c49100 Mon Sep 17 00:00:00 2001 From: MariusVuscanNx <96233009+MariusVuscanNx@users.noreply.github.com> Date: Thu, 10 Apr 2025 15:02:38 +0300 Subject: [PATCH 06/13] Extended the incidents data to include the activityNodeId (#6570) --- .../Resources/WorkflowInstances/Models/ActivityIncident.cs | 7 ++++++- .../Middleware/Activities/ExceptionHandlingMiddleware.cs | 2 +- .../Workflows/EngineExceptionHandlingMiddleware.cs | 2 +- .../Middleware/Workflows/ExceptionHandlingMiddleware.cs | 2 +- src/modules/Elsa.Workflows.Core/Models/ActivityIncident.cs | 6 +++++- .../Mappers/ActivityIncidentMapper.cs | 2 ++ .../Proto/WorkflowInstance.Messages.proto | 1 + 7 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Models/ActivityIncident.cs b/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Models/ActivityIncident.cs index 078275008..503b5f9ce 100644 --- a/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Models/ActivityIncident.cs +++ b/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Models/ActivityIncident.cs @@ -19,13 +19,15 @@ public class ActivityIncident /// Initializes a new instance of the class. /// /// The ID of the activity that caused the incident. + /// The Node ID of the activity that caused the incident. /// The type of the activity that caused the incident. /// The message of the incident. /// The exception that caused the incident. /// The timestamp of the incident. - public ActivityIncident(string activityId, string activityType, string message, ExceptionState? exception, DateTimeOffset timestamp) + public ActivityIncident(string activityId, string activityNodeId, string activityType, string message, ExceptionState? exception, DateTimeOffset timestamp) { ActivityId = activityId; + ActivityNodeId = activityNodeId; ActivityType = activityType; Message = message; Exception = exception; @@ -34,6 +36,9 @@ public class ActivityIncident /// The ID of the activity that caused the incident. public string ActivityId { get; init; } = default!; + + /// The Node ID of the activity that caused the incident. + public string ActivityNodeId { get; init; } = default!; /// The type of the activity that caused the incident. public string ActivityType { get; init; } = default!; diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs index 38d5963a2..60f4e3fec 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs @@ -47,7 +47,7 @@ public class ExceptionHandlingMiddleware(ActivityMiddlewareDelegate next, IIncid var activity = context.Activity; var exceptionState = ExceptionState.FromException(e); var now = systemClock.UtcNow; - var incident = new ActivityIncident(activity.Id, activity.Type, e.Message, exceptionState, now); + var incident = new ActivityIncident(activity.Id, activity.NodeId ,activity.Type, e.Message, exceptionState, now); context.WorkflowExecutionContext.Incidents.Add(incident); } diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Workflows/EngineExceptionHandlingMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Workflows/EngineExceptionHandlingMiddleware.cs index 1d1b8e917..39b32c3ce 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Workflows/EngineExceptionHandlingMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Workflows/EngineExceptionHandlingMiddleware.cs @@ -35,7 +35,7 @@ public class EngineExceptionHandlingMiddleware(WorkflowMiddlewareDelegate next, var exceptionState = ExceptionState.FromException(e); var now = systemClock.UtcNow; var activity = context.Workflow; - var incident = new ActivityIncident(activity.Id, activity.Type, e.Message, exceptionState, now); + var incident = new ActivityIncident(activity.Id, activity.NodeId, activity.Type, e.Message, exceptionState, now); // No state change as the workflow / activities status should be leading. // We will however be adding an incident to make the issue visible. diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Workflows/ExceptionHandlingMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Workflows/ExceptionHandlingMiddleware.cs index 71f56dbfb..3d6070c36 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Workflows/ExceptionHandlingMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Workflows/ExceptionHandlingMiddleware.cs @@ -49,7 +49,7 @@ public class ExceptionHandlingMiddleware : IWorkflowExecutionMiddleware var exceptionState = ExceptionState.FromException(e); var now = _systemClock.UtcNow; var activity = context.Workflow; - var incident = new ActivityIncident(activity.Id, activity.Type, e.Message, exceptionState, now); + var incident = new ActivityIncident(activity.Id, activity.NodeId ,activity.Type, e.Message, exceptionState, now); context.Incidents.Add(incident); context.TransitionTo(WorkflowSubStatus.Faulted); context.AddExecutionLogEntry("Faulted", e.Message, exceptionState); diff --git a/src/modules/Elsa.Workflows.Core/Models/ActivityIncident.cs b/src/modules/Elsa.Workflows.Core/Models/ActivityIncident.cs index a4fe02392..c787f729a 100644 --- a/src/modules/Elsa.Workflows.Core/Models/ActivityIncident.cs +++ b/src/modules/Elsa.Workflows.Core/Models/ActivityIncident.cs @@ -20,13 +20,15 @@ public class ActivityIncident /// Initializes a new instance of the class. /// /// The ID of the activity that caused the incident. + /// The Node ID of the activity that caused the incident. /// The type of the activity that caused the incident. /// The message of the incident. /// The exception that caused the incident. /// The timestamp of the incident. - public ActivityIncident(string activityId, string activityType, string message, ExceptionState? exception, DateTimeOffset timestamp) + public ActivityIncident(string activityId, string activityNodeId, string activityType, string message, ExceptionState? exception, DateTimeOffset timestamp) { ActivityId = activityId; + ActivityNodeId = activityNodeId; ActivityType = activityType; Message = message; Exception = exception; @@ -35,6 +37,8 @@ public class ActivityIncident /// The ID of the activity that caused the incident. public string ActivityId { get; init; } = default!; + /// The Node ID of the activity that caused the incident. + public string ActivityNodeId { get; init; } = default!; /// The type of the activity that caused the incident. public string ActivityType { get; init; } = default!; diff --git a/src/modules/Elsa.Workflows.Runtime.ProtoActor/Mappers/ActivityIncidentMapper.cs b/src/modules/Elsa.Workflows.Runtime.ProtoActor/Mappers/ActivityIncidentMapper.cs index 0686c85de..964b81c2a 100644 --- a/src/modules/Elsa.Workflows.Runtime.ProtoActor/Mappers/ActivityIncidentMapper.cs +++ b/src/modules/Elsa.Workflows.Runtime.ProtoActor/Mappers/ActivityIncidentMapper.cs @@ -32,6 +32,7 @@ public class ActivityIncidentMapper(ExceptionMapper exceptionMapper) return new() { ActivityId = source.ActivityId, + ActivityNodeId = source.ActivityNodeId, ActivityType = source.ActivityType, Message = source.Message, Exception = exceptionMapper.Map(source.Exception), @@ -47,6 +48,7 @@ public class ActivityIncidentMapper(ExceptionMapper exceptionMapper) return new() { ActivityId = source.ActivityId, + ActivityNodeId = source.ActivityNodeId, ActivityType = source.ActivityType, Message = source.Message, Exception = exceptionMapper.Map(source.Exception), diff --git a/src/modules/Elsa.Workflows.Runtime.ProtoActor/Proto/WorkflowInstance.Messages.proto b/src/modules/Elsa.Workflows.Runtime.ProtoActor/Proto/WorkflowInstance.Messages.proto index bb1683fab..909c8c73f 100644 --- a/src/modules/Elsa.Workflows.Runtime.ProtoActor/Proto/WorkflowInstance.Messages.proto +++ b/src/modules/Elsa.Workflows.Runtime.ProtoActor/Proto/WorkflowInstance.Messages.proto @@ -12,6 +12,7 @@ message ActivityIncident { string Message = 3; optional ExceptionState Exception = 4; string Timestamp = 5; // ISO 8601 + string ActivityNodeId = 6; } enum WorkflowStatus { From 8c52acece504d6cab491e2476c2943ceef264427 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 10 Apr 2025 15:02:39 +0200 Subject: [PATCH 07/13] Fix MessageReceived not setting Result (#6562) * Fix MessageReceived not setting Result Fixes #6540 * Add custom equality comparer for VariableDescriptor Introduced a `VariableDescriptorComparer` to ensure proper equality checks within `HashSet`. Updated related code to use the comparer and added a new message type `OrderReceived` for MassTransit integration. --- .../Elsa.Server.Web/Messages/OrderReceived.cs | 2 +- src/apps/Elsa.Server.Web/Program.cs | 1 + .../Activities/MessageReceived.cs | 45 ++++++++++++------- .../Features/MassTransitFeature.cs | 2 +- .../Models/VariableDescriptor.cs | 19 +++++++- .../Options/ManagementOptions.cs | 2 +- 6 files changed, 52 insertions(+), 19 deletions(-) diff --git a/src/apps/Elsa.Server.Web/Messages/OrderReceived.cs b/src/apps/Elsa.Server.Web/Messages/OrderReceived.cs index 411081bd1..32151ae81 100644 --- a/src/apps/Elsa.Server.Web/Messages/OrderReceived.cs +++ b/src/apps/Elsa.Server.Web/Messages/OrderReceived.cs @@ -2,5 +2,5 @@ namespace Elsa.Server.Web.Messages; public class OrderReceived { - public string OrderId { get; set; } = default!; + public string OrderId { get; set; } = null!; } \ 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 f88e3edd0..1f2617642 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -560,6 +560,7 @@ services elsa.UseMassTransit(massTransit => { massTransit.DisableConsumers = appRole == ApplicationRole.Api; + massTransit.AddMessageType(); if (massTransitBroker == MassTransitBroker.AzureServiceBus) { diff --git a/src/modules/Elsa.MassTransit/Activities/MessageReceived.cs b/src/modules/Elsa.MassTransit/Activities/MessageReceived.cs index 905bc9d53..631486075 100644 --- a/src/modules/Elsa.MassTransit/Activities/MessageReceived.cs +++ b/src/modules/Elsa.MassTransit/Activities/MessageReceived.cs @@ -16,48 +16,63 @@ public class MessageReceived : Trigger internal const string InputKey = "Message"; /// - public MessageReceived([CallerFilePath] string? source = default, [CallerLineNumber] int? line = default) : base(source, line) + public MessageReceived([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) { } /// /// The message type to receive. /// - public Type MessageType { get; set; } = default!; + public Type MessageType { get; set; } = null!; /// protected override object GetTriggerPayload(TriggerIndexingContext context) => GetBookmarkPayload(context.ExpressionExecutionContext); /// - protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + protected override ValueTask ExecuteAsync(ActivityExecutionContext context) { - // If we did not receive external input, it means we are just now encountering this activity and we need to block execution by creating a bookmark. - if (!context.TryGetWorkflowInput(InputKey, out var message) || message.GetType() != MessageType) + // If we did not receive external input, it means we are just now encountering this activity, and we need to block execution by creating a bookmark. + if (!TryGetMessage(context, out var message)) { // Create bookmarks for when we receive the expected HTTP request. context.CreateBookmark(GetBookmarkPayload(context.ExpressionExecutionContext), ResumeAsync, includeActivityInstanceId: false); - return; + return default; } - // Provide the received message as output. - context.Set(Result, message); - - // Remove the input to prevent it from being passed to the next activity. - context.WorkflowInput.Remove(InputKey); - - // Complete. - await context.CompleteActivityAsync(); + return ExecuteInternalAsync(context, message); } private ValueTask ResumeAsync(ActivityExecutionContext context) { + if(!TryGetMessage(context, out var message)) + throw new InvalidOperationException("Message was not received."); + return ExecuteInternalAsync(context, message); + } + + private ValueTask ExecuteInternalAsync(ActivityExecutionContext context, object message) + { + // Provide the received message as output. + context.Set(Result, message); + // Remove the input to prevent it from being passed to the next activity. context.WorkflowInput.Remove(InputKey); - + // Complete. return context.CompleteActivityAsync(); } + private bool TryGetMessage(ActivityExecutionContext context, out object message) + { + if (!context.TryGetWorkflowInput(InputKey, out message)) + return false; + + if (message.GetType() == MessageType) + return true; + + message = null!; + return false; + } + private object GetBookmarkPayload(ExpressionExecutionContext context) { // Generate bookmark data for message type. diff --git a/src/modules/Elsa.MassTransit/Features/MassTransitFeature.cs b/src/modules/Elsa.MassTransit/Features/MassTransitFeature.cs index 66780b0e0..39870a8ad 100644 --- a/src/modules/Elsa.MassTransit/Features/MassTransitFeature.cs +++ b/src/modules/Elsa.MassTransit/Features/MassTransitFeature.cs @@ -81,7 +81,7 @@ public class MassTransitFeature : FeatureBase var category = categoryAttr?.Category ?? activityAttr?.Category ?? "MassTransit"; var descriptionAttr = messageType.GetCustomAttribute(); var description = descriptionAttr?.Description ?? activityAttr?.Description; - options.VariableDescriptors.Add(new VariableDescriptor(messageType, category, description)); + options.VariableDescriptors.Add(new(messageType, category, description)); } }); } diff --git a/src/modules/Elsa.Workflows.Management/Models/VariableDescriptor.cs b/src/modules/Elsa.Workflows.Management/Models/VariableDescriptor.cs index f5439b5f9..f3376d6fe 100644 --- a/src/modules/Elsa.Workflows.Management/Models/VariableDescriptor.cs +++ b/src/modules/Elsa.Workflows.Management/Models/VariableDescriptor.cs @@ -3,4 +3,21 @@ namespace Elsa.Workflows.Management.Models; /// /// Represents a description of a .NET type that can be used as a workflow variable. /// -public record VariableDescriptor(Type Type, string Category, string? Description); \ No newline at end of file +public record VariableDescriptor(Type Type, string Category, string? Description) +{ + public class VariableDescriptorComparer : IEqualityComparer + { + public bool Equals(VariableDescriptor? x, VariableDescriptor? y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; + + return x.Type == y.Type; + } + + public int GetHashCode(VariableDescriptor obj) + { + return obj.Type.GetHashCode(); + } + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Options/ManagementOptions.cs b/src/modules/Elsa.Workflows.Management/Options/ManagementOptions.cs index 86b8d332b..aca77eb19 100644 --- a/src/modules/Elsa.Workflows.Management/Options/ManagementOptions.cs +++ b/src/modules/Elsa.Workflows.Management/Options/ManagementOptions.cs @@ -16,7 +16,7 @@ public class ManagementOptions /// /// A collection of types that are available to the system as variable types. /// - public HashSet VariableDescriptors { get; set; } = new(); + public HashSet VariableDescriptors { get; set; } = new(new VariableDescriptor.VariableDescriptorComparer()); /// /// The format to use for compressing workflow state. From 612fd263885a3c0d45beaa8afaf388a8b02707b3 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 10 Apr 2025 19:57:39 +0200 Subject: [PATCH 08/13] Enable strict mode for ObjectConverter in unit tests Set ObjectConverter.StrictMode to true in the test constructor to enforce stricter conversion rules during tests. This ensures better reliability and consistency in test coverage. --- .../Elsa.Workflows.Core.UnitTests/ObjectConversion/Tests.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/ObjectConversion/Tests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/ObjectConversion/Tests.cs index c1e228222..20704f1b7 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/ObjectConversion/Tests.cs +++ b/test/unit/Elsa.Workflows.Core.UnitTests/ObjectConversion/Tests.cs @@ -8,6 +8,11 @@ namespace Elsa.Workflows.Core.UnitTests.ObjectConversion; public class Tests { + public Tests() + { + ObjectConverter.StrictMode = true; + } + [Fact] public void TryConvertTo_SameType_ReturnsSuccess() { From d92512f08730e5b6679f0bf065e20bbac89ea3e1 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 13 Apr 2025 20:26:31 +0200 Subject: [PATCH 09/13] Fix Container type (#6584) Fixes an incorrect type use: IContainer lives in System.ComponentModel and does not represent a container activity. The correct type to use is `Container`. --- .../Services/ActivityDescriber.cs | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs index bbed2aded..f874bbeac 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs @@ -10,27 +10,13 @@ using Elsa.Workflows.Memory; using Elsa.Workflows.Models; using Elsa.Workflows.UIHints; using Humanizer; +using Container = Elsa.Workflows.Activities.Container; namespace Elsa.Workflows; /// -public class ActivityDescriber : IActivityDescriber +public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolver, IActivityFactory activityFactory, IPropertyUIHandlerResolver propertyUIHandlerResolver) : IActivityDescriber { - //private readonly IPropertyOptionsResolver _optionsResolver; - private readonly IPropertyDefaultValueResolver _defaultValueResolver; - private readonly IActivityFactory _activityFactory; - private readonly IPropertyUIHandlerResolver _propertyUIHandlerResolver; - /// - /// Constructor. - /// - public ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolver, IActivityFactory activityFactory, IPropertyUIHandlerResolver propertyUIHandlerResolver) - { - //_optionsResolver = optionsResolver; - _defaultValueResolver = defaultValueResolver; - _activityFactory = activityFactory; - _propertyUIHandlerResolver = propertyUIHandlerResolver; - } - /// public async Task DescribeActivityAsync([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type activityType, CancellationToken cancellationToken = default) { @@ -90,14 +76,14 @@ public class ActivityDescriber : IActivityDescriber Ports = allPorts.ToList(), Inputs = (await DescribeInputPropertiesAsync(inputProperties, cancellationToken)).ToList(), Outputs = (await DescribeOutputPropertiesAsync(outputProperties, cancellationToken)).ToList(), - IsContainer = typeof(IContainer).IsAssignableFrom(activityType), + IsContainer = typeof(Container).IsAssignableFrom(activityType), IsBrowsable = browsableAttr == null || browsableAttr.Browsable, IsStart = isStart, IsTerminal = isTerminal, Attributes = attributes, Constructor = context => { - var activity = _activityFactory.Create(activityType, context); + var activity = activityFactory.Create(activityType, context); activity.Type = fullTypeName; return activity; } @@ -158,7 +144,7 @@ public class ActivityDescriber : IActivityDescriber if (wrappedPropertyType.IsNullableType()) wrappedPropertyType = wrappedPropertyType.GetTypeOfNullable(); - var uiSpecification = await _propertyUIHandlerResolver.GetUIPropertiesAsync(propertyInfo, null, cancellationToken); + var uiSpecification = await propertyUIHandlerResolver.GetUIPropertiesAsync(propertyInfo, null, cancellationToken); return new InputDescriptor ( @@ -172,7 +158,7 @@ public class ActivityDescriber : IActivityDescriber descriptionAttribute?.Description ?? inputAttribute?.Description, inputAttribute?.Category, inputAttribute?.Order ?? 0, - _defaultValueResolver.GetDefaultValue(propertyInfo), + defaultValueResolver.GetDefaultValue(propertyInfo), inputAttribute?.DefaultSyntax, inputAttribute?.IsReadOnly ?? false, inputAttribute?.IsBrowsable ?? true, From e5c397d4fb873806af09f07e9aa216eb932b2643 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 14 Apr 2025 11:57:14 +0200 Subject: [PATCH 10/13] Refactor workflow instance creation and execution logic. (#6586) Updated `CreateAndRunInstanceAsync` to separate instance creation and execution with locking to handle nested workflow scenarios. Made `RunInstanceAsync` public to facilitate reuse in the distributed workflow client. --- .../Services/DistributedWorkflowClient.cs | 23 +++++++++++++++++-- .../Services/LocalWorkflowClient.cs | 2 +- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs b/src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs index a9468d11d..d483819a5 100644 --- a/src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs +++ b/src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs @@ -31,8 +31,27 @@ public class DistributedWorkflowClient( public async Task CreateAndRunInstanceAsync(CreateAndRunWorkflowInstanceRequest request, CancellationToken cancellationToken = default) { - var result = await _localWorkflowClient.CreateAndRunInstanceAsync(request, cancellationToken); - return result; + var createRequest = new CreateWorkflowInstanceRequest + { + Properties = request.Properties, + CorrelationId = request.CorrelationId, + Name = request.Name, + Input = request.Input, + WorkflowDefinitionHandle = request.WorkflowDefinitionHandle, + ParentId = request.ParentId + }; + var workflowInstance = await _localWorkflowClient.CreateInstanceInternalAsync(createRequest, cancellationToken); + + // We need to lock newly created workflow instances too, because it might dispatch child workflows that attempt to resume the parent workflow. + // For example, when using a DispatchWorkflow activity configured to wait for the dispatched workflow to complete. + return await WithLockAsync(async () => await _localWorkflowClient.RunInstanceAsync(workflowInstance, new() + { + Input = request.Input, + Variables = request.Variables, + Properties = request.Properties, + TriggerActivityId = request.TriggerActivityId, + ActivityHandle = request.ActivityHandle + }, cancellationToken)); } public async Task CancelAsync(CancellationToken cancellationToken = default) diff --git a/src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs b/src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs index 6d9a182f8..223468d2c 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs @@ -104,7 +104,7 @@ public class LocalWorkflowClient( return workflowInstanceManager.ExistsAsync(workflowInstanceId, cancellationToken); } - private async Task RunInstanceAsync(WorkflowInstance workflowInstance, RunWorkflowInstanceRequest request, CancellationToken cancellationToken = default) + public async Task RunInstanceAsync(WorkflowInstance workflowInstance, RunWorkflowInstanceRequest request, CancellationToken cancellationToken = default) { var workflowState = workflowInstance.WorkflowState; From 72370e92719872ce7482ae0b3e05114c4ef5e64f Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 14 Apr 2025 11:57:54 +0200 Subject: [PATCH 11/13] Remove alteration models and refactor to use JsonObject (#6582) Replaced alteration model hierarchy with a simpler JsonObject-based structure for representing alterations. Updated related interfaces, parameters, and API configuration to align with this refactored approach. --- .../DependencyInjectionExtensions.cs | 2 ++ .../Alterations/Contracts/IAlteration.cs | 6 ------ .../Alterations/Models/AlterationBase.cs | 8 -------- .../Alterations/Models/AlterationPlan.cs | 4 ++-- .../Alterations/Models/AlterationPlanParams.cs | 4 ++-- .../Resources/Alterations/Models/Cancel.cs | 6 ------ .../Alterations/Models/CancelActivity.cs | 17 ----------------- .../Resources/Alterations/Models/Migrate.cs | 12 ------------ .../Alterations/Models/ModifyVariable.cs | 18 ------------------ .../Alterations/Models/ScheduleActivity.cs | 17 ----------------- .../Alterations/Responses/RunRequest.cs | 4 ++-- 11 files changed, 8 insertions(+), 90 deletions(-) delete mode 100644 src/clients/Elsa.Api.Client/Resources/Alterations/Contracts/IAlteration.cs delete mode 100644 src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationBase.cs delete mode 100644 src/clients/Elsa.Api.Client/Resources/Alterations/Models/Cancel.cs delete mode 100644 src/clients/Elsa.Api.Client/Resources/Alterations/Models/CancelActivity.cs delete mode 100644 src/clients/Elsa.Api.Client/Resources/Alterations/Models/Migrate.cs delete mode 100644 src/clients/Elsa.Api.Client/Resources/Alterations/Models/ModifyVariable.cs delete mode 100644 src/clients/Elsa.Api.Client/Resources/Alterations/Models/ScheduleActivity.cs diff --git a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs index 3e23b2e45..e6d846919 100644 --- a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs @@ -2,6 +2,7 @@ using Elsa.Api.Client.Options; using Elsa.Api.Client.Resources.ActivityDescriptorOptions.Contracts; using Elsa.Api.Client.Resources.ActivityDescriptors.Contracts; using Elsa.Api.Client.Resources.ActivityExecutions.Contracts; +using Elsa.Api.Client.Resources.Alterations.Contracts; using Elsa.Api.Client.Resources.CommitStrategies.Contracts; using Elsa.Api.Client.Resources.Features.Contracts; using Elsa.Api.Client.Resources.Identity.Contracts; @@ -80,6 +81,7 @@ public static class DependencyInjectionExtensions services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); + services.AddApi(builderOptions); services.AddApi(builderOptions); }); } diff --git a/src/clients/Elsa.Api.Client/Resources/Alterations/Contracts/IAlteration.cs b/src/clients/Elsa.Api.Client/Resources/Alterations/Contracts/IAlteration.cs deleted file mode 100644 index 0ce1f7a50..000000000 --- a/src/clients/Elsa.Api.Client/Resources/Alterations/Contracts/IAlteration.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Elsa.Api.Client.Resources.Alterations.Contracts; - -/// -/// Marker interface for all alteration classes -/// -public interface IAlteration; \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationBase.cs b/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationBase.cs deleted file mode 100644 index 18615eca9..000000000 --- a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationBase.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Elsa.Api.Client.Resources.Alterations.Contracts; - -namespace Elsa.Api.Client.Resources.Alterations.Models; - -/// -/// A base class for all IAlterations. -/// -public abstract class AlterationBase : IAlteration; \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationPlan.cs b/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationPlan.cs index 993858cf5..dd5843d5f 100644 --- a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationPlan.cs +++ b/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationPlan.cs @@ -1,4 +1,4 @@ -using Elsa.Api.Client.Resources.Alterations.Contracts; +using System.Text.Json.Nodes; using Elsa.Api.Client.Resources.Alterations.Enums; using Elsa.Api.Client.Shared.Models; @@ -12,7 +12,7 @@ public class AlterationPlan : Entity /// /// The alterations to be applied. /// - public ICollection Alterations { get; set; } = new List(); + public ICollection Alterations { get; set; } = new List(); /// /// The IDs of the workflow instances that this plan applies to. diff --git a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationPlanParams.cs b/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationPlanParams.cs index f9fc5a1c1..893aa89e6 100644 --- a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationPlanParams.cs +++ b/src/clients/Elsa.Api.Client/Resources/Alterations/Models/AlterationPlanParams.cs @@ -1,4 +1,4 @@ -using Elsa.Api.Client.Resources.Alterations.Contracts; +using System.Text.Json.Nodes; namespace Elsa.Api.Client.Resources.Alterations.Models; @@ -15,7 +15,7 @@ public class AlterationPlanParams /// /// The alterations to be applied. /// - public ICollection Alterations { get; set; } = new List(); + public ICollection Alterations { get; set; } = new List(); /// /// The IDs of the workflow instances that this plan applies to. diff --git a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/Cancel.cs b/src/clients/Elsa.Api.Client/Resources/Alterations/Models/Cancel.cs deleted file mode 100644 index c616d4c3a..000000000 --- a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/Cancel.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Elsa.Api.Client.Resources.Alterations.Models; - -/// -/// Cancels the workflow instances in an alteration plan. -/// -public class Cancel : AlterationBase; \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/CancelActivity.cs b/src/clients/Elsa.Api.Client/Resources/Alterations/Models/CancelActivity.cs deleted file mode 100644 index 2500e7d97..000000000 --- a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/CancelActivity.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Elsa.Api.Client.Resources.Alterations.Models; - -/// -/// Cancels a workflow instance activity during an alteration -/// -public class CancelActivity : AlterationBase -{ - /// - /// The ID of the activity to be cancelled. If not specified, the activity instance ID will be used. - /// - public string? ActivityId { get; set; } - - /// - /// The ID of the activity instance to be cancelled. If specified, overrides . - /// - public string? ActivityInstanceId { get; set; } -} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/Migrate.cs b/src/clients/Elsa.Api.Client/Resources/Alterations/Models/Migrate.cs deleted file mode 100644 index 1a4dc3b69..000000000 --- a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/Migrate.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Elsa.Api.Client.Resources.Alterations.Models; - -/// -/// Migrates a workflow instance to a newer version in an alteration. -/// -public class Migrate : AlterationBase -{ - /// - /// The target version to upgrade to. - /// - public int TargetVersion { get; set; } -} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/ModifyVariable.cs b/src/clients/Elsa.Api.Client/Resources/Alterations/Models/ModifyVariable.cs deleted file mode 100644 index 60ab9f6ba..000000000 --- a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/ModifyVariable.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Elsa.Api.Client.Resources.Alterations.Models; - -/// -/// Modifies a variable in a workflow instance alteration -/// -public class ModifyVariable : AlterationBase -{ - /// - /// The ID of the variable to modify. - /// - public string VariableId { get; set; } = default!; - - /// - /// The new value of the variable. - /// - public object? Value { get; set; } - -} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/ScheduleActivity.cs b/src/clients/Elsa.Api.Client/Resources/Alterations/Models/ScheduleActivity.cs deleted file mode 100644 index 2b0f77f6d..000000000 --- a/src/clients/Elsa.Api.Client/Resources/Alterations/Models/ScheduleActivity.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Elsa.Api.Client.Resources.Alterations.Models; - -/// -/// Schedules an activity for execution in an alteration. -/// -public class ScheduleActivity : AlterationBase -{ - /// - /// The ID of the next activity to be scheduled. If not specified, the ActivityInstanceId will be used. - /// - public string? ActivityId { get; set; } - - /// - /// The ID of the activity instance to be scheduled. If not specified, the ActivityId will be used. - /// - public string? ActivityInstanceId { get; set; } -} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/Alterations/Responses/RunRequest.cs b/src/clients/Elsa.Api.Client/Resources/Alterations/Responses/RunRequest.cs index fdaf341e8..0d9995712 100644 --- a/src/clients/Elsa.Api.Client/Resources/Alterations/Responses/RunRequest.cs +++ b/src/clients/Elsa.Api.Client/Resources/Alterations/Responses/RunRequest.cs @@ -1,4 +1,4 @@ -using Elsa.Api.Client.Resources.Alterations.Contracts; +using System.Text.Json.Nodes; namespace Elsa.Api.Client.Resources.Alterations.Responses; @@ -10,7 +10,7 @@ public class RunRequest /// /// The alterations to be applied. /// - public ICollection Alterations { get; set; } = new List(); + public ICollection Alterations { get; set; } = new List(); /// /// The IDs of the workflow instances that this plan applies to. From e15baa6f4458483e819d61de01b6a07f40a15f87 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 15 Apr 2025 09:51:32 +0200 Subject: [PATCH 12/13] Refactor variable storage driver mapping logic. (#6588) Introduced a new `GetStorageDriverType` method for cleaner and modular handling of storage driver type resolution. Added dependency on `IServiceScopeFactory` to manage backward compatibility with older type namespaces. --- .../Features/WorkflowsFeature.cs | 14 ++++----- .../Mappers/VariableDefinitionMapper.cs | 29 +++++++++++++++++-- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs index ffafd87cb..5759abed5 100644 --- a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs @@ -43,27 +43,27 @@ public class WorkflowsFeature : FeatureBase public WorkflowsFeature(IModule module) : base(module) { } - + /// /// A factory that instantiates a concrete . /// public Func StandardInStreamProvider { get; set; } = _ => new StandardInStreamProvider(Console.In); - + /// /// A factory that instantiates a concrete . /// public Func StandardOutStreamProvider { get; set; } = _ => new StandardOutStreamProvider(Console.Out); - + /// /// A factory that instantiates a concrete . /// public Func IdentityGenerator { get; set; } = sp => new RandomLongIdentityGenerator(); - + /// /// A handler for committing workflow execution state. /// public Func CommitStateHandler { get; set; } = sp => new NoopCommitStateHandler(); - + /// /// A factory that instantiates a concrete . /// @@ -80,7 +80,7 @@ public class WorkflowsFeature : FeatureBase public Action WorkflowExecutionPipeline { get; set; } = builder => builder .UseExceptionHandling() .UseDefaultActivityScheduler(); - + /// /// A delegate to configure the . /// @@ -94,7 +94,7 @@ public class WorkflowsFeature : FeatureBase StandardInStreamProvider = provider; return this; } - + /// /// Fluent method to set . /// diff --git a/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs b/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs index 79a7d8ec6..c1c3e491c 100644 --- a/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs +++ b/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs @@ -4,6 +4,7 @@ using Elsa.Expressions.Helpers; using Elsa.Extensions; using Elsa.Workflows.Memory; using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Elsa.Workflows.Management.Mappers; @@ -11,7 +12,7 @@ namespace Elsa.Workflows.Management.Mappers; /// /// Maps s to s and vice versa. /// -public class VariableDefinitionMapper(IWellKnownTypeRegistry wellKnownTypeRegistry, ILogger logger) +public class VariableDefinitionMapper(IWellKnownTypeRegistry wellKnownTypeRegistry, IServiceScopeFactory scopeFactory, ILogger logger) { /// /// Maps a to a . @@ -47,7 +48,7 @@ public class VariableDefinitionMapper(IWellKnownTypeRegistry wellKnownTypeRegist }); } - variable.StorageDriverType = !string.IsNullOrEmpty(source.StorageDriverTypeName) ? Type.GetType(source.StorageDriverTypeName) : null; + variable.StorageDriverType = GetStorageDriverType(source.StorageDriverTypeName); return variable; } @@ -90,4 +91,28 @@ public class VariableDefinitionMapper(IWellKnownTypeRegistry wellKnownTypeRegist /// Maps a list of s to a list of s. /// public IEnumerable Map(IEnumerable? source) => source?.Select(Map) ?? []; + + private Type? GetStorageDriverType(string? storageDriverTypeName) + { + if (string.IsNullOrEmpty(storageDriverTypeName)) + return null; + + var type = Type.GetType(storageDriverTypeName); + + if (type != null) + return type; + + // TODO: The following code handles backward compatibility with variable definitions referencing older .NET type namespaces. + // We will refactor this by storing a driver identifier rather than its full type name - which is brittle in case we move namespaces. + using var scope = scopeFactory.CreateScope(); + var storageDrivers = scope.ServiceProvider.GetServices(); + foreach (var storageDriver in storageDrivers) + { + var typeName = storageDriver.GetType().Name; + if (storageDriverTypeName.Contains(typeName, StringComparison.OrdinalIgnoreCase)) + return storageDriver.GetType(); + } + + return null; + } } \ No newline at end of file From 278ed34981c4709c0f33f636b5d09a82fc71c447 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 15 Apr 2025 09:51:42 +0200 Subject: [PATCH 13/13] Make input variable names case-insensitive in dictionary (#6590) * Make input variable names case-insensitive in dictionary Previously, input variable names were case-sensitive when added to the dictionary, which could lead to potential issues with naming mismatches. This change ensures that input names are now treated as case-insensitive by using `StringComparer.OrdinalIgnoreCase`, improving consistency and usability. Fixes #6598 * Refactor default value assignments and object initialization. Replaced `default` with explicit `null` for optional parameters to improve clarity. Simplified object instantiation by using target-typed `new` for brevity. These changes enhance code readability and maintainability. --- ...ConfigureEngineWithVariablesAndInputOutputAccessors.cs | 2 +- .../Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs b/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs index 14e407125..479000b11 100644 --- a/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs +++ b/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs @@ -55,7 +55,7 @@ public class ConfigureEngineWithVariablesAndInputOutputAccessors(IOptions x.Name.IsValidVariableName()).ToDictionary(x => x.Name); + var inputs = context.GetWorkflowInputs().Where(x => x.Name.IsValidVariableName()).ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); if (!context.TryGetWorkflowExecutionContext(out var workflowExecutionContext)) return; diff --git a/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs b/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs index 94fdf51b6..256b86bae 100644 --- a/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs +++ b/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs @@ -32,8 +32,8 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification public async Task EvaluateAsync(string expression, Type returnType, ExpressionExecutionContext context, - ExpressionEvaluatorOptions? options = default, - Action? configureEngine = default, + ExpressionEvaluatorOptions? options = null, + Action? configureEngine = null, CancellationToken cancellationToken = default) { var engine = await GetConfiguredEngine(configureEngine, context, options, cancellationToken); @@ -46,7 +46,7 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification private async Task GetConfiguredEngine(Action? configureEngine, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options, CancellationToken cancellationToken) { - options ??= new ExpressionEvaluatorOptions(); + options ??= new(); var engineOptions = new Jint.Options { @@ -130,7 +130,7 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification { var prepareOptions = new ScriptPreparationOptions { - ParsingOptions = new ScriptParsingOptions + ParsingOptions = new() { AllowReturnOutsideFunction = true }