Merge remote-tracking branch 'origin/rc/3.4.0'

This commit is contained in:
Sipke Schoorstra 2025-04-16 08:41:04 +02:00
commit 2eec542f50
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
59 changed files with 298 additions and 217 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -2,5 +2,5 @@ namespace Elsa.Server.Web.Messages;
public class OrderReceived
{
public string OrderId { get; set; } = default!;
public string OrderId { get; set; } = null!;
}

View file

@ -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;
@ -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;
@ -107,7 +107,7 @@ const bool disableVariableWrappers = false;
const bool disableVariableCopying = false;
const bool useManualOtelInstrumentation = false;
ObjectConverter.StrictMode = true; // Default.
ObjectConverter.StrictMode = false;
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
@ -560,6 +560,7 @@ services
elsa.UseMassTransit(massTransit =>
{
massTransit.DisableConsumers = appRole == ApplicationRole.Api;
massTransit.AddMessageType<OrderReceived>();
if (massTransitBroker == MassTransitBroker.AzureServiceBus)
{

View file

@ -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<IJavaScriptApi>(builderOptions);
services.AddApi<IExpressionDescriptorsApi>(builderOptions);
services.AddApi<IWorkflowContextProviderDescriptorsApi>(builderOptions);
services.AddApi<IAlterationsApi>(builderOptions);
services.AddApi<ITasksApi>(builderOptions);
});
}

View file

@ -1,6 +0,0 @@
namespace Elsa.Api.Client.Resources.Alterations.Contracts;
/// <summary>
/// Marker interface for all alteration classes
/// </summary>
public interface IAlteration;

View file

@ -1,8 +0,0 @@
using Elsa.Api.Client.Resources.Alterations.Contracts;
namespace Elsa.Api.Client.Resources.Alterations.Models;
/// <summary>
/// A base class for all IAlterations.
/// </summary>
public abstract class AlterationBase : IAlteration;

View file

@ -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
/// <summary>
/// The alterations to be applied.
/// </summary>
public ICollection<IAlteration> Alterations { get; set; } = new List<IAlteration>();
public ICollection<JsonObject> Alterations { get; set; } = new List<JsonObject>();
/// <summary>
/// The IDs of the workflow instances that this plan applies to.

View file

@ -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
/// <summary>
/// The alterations to be applied.
/// </summary>
public ICollection<IAlteration> Alterations { get; set; } = new List<IAlteration>();
public ICollection<JsonObject> Alterations { get; set; } = new List<JsonObject>();
/// <summary>
/// The IDs of the workflow instances that this plan applies to.

View file

@ -1,6 +0,0 @@
namespace Elsa.Api.Client.Resources.Alterations.Models;
/// <summary>
/// Cancels the workflow instances in an alteration plan.
/// </summary>
public class Cancel : AlterationBase;

View file

@ -1,17 +0,0 @@
namespace Elsa.Api.Client.Resources.Alterations.Models;
/// <summary>
/// Cancels a workflow instance activity during an alteration
/// </summary>
public class CancelActivity : AlterationBase
{
/// <summary>
/// The ID of the activity to be cancelled. If not specified, the activity instance ID will be used.
/// </summary>
public string? ActivityId { get; set; }
/// <summary>
/// The ID of the activity instance to be cancelled. If specified, overrides <see cref="ActivityId"/>.
/// </summary>
public string? ActivityInstanceId { get; set; }
}

View file

@ -1,12 +0,0 @@
namespace Elsa.Api.Client.Resources.Alterations.Models;
/// <summary>
/// Migrates a workflow instance to a newer version in an alteration.
/// </summary>
public class Migrate : AlterationBase
{
/// <summary>
/// The target version to upgrade to.
/// </summary>
public int TargetVersion { get; set; }
}

View file

@ -1,18 +0,0 @@
namespace Elsa.Api.Client.Resources.Alterations.Models;
/// <summary>
/// Modifies a variable in a workflow instance alteration
/// </summary>
public class ModifyVariable : AlterationBase
{
/// <summary>
/// The ID of the variable to modify.
/// </summary>
public string VariableId { get; set; } = default!;
/// <summary>
/// The new value of the variable.
/// </summary>
public object? Value { get; set; }
}

View file

@ -1,17 +0,0 @@
namespace Elsa.Api.Client.Resources.Alterations.Models;
/// <summary>
/// Schedules an activity for execution in an alteration.
/// </summary>
public class ScheduleActivity : AlterationBase
{
/// <summary>
/// The ID of the next activity to be scheduled. If not specified, the ActivityInstanceId will be used.
/// </summary>
public string? ActivityId { get; set; }
/// <summary>
/// The ID of the activity instance to be scheduled. If not specified, the ActivityId will be used.
/// </summary>
public string? ActivityInstanceId { get; set; }
}

View file

@ -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
/// <summary>
/// The alterations to be applied.
/// </summary>
public ICollection<IAlteration> Alterations { get; set; } = new List<IAlteration>();
public ICollection<JsonObject> Alterations { get; set; } = new List<JsonObject>();
/// <summary>
/// The IDs of the workflow instances that this plan applies to.

View file

@ -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 <see cref="AlterationPlanCompleted"/> notifications and triggers any workflows that are waiting for the plan to complete.
/// </summary>
[UsedImplicitly]
public class AlterationPlanCompletedHandler(IWorkflowDispatcher workflowDispatcher) : INotificationHandler<AlterationPlanCompleted>
public class AlterationPlanCompletedHandler(IBookmarkQueue bookmarkQueue, IStimulusHasher stimulusHasher) : INotificationHandler<AlterationPlanCompleted>
{
/// <inheritdoc />
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<Activities.AlterationPlanCompleted>(), bookmarkPayload);
await workflowDispatcher.DispatchAsync(triggerRequest, cancellationToken);
var activityTypeName = ActivityTypeNameHelper.GenerateTypeName<Activities.AlterationPlanCompleted>();
var item = new NewBookmarkQueueItem
{
ActivityTypeName = activityTypeName,
StimulusHash = stimulusHasher.Hash(activityTypeName, bookmarkPayload)
};
await bookmarkQueue.EnqueueAsync(item, cancellationToken);
}
}

View file

@ -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;

View file

@ -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)

View file

@ -1,7 +1,7 @@
using System.IO.Compression;
using System.Text;
namespace Elsa.Workflows.Management.Compression;
namespace Elsa.Common.Codecs;
/// <summary>
/// Represents a GZip compression strategy.

View file

@ -1,4 +1,4 @@
namespace Elsa.Workflows.Management.Compression;
namespace Elsa.Common.Codecs;
/// <summary>
/// Represents a compression strategy that does not compress or decompress the input.

View file

@ -1,7 +1,7 @@
using System.Text;
using IronCompress;
namespace Elsa.Workflows.Management.Compression;
namespace Elsa.Common.Codecs;
/// <summary>
/// Represents a ZSTD compression strategy.

View file

@ -1,4 +1,4 @@
namespace Elsa.Workflows.Management;
namespace Elsa.Common;
/// <summary>
/// Represents a compression strategy.

View file

@ -1,4 +1,4 @@
namespace Elsa.Workflows.Management;
namespace Elsa.Common;
/// <summary>
/// Resolves a <see cref="ICompressionCodec"/> from its name.

View file

@ -16,6 +16,7 @@
<ItemGroup>
<PackageReference Include="Cronos" />
<PackageReference Include="DistributedLock.Core" />
<PackageReference Include="IronCompress" />
<PackageReference Include="LinqKit.Core" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />

View file

@ -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<ICompressionCodecResolver, CompressionCodecResolver>()
.AddSingleton<ICompressionCodec, None>()
.AddSingleton<ICompressionCodec, GZip>()
.AddSingleton<ICompressionCodec, Zstd>();
}
}

View file

@ -13,7 +13,7 @@ public class Tenant : Entity
/// <summary>
/// Gets or sets the name.
/// </summary>
public string Name { get; set; } = default!;
public string Name { get; set; } = null!;
/// <summary>
/// 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"
};
}

View file

@ -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<Tenant?> 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<Tenant?> FindAsync(TenantFilter filter, CancellationToken cancellationToken = default)
@ -31,7 +32,7 @@ public class DefaultTenantService(IServiceScopeFactory scopeFactory, ITenantScop
public async Task<Tenant> GetAsync(string id, CancellationToken cancellationToken = default)
{
var dictionary = await GetTenantsDictionaryAsync(cancellationToken);
return dictionary[id];
return dictionary[id.EmptyIfNull()];
}
public async Task<Tenant> GetAsync(TenantFilter filter, CancellationToken cancellationToken = default)
@ -76,7 +77,7 @@ public class DefaultTenantService(IServiceScopeFactory scopeFactory, ITenantScop
var tenantsProvider = scope.ServiceProvider.GetRequiredService<ITenantsProvider>();
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,7 +130,7 @@ 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))
@ -140,7 +141,7 @@ public class DefaultTenantService(IServiceScopeFactory scopeFactory, ITenantScop
{
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(tenant, scope, cancellationToken));

View file

@ -11,7 +11,7 @@ public class TenantFilter
/// <summary>
/// Gets or sets the tenant ID to filter for.
/// </summary>
public string? Id { get; set; }
public string Id { get; set; } = null!;
/// <summary>
/// Applies the filter to the specified queryable.
@ -20,7 +20,7 @@ public class TenantFilter
/// <returns>The filtered queryable.</returns>
public IQueryable<Tenant> Apply(IQueryable<Tenant> queryable)
{
if (Id != null) queryable = queryable.Where(x => x.Id == Id);
queryable = queryable.Where(x => x.Id == Id);
return queryable;
}

View file

@ -1,6 +1,6 @@
using Elsa.Workflows.Management.Compression;
using Elsa.Common.Codecs;
namespace Elsa.Workflows.Management.Services;
namespace Elsa.Common.Services;
/// <inheritdoc />
public class CompressionCodecResolver : ICompressionCodecResolver

View file

@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Open.Linq.AsyncExtensions" />
<PackageReference Include="Scrutor" />
<PackageReference Include="System.CommandLine" />
</ItemGroup>

View file

@ -1,8 +1,8 @@
using Elsa.Common.Entities;
using Elsa.Common.Multitenancy;
using Elsa.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.EntityFrameworkCore;
@ -50,7 +50,7 @@ public abstract class ElsaDbContextBase : DbContext, IElsaDbContextSchema
var tenantId = tenantAccessor?.Tenant?.Id;
if (!string.IsNullOrWhiteSpace(tenantId))
TenantId = tenantId;
TenantId = tenantId.NullIfEmpty();
}
/// <inheritdoc/>

View file

@ -33,6 +33,5 @@ public class ElsaDbContextOptionsExtension : IDbContextOptionsExtension
/// <inheritdoc />
public void Validate(IDbContextOptions options)
{
}
}

View file

@ -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;
}

View file

@ -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<Func<Entity, bool>> filterExpr = entity => dbContext.TenantId == entity.TenantId;
Expression<Func<Entity, bool>> filterExpr = entity => entity.TenantId == tenantId;
var body = ReplacingExpressionVisitor.Replace(filterExpr.Parameters[0], parameter, filterExpr.Body);
var lambdaExpression = Expression.Lambda(body, parameter);

View file

@ -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

View file

@ -60,6 +60,8 @@ public abstract class PersistenceFeatureBase<TFeature, TDbContext>(IModule modul
Services.AddPooledDbContextFactory<TDbContext>(setup);
else
Services.AddDbContextFactory<TDbContext>(setup, DbContextFactoryLifetime);
Services.Decorate<IDbContextFactory<TDbContext>, TenantAwareDbContextFactory<TDbContext>>();
}
protected virtual void ConfigureMigrations()

View file

@ -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<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
/// <param name="cancellationToken">The cancellation token.</param>
public async Task AddManyAsync(
IEnumerable<TEntity> entities,
Func<TDbContext, TEntity, CancellationToken, ValueTask>? onSaving = default,
Func<TDbContext, TEntity, CancellationToken, ValueTask>? onSaving = null,
CancellationToken cancellationToken = default)
{
var entityList = entities.ToList();
@ -151,7 +153,7 @@ public class Store<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
/// <param name="entities">The entities to save.</param>
/// <param name="keySelector">The key selector to get the primary key property.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task SaveManyAsync(IEnumerable<TEntity> entities, Expression<Func<TEntity, string>> keySelector, CancellationToken cancellationToken = default) => await SaveManyAsync(entities, keySelector, default, cancellationToken);
public async Task SaveManyAsync(IEnumerable<TEntity> entities, Expression<Func<TEntity, string>> keySelector, CancellationToken cancellationToken = default) => await SaveManyAsync(entities, keySelector, null, cancellationToken);
/// <summary>
/// Saves the specified entities.
@ -163,7 +165,7 @@ public class Store<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
public async Task SaveManyAsync(
IEnumerable<TEntity> entities,
Expression<Func<TEntity, string>> keySelector,
Func<TDbContext, TEntity, CancellationToken, ValueTask>? onSaving = default,
Func<TDbContext, TEntity, CancellationToken, ValueTask>? onSaving = null,
CancellationToken cancellationToken = default)
{
var entityList = entities.ToList();
@ -179,6 +181,14 @@ public class Store<TDbContext, TEntity>(IDbContextFactory<TDbContext> 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<ITenantAccessor>().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<TDbContext, TEntity>(IDbContextFactory<TDbContext> 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<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
/// <param name="predicate">The predicate to use.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The entity if found, otherwise <c>null</c>.</returns>
public async Task<TEntity?> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default) => await FindAsync(predicate, default, cancellationToken);
public async Task<TEntity?> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default) => await FindAsync(predicate, null, cancellationToken);
/// <summary>
/// Finds the entity matching the specified predicate.
@ -258,7 +268,7 @@ public class Store<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
/// <param name="onLoading">A callback to run after the entity is loaded</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns></returns>
public async Task<TEntity?> FindAsync(Expression<Func<TEntity, bool>> predicate, Func<TDbContext, TEntity?, TEntity?>? onLoading = default, CancellationToken cancellationToken = default)
public async Task<TEntity?> FindAsync(Expression<Func<TEntity, bool>> predicate, Func<TDbContext, TEntity?, TEntity?>? onLoading = null, CancellationToken cancellationToken = default)
{
await using var dbContext = await CreateDbContextAsync(cancellationToken);
var set = dbContext.Set<TEntity>().AsNoTracking();
@ -267,7 +277,7 @@ public class Store<TDbContext, TEntity>(IDbContextFactory<TDbContext> 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<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
/// <param name="onLoading">A callback to run after the entity is loaded</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <returns>The entity if found, otherwise <c>null</c></returns>
public async Task<TEntity?> FindAsync(Func<IQueryable<TEntity>, IQueryable<TEntity>> query, Func<TDbContext, TEntity?, CancellationToken, ValueTask>? onLoading = default, CancellationToken cancellationToken = default)
public async Task<TEntity?> FindAsync(Func<IQueryable<TEntity>, IQueryable<TEntity>> query, Func<TDbContext, TEntity?, CancellationToken, ValueTask>? onLoading = null, CancellationToken cancellationToken = default)
{
return await FindAsync(query, onLoading, false, cancellationToken);
}
@ -293,7 +303,7 @@ public class Store<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
/// <param name="tenantAgnostic">Define is the request should be tenant agnostic or not</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <returns>The entity if found, otherwise <c>null</c></returns>
public async Task<TEntity?> FindAsync(Func<IQueryable<TEntity>, IQueryable<TEntity>> query, Func<TDbContext, TEntity?, CancellationToken, ValueTask>? onLoading = default, bool tenantAgnostic = false, CancellationToken cancellationToken = default)
public async Task<TEntity?> FindAsync(Func<IQueryable<TEntity>, IQueryable<TEntity>> query, Func<TDbContext, TEntity?, CancellationToken, ValueTask>? onLoading = null, bool tenantAgnostic = false, CancellationToken cancellationToken = default)
{
return await QueryAsync(query, onLoading, tenantAgnostic, cancellationToken).FirstOrDefault();
}
@ -324,12 +334,12 @@ public class Store<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
/// <summary>
/// Finds a list of entities using a query
/// </summary>
public async Task<IEnumerable<TEntity>> FindManyAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default) => await FindManyAsync(predicate, default, cancellationToken);
public async Task<IEnumerable<TEntity>> FindManyAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default) => await FindManyAsync(predicate, null, cancellationToken);
/// <summary>
/// Finds a list of entities using a query
/// </summary>
public async Task<IEnumerable<TEntity>> FindManyAsync(Expression<Func<TEntity, bool>> predicate, Action<TDbContext, TEntity?>? onLoading = default, CancellationToken cancellationToken = default)
public async Task<IEnumerable<TEntity>> FindManyAsync(Expression<Func<TEntity, bool>> predicate, Action<TDbContext, TEntity?>? onLoading = null, CancellationToken cancellationToken = default)
{
await using var dbContext = await CreateDbContextAsync(cancellationToken);
var set = dbContext.Set<TEntity>().AsNoTracking();
@ -349,9 +359,9 @@ public class Store<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
Expression<Func<TEntity, bool>> predicate,
Expression<Func<TEntity, TKey>> 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);
/// <summary>
/// Returns a list of entities using a query
@ -360,8 +370,8 @@ public class Store<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
Expression<Func<TEntity, bool>>? predicate,
Expression<Func<TEntity, TKey>>? orderBy,
OrderDirection orderDirection = OrderDirection.Ascending,
PageArgs? pageArgs = default,
Func<TDbContext, TEntity?, TEntity?>? onLoading = default,
PageArgs? pageArgs = null,
Func<TDbContext, TEntity?, TEntity?>? onLoading = null,
CancellationToken cancellationToken = default)
{
await using var dbContext = await CreateDbContextAsync(cancellationToken);
@ -394,7 +404,7 @@ public class Store<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
return ListAsync(null, cancellationToken);
}
public async Task<IEnumerable<TEntity>> ListAsync(Action<TDbContext, TEntity?>? onLoading = default, CancellationToken cancellationToken = default)
public async Task<IEnumerable<TEntity>> ListAsync(Action<TDbContext, TEntity?>? onLoading = null, CancellationToken cancellationToken = default)
{
await using var dbContext = await CreateDbContextAsync(cancellationToken);
var set = dbContext.Set<TEntity>().AsNoTracking();
@ -447,7 +457,7 @@ public class Store<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
/// </summary>
public async Task<IEnumerable<TEntity>> QueryAsync(Func<IQueryable<TEntity>, IQueryable<TEntity>> query, CancellationToken cancellationToken = default)
{
return await QueryAsync(query, default, false, cancellationToken);
return await QueryAsync(query, null, false, cancellationToken);
}
/// <summary>
@ -455,13 +465,13 @@ public class Store<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
/// </summary>
public async Task<IEnumerable<TEntity>> QueryAsync(Func<IQueryable<TEntity>, IQueryable<TEntity>> query, bool tenantAgnostic, CancellationToken cancellationToken = default)
{
return await QueryAsync(query, default, tenantAgnostic, cancellationToken);
return await QueryAsync(query, null, tenantAgnostic, cancellationToken);
}
/// <summary>
/// Queries the database using a query and a selector.
/// </summary>
public async Task<IEnumerable<TEntity>> QueryAsync(Func<IQueryable<TEntity>, IQueryable<TEntity>> query, Func<TDbContext, TEntity?, CancellationToken, ValueTask>? onLoading = default, CancellationToken cancellationToken = default)
public async Task<IEnumerable<TEntity>> QueryAsync(Func<IQueryable<TEntity>, IQueryable<TEntity>> query, Func<TDbContext, TEntity?, CancellationToken, ValueTask>? onLoading = null, CancellationToken cancellationToken = default)
{
return await QueryAsync(query, onLoading, false, cancellationToken);
}
@ -469,7 +479,7 @@ public class Store<TDbContext, TEntity>(IDbContextFactory<TDbContext> dbContextF
/// <summary>
/// Queries the database using a query and a selector.
/// </summary>
public async Task<IEnumerable<TEntity>> QueryAsync(Func<IQueryable<TEntity>, IQueryable<TEntity>> query, Func<TDbContext, TEntity?, CancellationToken, ValueTask>? onLoading = default, bool ignoreQueryFilters = false, CancellationToken cancellationToken = default)
public async Task<IEnumerable<TEntity>> QueryAsync(Func<IQueryable<TEntity>, IQueryable<TEntity>> query, Func<TDbContext, TEntity?, CancellationToken, ValueTask>? onLoading = null, bool ignoreQueryFilters = false, CancellationToken cancellationToken = default)
{
await using var dbContext = await CreateDbContextAsync(cancellationToken);
var asNoTracking = onLoading == null;

View file

@ -0,0 +1,37 @@
using Elsa.Common.Multitenancy;
using Elsa.Extensions;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
namespace Elsa.EntityFrameworkCore;
/// <summary>
/// A factory class that wraps and extends the functionality of an existing <see cref="IDbContextFactory{TDbContext}"/>
/// to provide multi-tenancy support. This ensures that the created DbContext instances are aware of the current tenant context.
/// </summary>
[UsedImplicitly]
public class TenantAwareDbContextFactory<TDbContext>(
IDbContextFactory<TDbContext> decoratedFactory,
ITenantAccessor tenantAccessor) : IDbContextFactory<TDbContext>
where TDbContext : DbContext
{
public TDbContext CreateDbContext()
{
var context = decoratedFactory.CreateDbContext();
SetTenantId(context);
return context;
}
public async Task<TDbContext> 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();
}
}

View file

@ -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;

View file

@ -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;

View file

@ -27,7 +27,7 @@ public record ObjectConverterOptions(JsonSerializerOptions? SerializerOptions =
/// </summary>
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.
/// <summary>
/// Attempts to convert the source value into the destination type.

View file

@ -55,7 +55,7 @@ public class ConfigureEngineWithVariablesAndInputOutputAccessors(IOptions<JintOp
if (context.IsContainedWithinCompositeActivity())
return;
var inputs = context.GetWorkflowInputs().Where(x => 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;

View file

@ -32,8 +32,8 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification
public async Task<object?> EvaluateAsync(string expression,
Type returnType,
ExpressionExecutionContext context,
ExpressionEvaluatorOptions? options = default,
Action<Engine>? configureEngine = default,
ExpressionEvaluatorOptions? options = null,
Action<Engine>? 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<Engine> GetConfiguredEngine(Action<Engine>? 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
}

View file

@ -16,48 +16,63 @@ public class MessageReceived : Trigger<object>
internal const string InputKey = "Message";
/// <inheritdoc />
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)
{
}
/// <summary>
/// The message type to receive.
/// </summary>
public Type MessageType { get; set; } = default!;
public Type MessageType { get; set; } = null!;
/// <inheritdoc />
protected override object GetTriggerPayload(TriggerIndexingContext context) => GetBookmarkPayload(context.ExpressionExecutionContext);
/// <inheritdoc />
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<object>(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.

View file

@ -81,7 +81,7 @@ public class MassTransitFeature : FeatureBase
var category = categoryAttr?.Category ?? activityAttr?.Category ?? "MassTransit";
var descriptionAttr = messageType.GetCustomAttribute<DescriptionAttribute>();
var description = descriptionAttr?.Description ?? activityAttr?.Description;
options.VariableDescriptors.Add(new VariableDescriptor(messageType, category, description));
options.VariableDescriptors.Add(new(messageType, category, description));
}
});
}

View file

@ -43,27 +43,27 @@ public class WorkflowsFeature : FeatureBase
public WorkflowsFeature(IModule module) : base(module)
{
}
/// <summary>
/// A factory that instantiates a concrete <see cref="IStandardInStreamProvider"/>.
/// </summary>
public Func<IServiceProvider, IStandardInStreamProvider> StandardInStreamProvider { get; set; } = _ => new StandardInStreamProvider(Console.In);
/// <summary>
/// A factory that instantiates a concrete <see cref="IStandardOutStreamProvider"/>.
/// </summary>
public Func<IServiceProvider, IStandardOutStreamProvider> StandardOutStreamProvider { get; set; } = _ => new StandardOutStreamProvider(Console.Out);
/// <summary>
/// A factory that instantiates a concrete <see cref="IIdentityGenerator"/>.
/// </summary>
public Func<IServiceProvider, IIdentityGenerator> IdentityGenerator { get; set; } = sp => new RandomLongIdentityGenerator();
/// <summary>
/// A handler for committing workflow execution state.
/// </summary>
public Func<IServiceProvider, ICommitStateHandler> CommitStateHandler { get; set; } = sp => new NoopCommitStateHandler();
/// <summary>
/// A factory that instantiates a concrete <see cref="ILoggerStateGenerator{WorkflowExecutionContext}"/>.
/// </summary>
@ -80,7 +80,7 @@ public class WorkflowsFeature : FeatureBase
public Action<IWorkflowExecutionPipelineBuilder> WorkflowExecutionPipeline { get; set; } = builder => builder
.UseExceptionHandling()
.UseDefaultActivityScheduler();
/// <summary>
/// A delegate to configure the <see cref="IActivityExecutionPipeline"/>.
/// </summary>
@ -94,7 +94,7 @@ public class WorkflowsFeature : FeatureBase
StandardInStreamProvider = provider;
return this;
}
/// <summary>
/// Fluent method to set <see cref="StandardOutStreamProvider"/>.
/// </summary>

View file

@ -32,14 +32,34 @@ public class ExceptionHandlingMiddleware(ActivityMiddlewareDelegate next, IIncid
catch (Exception e)
{
logger.LogWarning(e, "An exception was caught from a downstream middleware component");
context.Fault(e);
LogExceptionAndTransition(context, e);
FaultAncestors(context);
await HandleIncidentAsync(context);
}
}
private void LogExceptionAndTransition(ActivityExecutionContext context, Exception e)
{
context.Exception = e;
context.TransitionTo(ActivityStatus.Faulted);
var activity = context.Activity;
var exceptionState = ExceptionState.FromException(e);
var now = systemClock.UtcNow;
var incident = new ActivityIncident(activity.Id, activity.NodeId ,activity.Type, e.Message, exceptionState, now);
context.WorkflowExecutionContext.Incidents.Add(incident);
}
private async Task HandleIncidentAsync(ActivityExecutionContext context)
{
var strategy = await incidentStrategyResolver.ResolveStrategyAsync(context);
strategy.HandleIncident(context);
}
private static void FaultAncestors(ActivityExecutionContext context)
{
var ancestors = context.GetAncestors();
foreach (var ancestor in ancestors)
ancestor.TransitionTo(ActivityStatus.Faulted);
}
}

View file

@ -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;
/// <inheritdoc />
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;
/// <summary>
/// Constructor.
/// </summary>
public ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolver, IActivityFactory activityFactory, IPropertyUIHandlerResolver propertyUIHandlerResolver)
{
//_optionsResolver = optionsResolver;
_defaultValueResolver = defaultValueResolver;
_activityFactory = activityFactory;
_propertyUIHandlerResolver = propertyUIHandlerResolver;
}
/// <inheritdoc />
public async Task<ActivityDescriptor> 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,

View file

@ -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;
/// <summary>
/// Installs and configures the workflow management feature.
/// </summary>
[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<WorkflowDefinitionMapper>()
.AddSingleton<VariableDefinitionMapper>()
.AddSingleton<WorkflowStateMapper>()
.AddSingleton<ICompressionCodecResolver, CompressionCodecResolver>()
.AddSingleton<ICompressionCodec, None>()
.AddSingleton<ICompressionCodec, GZip>()
.AddSingleton<ICompressionCodec, Zstd>()
;
Services

View file

@ -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;
/// <summary>
/// Maps <see cref="Variable"/>s to <see cref="VariableDefinition"/>s and vice versa.
/// </summary>
public class VariableDefinitionMapper(IWellKnownTypeRegistry wellKnownTypeRegistry, ILogger<VariableDefinitionMapper> logger)
public class VariableDefinitionMapper(IWellKnownTypeRegistry wellKnownTypeRegistry, IServiceScopeFactory scopeFactory, ILogger<VariableDefinitionMapper> logger)
{
/// <summary>
/// Maps a <see cref="VariableDefinition"/> to a <see cref="Variable"/>.
@ -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 <see cref="Variable"/>s to a list of <see cref="VariableDefinition"/>s.
/// </summary>
public IEnumerable<VariableDefinition> Map(IEnumerable<Variable>? 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<IStorageDriver>();
foreach (var storageDriver in storageDrivers)
{
var typeName = storageDriver.GetType().Name;
if (storageDriverTypeName.Contains(typeName, StringComparison.OrdinalIgnoreCase))
return storageDriver.GetType();
}
return null;
}
}

View file

@ -3,4 +3,21 @@ namespace Elsa.Workflows.Management.Models;
/// <summary>
/// Represents a description of a .NET type that can be used as a workflow variable.
/// </summary>
public record VariableDescriptor(Type Type, string Category, string? Description);
public record VariableDescriptor(Type Type, string Category, string? Description)
{
public class VariableDescriptorComparer : IEqualityComparer<VariableDescriptor>
{
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();
}
}
}

View file

@ -16,7 +16,7 @@ public class ManagementOptions
/// <summary>
/// A collection of types that are available to the system as variable types.
/// </summary>
public HashSet<VariableDescriptor> VariableDescriptors { get; set; } = new();
public HashSet<VariableDescriptor> VariableDescriptors { get; set; } = new(new VariableDescriptor.VariableDescriptorComparer());
/// <summary>
/// The format to use for compressing workflow state.

View file

@ -31,8 +31,27 @@ public class DistributedWorkflowClient(
public async Task<RunWorkflowInstanceResponse> 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)

View file

@ -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());

View file

@ -37,7 +37,7 @@ public class BookmarkQueueSignaler : IBookmarkQueueSignaler
lock (_lock)
{
// Reset the TCS for the next wait
_tcs = new TaskCompletionSource<object?>();
_tcs = new();
}
}
}

View file

@ -104,7 +104,7 @@ public class LocalWorkflowClient(
return workflowInstanceManager.ExistsAsync(workflowInstanceId, cancellationToken);
}
private async Task<RunWorkflowInstanceResponse> RunInstanceAsync(WorkflowInstance workflowInstance, RunWorkflowInstanceRequest request, CancellationToken cancellationToken = default)
public async Task<RunWorkflowInstanceResponse> RunInstanceAsync(WorkflowInstance workflowInstance, RunWorkflowInstanceRequest request, CancellationToken cancellationToken = default)
{
var workflowState = workflowInstance.WorkflowState;

View file

@ -8,6 +8,11 @@ namespace Elsa.Workflows.Core.UnitTests.ObjectConversion;
public class Tests
{
public Tests()
{
ObjectConverter.StrictMode = true;
}
[Fact]
public void TryConvertTo_SameType_ReturnsSuccess()
{