Add shell middleware, call‑stack tracking, and workflow reference graph APIs (#7333)

* refactor(deps): use local CShells project refs

Replace CShells NuGet package references with direct project references to the local CShells source to enable developing and testing against local changes and simplify build integration across modules.

* Handle assembly load errors in feature discovery

Added error handling for assembly load failures in feature discovery to improve resilience. Also updated configuration for identity token options and removed unused service bus consumer dependencies. Simplified project structure by moving and cleaning up `Directory.Build.targets` files.

* Refactor configuration and service extension methods.

Moved `ShellSettingsExtensions` and `ShellConfiguration` to `CShells.Abstractions` for better modularity. Added new `ServiceCollectionFeatureExtensions` to improve options registration. Updated appsettings and references to support these changes.

* Introduce ManagementServiceCollectionExtensions to streamline activity and variable registration

Added `ManagementServiceCollectionExtensions` for registering Elsa activity types and variable descriptors, providing a modular and shell-feature-compatible approach to configuration. Updated relevant features to utilize these new extension methods, enhancing code modularity and reducing redundancy.

* Add resilience strategy registration to HTTP feature

Introduced `ResilienceServiceCollectionExtensions` to register resilience strategies within the `Elsa.Resilience.Core` module. Updated `HttpFeature` to incorporate resilience strategies, enhancing HTTP-related resilience configuration leveraging the new extension methods.

* Add new configuration options to JavaScriptFeature

Implemented multiple properties in `JavaScriptFeature` to enhance JavaScript execution: `AllowClrAccess`, `AllowConfigurationAccess`, `ScriptCacheTimeout`, `DisableWrappers`, and `DisableVariableCopying`. These additions enable more flexible and secure configuration of the Jint JavaScript engine.

* refactor(workflows): unify graph caching

Resolve workflow definitions first and store graphs under stable per-version-ID cache keys so different lookup paths share entries.
Centralize cache creation and change-token registration to remove duplicated caching logic.
Skip materializer-unavailable definitions to avoid caching null graphs and simplify flow.

* refactor(tests): centralize default IDs and materializer setup

Introduce constants for default definition and version IDs, and materializer name. Refactor tests to use these constants, streamline graph and definition resolution, and improve cache key creation by sharing logic across tests. Extend tests to check scenarios with unavailable materializers, ensuring caching only occurs for valid cases.

* extend(tests): enhance cache key verification in AutoUpdateTests

Added checks for both workflow definition and version cache keys in AutoUpdateTests to ensure comprehensive cache validation, improving test reliability and coverage.

* refactor(projects): update CShells project paths and solution configuration

Revised project reference paths in `Elsa.ModularServer.Web.csproj` for CShells projects and updated `Elsa.sln` to include new CShells projects, streamlining project organization and build configuration.

* Add `IWorkflowReferenceGraphBuilder` to `WorkflowManagementFeature`; rename `ResilienceShellFeature` to `ResilienceFeature`.

* Refactor `HttpFeature` to use `IMiddlewareShellFeature`, include `HttpWorkflowsMiddleware`, and update `HttpActivityOptions` defaults.

* Add `AddTypeAlias` and `AddVariableTypeAndAlias` extension methods to service collections

- Introduced `AddTypeAlias<T>` method in `ServiceCollectionExtensions.cs` for adding type aliases.
- Added `AddVariableTypeAndAlias<T>` method in `ManagementServiceCollectionExtensions.cs` to add variable types with aliases.

* Update CShells package versions to 0.0.11 and replace ProjectReferences with PackageReferences in project files
This commit is contained in:
Sipke Schoorstra 2026-02-28 21:16:04 +01:00 committed by GitHub
parent a79d6a257e
commit 11fec1c85d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 538 additions and 237 deletions

7
Directory.Build.targets Normal file
View file

@ -0,0 +1,7 @@
<Project>
<!-- Use project references instead of NuGet packages for local development -->
<PropertyGroup>
<UseProjectReferences>true</UseProjectReferences>
</PropertyGroup>
</Project>

View file

@ -100,12 +100,12 @@
<PackageVersion Include="coverlet.collector" Version="6.0.4" PrivateAssets="All"/> <PackageVersion Include="coverlet.collector" Version="6.0.4" PrivateAssets="All"/>
<PackageVersion Include="coverlet.msbuild" Version="6.0.4" PrivateAssets="All"/> <PackageVersion Include="coverlet.msbuild" Version="6.0.4" PrivateAssets="All"/>
<PackageVersion Include="Cronos" Version="0.11.1"/> <PackageVersion Include="Cronos" Version="0.11.1"/>
<PackageVersion Include="CShells" Version="0.0.9"/> <PackageVersion Include="CShells" Version="0.0.11"/>
<PackageVersion Include="CShells.Abstractions" Version="0.0.9"/> <PackageVersion Include="CShells.Abstractions" Version="0.0.11"/>
<PackageVersion Include="CShells.AspNetCore" Version="0.0.9"/> <PackageVersion Include="CShells.AspNetCore" Version="0.0.11"/>
<PackageVersion Include="CShells.AspNetCore.Abstractions" Version="0.0.9"/> <PackageVersion Include="CShells.AspNetCore.Abstractions" Version="0.0.11"/>
<PackageVersion Include="CShells.FastEndpoints" Version="0.0.9"/> <PackageVersion Include="CShells.FastEndpoints" Version="0.0.11"/>
<PackageVersion Include="CShells.FastEndpoints.Abstractions" Version="0.0.9"/> <PackageVersion Include="CShells.FastEndpoints.Abstractions" Version="0.0.11"/>
<PackageVersion Include="Datadog.Trace.Bundle" Version="3.32.0"/> <PackageVersion Include="Datadog.Trace.Bundle" Version="3.32.0"/>
<PackageVersion Include="DistributedLock" Version="2.7.1"/> <PackageVersion Include="DistributedLock" Version="2.7.1"/>
<PackageVersion Include="DistributedLock.Core" Version="1.0.8"/> <PackageVersion Include="DistributedLock.Core" Version="1.0.8"/>

View file

@ -337,6 +337,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Http.IntegrationTests"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Tenants.UnitTests", "test\unit\Elsa.Tenants.UnitTests\Elsa.Tenants.UnitTests.csproj", "{DC476900-D836-4920-A696-CF8796668723}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Tenants.UnitTests", "test\unit\Elsa.Tenants.UnitTests\Elsa.Tenants.UnitTests.csproj", "{DC476900-D836-4920-A696-CF8796668723}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cshells", "cshells", "{E85D181A-2633-440B-AF1E-2DA31C910BFD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CShells.Abstractions", "..\cshells\src\CShells.Abstractions\CShells.Abstractions.csproj", "{FD6D8905-1181-4524-8241-2BFD61BA92E4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CShells", "..\cshells\src\CShells\CShells.csproj", "{3DDA30C9-E530-4CFD-A383-9B12DBCC6ED4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CShells.AspNetCore.Abstractions", "..\cshells\src\CShells.AspNetCore.Abstractions\CShells.AspNetCore.Abstractions.csproj", "{F0B9F468-4FB8-497D-B0AC-4567F3126D91}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CShells.AspNetCore", "..\cshells\src\CShells.AspNetCore\CShells.AspNetCore.csproj", "{85AFD6A7-BFBA-46BC-8701-90565C9C7473}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CShells.FastEndpoints.Abstractions", "..\cshells\src\CShells.FastEndpoints.Abstractions\CShells.FastEndpoints.Abstractions.csproj", "{80137E60-7C6D-411B-808A-092BA05E5B04}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CShells.FastEndpoints", "..\cshells\src\CShells.FastEndpoints\CShells.FastEndpoints.csproj", "{3C64C25E-F825-42CC-A696-9CD30F13B9A6}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -613,6 +627,30 @@ Global
{DC476900-D836-4920-A696-CF8796668723}.Debug|Any CPU.Build.0 = Debug|Any CPU {DC476900-D836-4920-A696-CF8796668723}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DC476900-D836-4920-A696-CF8796668723}.Release|Any CPU.ActiveCfg = Release|Any CPU {DC476900-D836-4920-A696-CF8796668723}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DC476900-D836-4920-A696-CF8796668723}.Release|Any CPU.Build.0 = Release|Any CPU {DC476900-D836-4920-A696-CF8796668723}.Release|Any CPU.Build.0 = Release|Any CPU
{FD6D8905-1181-4524-8241-2BFD61BA92E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FD6D8905-1181-4524-8241-2BFD61BA92E4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FD6D8905-1181-4524-8241-2BFD61BA92E4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FD6D8905-1181-4524-8241-2BFD61BA92E4}.Release|Any CPU.Build.0 = Release|Any CPU
{3DDA30C9-E530-4CFD-A383-9B12DBCC6ED4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3DDA30C9-E530-4CFD-A383-9B12DBCC6ED4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3DDA30C9-E530-4CFD-A383-9B12DBCC6ED4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3DDA30C9-E530-4CFD-A383-9B12DBCC6ED4}.Release|Any CPU.Build.0 = Release|Any CPU
{F0B9F468-4FB8-497D-B0AC-4567F3126D91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F0B9F468-4FB8-497D-B0AC-4567F3126D91}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F0B9F468-4FB8-497D-B0AC-4567F3126D91}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F0B9F468-4FB8-497D-B0AC-4567F3126D91}.Release|Any CPU.Build.0 = Release|Any CPU
{85AFD6A7-BFBA-46BC-8701-90565C9C7473}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{85AFD6A7-BFBA-46BC-8701-90565C9C7473}.Debug|Any CPU.Build.0 = Debug|Any CPU
{85AFD6A7-BFBA-46BC-8701-90565C9C7473}.Release|Any CPU.ActiveCfg = Release|Any CPU
{85AFD6A7-BFBA-46BC-8701-90565C9C7473}.Release|Any CPU.Build.0 = Release|Any CPU
{80137E60-7C6D-411B-808A-092BA05E5B04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{80137E60-7C6D-411B-808A-092BA05E5B04}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80137E60-7C6D-411B-808A-092BA05E5B04}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80137E60-7C6D-411B-808A-092BA05E5B04}.Release|Any CPU.Build.0 = Release|Any CPU
{3C64C25E-F825-42CC-A696-9CD30F13B9A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3C64C25E-F825-42CC-A696-9CD30F13B9A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3C64C25E-F825-42CC-A696-9CD30F13B9A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3C64C25E-F825-42CC-A696-9CD30F13B9A6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -719,6 +757,12 @@ Global
{A3C07D5B-2A30-494E-B9BC-4B1594B31ABC} = {18453B51-25EB-4317-A4B3-B10518252E92} {A3C07D5B-2A30-494E-B9BC-4B1594B31ABC} = {18453B51-25EB-4317-A4B3-B10518252E92}
{8C4F6A2D-1E9F-4B3C-9D8E-7F5A6B4C3D2E} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} {8C4F6A2D-1E9F-4B3C-9D8E-7F5A6B4C3D2E} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
{DC476900-D836-4920-A696-CF8796668723} = {18453B51-25EB-4317-A4B3-B10518252E92} {DC476900-D836-4920-A696-CF8796668723} = {18453B51-25EB-4317-A4B3-B10518252E92}
{FD6D8905-1181-4524-8241-2BFD61BA92E4} = {E85D181A-2633-440B-AF1E-2DA31C910BFD}
{3DDA30C9-E530-4CFD-A383-9B12DBCC6ED4} = {E85D181A-2633-440B-AF1E-2DA31C910BFD}
{F0B9F468-4FB8-497D-B0AC-4567F3126D91} = {E85D181A-2633-440B-AF1E-2DA31C910BFD}
{85AFD6A7-BFBA-46BC-8701-90565C9C7473} = {E85D181A-2633-440B-AF1E-2DA31C910BFD}
{80137E60-7C6D-411B-808A-092BA05E5B04} = {E85D181A-2633-440B-AF1E-2DA31C910BFD}
{3C64C25E-F825-42CC-A696-9CD30F13B9A6} = {E85D181A-2633-440B-AF1E-2DA31C910BFD}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -26,13 +26,49 @@ namespace Elsa.Expressions.JavaScript.ShellFeatures;
public class JavaScriptFeature : IShellFeature public class JavaScriptFeature : IShellFeature
{ {
/// <summary> /// <summary>
/// Configures the Jint options. /// Enables access to any .NET class. Do not enable if you are executing workflows from untrusted sources (e.g. user defined workflows).
///
/// See Jint docs for more: https://github.com/sebastienros/jint#accessing-net-assemblies-and-classes
/// </summary> /// </summary>
public Action<JintOptions> JintOptions { get; set; } = _ => { }; public bool AllowClrAccess { get; set; }
/// <summary>
/// Enables access to .NET configuration via the <c>getConfig</c> function.
/// Do not enable if you are executing workflows from untrusted sources (e.g user defined workflows).
/// </summary>
public bool AllowConfigurationAccess { get; set; }
/// <summary>
/// The timeout for script caching.
/// </summary>
/// <remarks>
/// The <c>ScriptCacheTimeout</c> property specifies the duration for which the scripts are cached in the Jint JavaScript engine. When a script is executed, it is compiled and cached for future use. This caching improves performance by avoiding repetitive compilation of the same script.
/// If the value of <c>ScriptCacheTimeout</c> is <c>null</c>, the scripts are cached indefinitely. If a time value is specified, the scripts will be purged from the cache after they've been unused for the specified duration and recompiled on next use.
/// </remarks>
public TimeSpan? ScriptCacheTimeout { get; set; } = TimeSpan.FromDays(1);
/// <summary>
/// Disables the generation of variable wrappers. E.g. <c>getMyVariable()</c> will no longer be available for variables. Instead, you can only access variables using <c>getVariable("MyVariable")</c> function.
/// This is useful if your application requires the use of invalid JavaScript variable names.
/// </summary>
public bool DisableWrappers { get; set; }
/// <summary>
/// Disables copying workflow variables into the Jint engine and copying them back into the workflow execution context.
/// Disabling this option will increase performance but will also prevent you from accessing workflow variables from within JavaScript expressions using the <c>variables.MyVariable</c> syntax.
/// </summary>
public bool DisableVariableCopying { get; set; }
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
services.Configure(JintOptions); services.Configure<JintOptions>(options =>
{
options.AllowClrAccess = AllowClrAccess;
options.AllowConfigurationAccess = AllowConfigurationAccess;
options.ScriptCacheTimeout = ScriptCacheTimeout;
options.DisableWrappers = DisableWrappers;
options.DisableVariableCopying = DisableVariableCopying;
});
// JavaScript services. // JavaScript services.
services services

View file

@ -0,0 +1,14 @@
using Elsa.Expressions.Options;
using Elsa.Extensions;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Expressions.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddTypeAlias<T>(this IServiceCollection services, string alias)
{
services.Configure<ExpressionOptions>(options => options.AddTypeAlias<T>(alias));
return services;
}
}

View file

@ -27,13 +27,17 @@ namespace Elsa.Http.Middleware;
/// An ASP.NET middleware component that tries to match the inbound request path to an associated workflow and then run that workflow. /// An ASP.NET middleware component that tries to match the inbound request path to an associated workflow and then run that workflow.
/// </summary> /// </summary>
[PublicAPI] [PublicAPI]
public class HttpWorkflowsMiddleware(RequestDelegate next, IOptions<HttpActivityOptions> options) public class HttpWorkflowsMiddleware(RequestDelegate next)
{ {
/// <summary> /// <summary>
/// Attempts to match the inbound request path to an associated workflow and then run that workflow. /// Attempts to match the inbound request path to an associated workflow and then run that workflow.
/// </summary> /// </summary>
[RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)")] [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)")]
public async Task InvokeAsync(HttpContext httpContext, IServiceProvider serviceProvider) public async Task InvokeAsync(
HttpContext httpContext,
IServiceProvider serviceProvider,
IOptions<HttpActivityOptions> options,
IHttpWorkflowLookupService httpWorkflowLookupService)
{ {
var path = httpContext.Request.Path.Value!.NormalizeRoute(); var path = httpContext.Request.Path.Value!.NormalizeRoute();
var matchingPath = GetMatchingRoute(serviceProvider, path).Route; var matchingPath = GetMatchingRoute(serviceProvider, path).Route;
@ -63,7 +67,6 @@ public class HttpWorkflowsMiddleware(RequestDelegate next, IOptions<HttpActivity
var cancellationToken = httpContext.RequestAborted; var cancellationToken = httpContext.RequestAborted;
var request = httpContext.Request; var request = httpContext.Request;
var method = request.Method.ToLowerInvariant(); var method = request.Method.ToLowerInvariant();
var httpWorkflowLookupService = serviceProvider.GetRequiredService<IHttpWorkflowLookupService>();
var workflowInstanceId = await GetWorkflowInstanceIdAsync(serviceProvider, httpContext, cancellationToken); var workflowInstanceId = await GetWorkflowInstanceIdAsync(serviceProvider, httpContext, cancellationToken);
var correlationId = await GetCorrelationIdAsync(serviceProvider, httpContext, cancellationToken); var correlationId = await GetCorrelationIdAsync(serviceProvider, httpContext, cancellationToken);
var bookmarkHash = ComputeBookmarkHash(serviceProvider, matchingPath, method); var bookmarkHash = ComputeBookmarkHash(serviceProvider, matchingPath, method);

View file

@ -15,7 +15,7 @@ public class HttpActivityOptions
/// <summary> /// <summary>
/// The base URL of the server. This should be set to the same value at which the Elsa Server is publicly available. It will be used when generating absolute URLs need to be generated by activities such as SendEmail. /// The base URL of the server. This should be set to the same value at which the Elsa Server is publicly available. It will be used when generating absolute URLs need to be generated by activities such as SendEmail.
/// </summary> /// </summary>
public Uri BaseUrl { get; set; } = null!; public Uri BaseUrl { get; set; } = new Uri("https://localhost:5001");
/// <summary> /// <summary>
/// The prefix used for API routes. /// The prefix used for API routes.
@ -42,5 +42,5 @@ public class HttpActivityOptions
/// A boolean to opt-in if you want to call CompleteAsync() in the HttpResponse Activity /// A boolean to opt-in if you want to call CompleteAsync() in the HttpResponse Activity
/// and not waiting for the end of the HttpMiddleware. /// and not waiting for the end of the HttpMiddleware.
/// </summary> /// </summary>
public Boolean WriteHttpResponseSynchronously { get; set; } = false; public bool WriteHttpResponseSynchronously { get; set; } = false;
} }

View file

@ -1,3 +1,4 @@
using CShells.AspNetCore.Features;
using CShells.Features; using CShells.Features;
using Elsa.Expressions.Options; using Elsa.Expressions.Options;
using Elsa.Extensions; using Elsa.Extensions;
@ -6,20 +7,27 @@ using Elsa.Http.ContentWriters;
using Elsa.Http.DownloadableContentHandlers; using Elsa.Http.DownloadableContentHandlers;
using Elsa.Http.FileCaches; using Elsa.Http.FileCaches;
using Elsa.Http.Handlers; using Elsa.Http.Handlers;
using Elsa.Http.Middleware;
using Elsa.Http.Options; using Elsa.Http.Options;
using Elsa.Http.Parsers; using Elsa.Http.Parsers;
using Elsa.Http.PortResolvers; using Elsa.Http.PortResolvers;
using Elsa.Http.Resilience;
using Elsa.Http.Selectors; using Elsa.Http.Selectors;
using Elsa.Http.Services; using Elsa.Http.Services;
using Elsa.Http.Tasks; using Elsa.Http.Tasks;
using Elsa.Http.TriggerPayloadValidators; using Elsa.Http.TriggerPayloadValidators;
using Elsa.Http.UIHints; using Elsa.Http.UIHints;
using Elsa.Resilience.Extensions;
using Elsa.Workflows; using Elsa.Workflows;
using Elsa.Workflows.Management.Extensions;
using FluentStorage; using FluentStorage;
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Elsa.Http.ShellFeatures; namespace Elsa.Http.ShellFeatures;
@ -32,12 +40,12 @@ namespace Elsa.Http.ShellFeatures;
Description = "Provides HTTP-related activities and services for workflow execution", Description = "Provides HTTP-related activities and services for workflow execution",
DependsOn = ["HttpJavaScript", "Resilience"])] DependsOn = ["HttpJavaScript", "Resilience"])]
[UsedImplicitly] [UsedImplicitly]
public class HttpFeature : IShellFeature public class HttpFeature : IMiddlewareShellFeature
{ {
/// <summary> /// <summary>
/// A delegate to configure <see cref="HttpActivityOptions"/>. /// The <see cref="HttpActivityOptions"/> to configure.
/// </summary> /// </summary>
public Action<HttpActivityOptions>? ConfigureHttpOptions { get; set; } public HttpActivityOptions HttpActivityOptions { get; set; } = new();
/// <summary> /// <summary>
/// A delegate to configure <see cref="HttpFileCacheOptions"/>. /// A delegate to configure <see cref="HttpFileCacheOptions"/>.
@ -99,15 +107,34 @@ public class HttpFeature : IShellFeature
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
var configureOptions = ConfigureHttpOptions ?? (options => // Register HTTP activities.
{ services.AddActivitiesFrom<HttpFeature>();
options.BasePath = "/workflows";
options.BaseUrl = new Uri("http://localhost"); // Register HTTP variable types.
}); services.AddVariableDescriptors([
new(typeof(HttpRouteData), "HTTP", null),
new(typeof(HttpRequest), "HTTP", null),
new(typeof(HttpResponse), "HTTP", null),
new(typeof(HttpResponseMessage), "HTTP", null),
new(typeof(HttpHeaders), "HTTP", null),
new(typeof(IFormFile), "HTTP", null),
new(typeof(HttpFile), "HTTP", null),
new(typeof(Downloadable), "HTTP", null),
]);
// Register the HTTP resilience strategy.
services.AddResilienceStrategy<HttpResilienceStrategy>();
var configureFileCacheOptions = ConfigureHttpFileCacheOptions ?? (options => { options.TimeToLive = TimeSpan.FromDays(7); }); var configureFileCacheOptions = ConfigureHttpFileCacheOptions ?? (options => { options.TimeToLive = TimeSpan.FromDays(7); });
services.Configure(configureOptions); services.Configure<HttpActivityOptions>(options =>
{
options.BasePath = HttpActivityOptions.BasePath;
options.BaseUrl = HttpActivityOptions.BaseUrl;
options.AvailableContentTypes = HttpActivityOptions.AvailableContentTypes;
options.WriteHttpResponseSynchronously = HttpActivityOptions.WriteHttpResponseSynchronously;
});
services.Configure(configureFileCacheOptions); services.Configure(configureFileCacheOptions);
var httpClientBuilder = services.AddHttpClient<SendHttpRequestBase>(HttpClient); var httpClientBuilder = services.AddHttpClient<SendHttpRequestBase>(HttpClient);
@ -207,5 +234,10 @@ public class HttpFeature : IShellFeature
options.AddTypeAlias<Downloadable[]>("Downloadable[]"); options.AddTypeAlias<Downloadable[]>("Downloadable[]");
}); });
} }
}
/// <inheritdoc />
public void UseMiddleware(IApplicationBuilder app, IHostEnvironment? environment)
{
app.UseWorkflows();
}
}

View file

@ -1,4 +1,5 @@
using AspNetCore.Authentication.ApiKey; using AspNetCore.Authentication.ApiKey;
using CShells.Configuration;
using CShells.FastEndpoints.Features; using CShells.FastEndpoints.Features;
using CShells.Features; using CShells.Features;
using Elsa.Common.Multitenancy; using Elsa.Common.Multitenancy;
@ -10,6 +11,7 @@ using Elsa.Identity.Options;
using Elsa.Identity.Providers; using Elsa.Identity.Providers;
using Elsa.Identity.Services; using Elsa.Identity.Services;
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Identity.ShellFeatures; namespace Elsa.Identity.ShellFeatures;
@ -26,8 +28,7 @@ public class IdentityFeature : IFastEndpointsShellFeature
{ {
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
// Configure options - Note: SigningKey must be configured by the application for security services.AddOptions<IdentityTokenOptions>().Configure<ShellConfiguration>((options, config) => config.GetSection("Identity").Bind(options));
services.AddOptions<IdentityTokenOptions>().BindConfiguration("Identity");
services.Configure<ApiKeyOptions>(ApiKeyDefaults.AuthenticationScheme, options => services.Configure<ApiKeyOptions>(ApiKeyDefaults.AuthenticationScheme, options =>
{ {
options.Realm = "Elsa Workflows"; options.Realm = "Elsa Workflows";

View file

@ -11,7 +11,7 @@ using Microsoft.Extensions.Options;
namespace Elsa.Persistence.EFCore; namespace Elsa.Persistence.EFCore;
public abstract class PersistenceShellFeatureBase<TDbContext> : IShellFeature public abstract class PersistenceShellFeatureBase<TDbContext> : IShellFeature
where TDbContext : ElsaDbContextBase where TDbContext : DbContext
{ {
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to use context pooling. /// Gets or sets a value indicating whether to use context pooling.

View file

@ -0,0 +1,29 @@
using Elsa.Resilience.Options;
using Microsoft.Extensions.DependencyInjection;
// ReSharper disable once CheckNamespace
namespace Elsa.Resilience.Extensions;
/// <summary>
/// <see cref="IServiceCollection"/> extension methods for registering resilience strategy types
/// via <see cref="ResilienceOptions"/>.
/// </summary>
public static class ResilienceServiceCollectionExtensions
{
/// <summary>
/// Registers a resilience strategy type with <see cref="ResilienceOptions"/>.
/// </summary>
public static IServiceCollection AddResilienceStrategy<T>(this IServiceCollection services) =>
services.Configure<ResilienceOptions>(options => options.StrategyTypes.Add(typeof(T)));
/// <summary>
/// Registers multiple resilience strategy types with <see cref="ResilienceOptions"/>.
/// </summary>
public static IServiceCollection AddResilienceStrategies(this IServiceCollection services, IEnumerable<Type> types) =>
services.Configure<ResilienceOptions>(options =>
{
foreach (var type in types)
options.StrategyTypes.Add(type);
});
}

View file

@ -14,7 +14,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Resilience.ShellFeatures; namespace Elsa.Resilience.ShellFeatures;
[ShellFeature] [ShellFeature]
public class ResilienceShellFeature : IFastEndpointsShellFeature public class ResilienceFeature : IFastEndpointsShellFeature
{ {
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
@ -37,7 +37,7 @@ public class ResilienceShellFeature : IFastEndpointsShellFeature
.AddScoped<IRetryAttemptRecorder, ActivityExecutionContextRetryAttemptRecorder>() .AddScoped<IRetryAttemptRecorder, ActivityExecutionContextRetryAttemptRecorder>()
.AddScoped<IRetryAttemptReader, ActivityExecutionContextRetryAttemptReader>() .AddScoped<IRetryAttemptReader, ActivityExecutionContextRetryAttemptReader>()
.AddScoped<ActivityExecutionContextRetryAttemptReader>() .AddScoped<ActivityExecutionContextRetryAttemptReader>()
.AddHandlersFrom<ResilienceShellFeature>(); .AddHandlersFrom<ResilienceFeature>();
// Register transient exception detection infrastructure // Register transient exception detection infrastructure
services services

View file

@ -1,12 +1,13 @@
using CShells.Features; using CShells.Features;
using Elsa.Common.Multitenancy; using Elsa.Common.Multitenancy;
using Elsa.Common.RecurringTasks; using Elsa.Common.ShellFeatures;
using Elsa.Extensions; using Elsa.Extensions;
using Elsa.Scheduling.Bookmarks; using Elsa.Scheduling.Bookmarks;
using Elsa.Scheduling.Handlers; using Elsa.Scheduling.Handlers;
using Elsa.Scheduling.HostedServices; using Elsa.Scheduling.HostedServices;
using Elsa.Scheduling.Services; using Elsa.Scheduling.Services;
using Elsa.Scheduling.TriggerPayloadValidators; using Elsa.Scheduling.TriggerPayloadValidators;
using Elsa.Workflows.Management.Extensions;
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -18,7 +19,7 @@ namespace Elsa.Scheduling.ShellFeatures;
[ShellFeature( [ShellFeature(
DisplayName = "Scheduling", DisplayName = "Scheduling",
Description = "Provides scheduling capabilities for workflows including cron and delay-based triggers", Description = "Provides scheduling capabilities for workflows including cron and delay-based triggers",
DependsOn = ["SystemClock"])] DependsOn = [typeof(SystemClockFeature)])]
[UsedImplicitly] [UsedImplicitly]
public class SchedulingFeature : IShellFeature public class SchedulingFeature : IShellFeature
{ {
@ -47,9 +48,8 @@ public class SchedulingFeature : IShellFeature
.AddScoped(WorkflowScheduler) .AddScoped(WorkflowScheduler)
.AddBackgroundTask<CreateSchedulesBackgroundTask>() .AddBackgroundTask<CreateSchedulesBackgroundTask>()
.AddHandlersFrom<ScheduleWorkflows>() .AddHandlersFrom<ScheduleWorkflows>()
.AddTriggerPayloadValidator<CronTriggerPayloadValidator, CronTriggerPayload>()
//Trigger payload validators. .AddActivitiesFrom<SchedulingFeature>();
.AddTriggerPayloadValidator<CronTriggerPayloadValidator, CronTriggerPayload>();
} }
} }

View file

@ -120,6 +120,7 @@ public class WorkflowsFeature : IShellFeature
.AddScoped<IActivityResolver, SwitchActivityResolver>() .AddScoped<IActivityResolver, SwitchActivityResolver>()
.AddSerializationOptionsConfigurator<AdditionalConvertersConfigurator>() .AddSerializationOptionsConfigurator<AdditionalConvertersConfigurator>()
.AddSerializationOptionsConfigurator<CustomConstructorConfigurator>() .AddSerializationOptionsConfigurator<CustomConstructorConfigurator>()
.AddSerializationOptionsConfigurator<FlowchartSerializationOptionConfigurator>()
// Domain event handlers. // Domain event handlers.
.AddHandlersFrom<WorkflowsFeature>() .AddHandlersFrom<WorkflowsFeature>()

View file

@ -0,0 +1,105 @@
using System.Reflection;
using Elsa.Expressions.Extensions;
using Elsa.Workflows.Management.Models;
using Elsa.Workflows.Management.Options;
using Microsoft.Extensions.DependencyInjection;
// ReSharper disable once CheckNamespace
namespace Elsa.Workflows.Management.Extensions;
/// <summary>
/// <see cref="IServiceCollection"/> extension methods for registering Elsa activity types
/// and variable descriptors via <see cref="ManagementOptions"/>.
/// </summary>
/// <remarks>
/// These extensions are the shell-feature-compatible replacement for calling
/// <c>WorkflowManagementFeature.AddActivitiesFrom&lt;T&gt;()</c> in the old-style feature system.
/// Because <c>services.Configure&lt;ManagementOptions&gt;</c> is additive, multiple features
/// can independently register activities without any coupling to each other.
/// </remarks>
public static class ManagementServiceCollectionExtensions
{
// -------------------------------------------------------------------------
// Activities
// -------------------------------------------------------------------------
/// <summary>
/// Registers the supplied activity <paramref name="types"/> with <see cref="ManagementOptions"/>.
/// </summary>
public static IServiceCollection AddActivities(this IServiceCollection services, IEnumerable<Type> types) =>
services.Configure<ManagementOptions>(options =>
{
foreach (var type in types)
options.ActivityTypes.Add(type);
});
/// <summary>
/// Registers a single activity type <typeparamref name="TActivity"/> with <see cref="ManagementOptions"/>.
/// </summary>
public static IServiceCollection AddActivity<TActivity>(this IServiceCollection services)
where TActivity : IActivity =>
services.AddActivities([typeof(TActivity)]);
/// <summary>
/// Scans <paramref name="assembly"/> and registers every concrete, non-generic
/// <see cref="IActivity"/> implementation found.
/// </summary>
public static IServiceCollection AddActivitiesFrom(this IServiceCollection services, Assembly assembly)
{
var types = assembly.GetExportedTypes()
.Where(t => typeof(IActivity).IsAssignableFrom(t)
&& t is { IsAbstract: false, IsInterface: false, IsGenericTypeDefinition: false });
return services.AddActivities(types);
}
/// <summary>
/// Scans the assembly that contains <typeparamref name="TMarker"/> and registers every
/// concrete, non-generic <see cref="IActivity"/> implementation found.
/// </summary>
public static IServiceCollection AddActivitiesFrom<TMarker>(this IServiceCollection services) =>
services.AddActivitiesFrom(typeof(TMarker).Assembly);
// -------------------------------------------------------------------------
// Variable descriptors
// -------------------------------------------------------------------------
/// <summary>
/// Registers the supplied <paramref name="descriptors"/> with <see cref="ManagementOptions"/>.
/// </summary>
public static IServiceCollection AddVariableDescriptors(
this IServiceCollection services,
IEnumerable<VariableDescriptor> descriptors) =>
services.Configure<ManagementOptions>(options =>
{
foreach (var descriptor in descriptors)
options.VariableDescriptors.Add(descriptor);
});
/// <summary>
/// Registers a single variable descriptor.
/// </summary>
public static IServiceCollection AddVariableDescriptor(
this IServiceCollection services,
VariableDescriptor descriptor) =>
services.AddVariableDescriptors([descriptor]);
/// <summary>
/// Registers a variable descriptor for <typeparamref name="T"/> with the given
/// <paramref name="category"/> and optional <paramref name="description"/>.
/// </summary>
public static IServiceCollection AddVariableDescriptor<T>(
this IServiceCollection services,
string category,
string? description = null) =>
services.AddVariableDescriptor(new(typeof(T), category, description));
/// <summary>
/// Adds a variable type and its alias to the specified service collection.
/// </summary>
public static IServiceCollection AddVariableTypeAndAlias<T>(this IServiceCollection services, string alias, string category)
{
return services
.AddVariableDescriptor<T>(category)
.AddTypeAlias<T>(alias);
}
}

View file

@ -87,10 +87,11 @@ public class CachingWorkflowDefinitionService(
/// <inheritdoc /> /// <inheritdoc />
public async Task<WorkflowGraph?> FindWorkflowGraphAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) public async Task<WorkflowGraph?> FindWorkflowGraphAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default)
{ {
var cacheKey = cacheManager.CreateWorkflowFilterCacheKey(filter); // Resolve the definition first (already cached by FindWorkflowDefinitionAsync) so the
return await FindFromCacheAsync(cacheKey, // WorkflowGraph ends up under the stable per-version-ID key rather than a filter-hash key
() => decoratedService.FindWorkflowGraphAsync(filter, cancellationToken), // that can never be shared with the other FindWorkflowGraphAsync overloads.
x => x.Workflow.Identity.DefinitionId); var definition = await FindWorkflowDefinitionAsync(filter, cancellationToken);
return await FindWorkflowGraphForDefinitionAsync(definition, cancellationToken);
} }
/// <inheritdoc /> /// <inheritdoc />
@ -100,11 +101,8 @@ public class CachingWorkflowDefinitionService(
var workflowGraphs = new List<WorkflowGraph>(); var workflowGraphs = new List<WorkflowGraph>();
foreach (var workflowDefinition in workflowDefinitions) foreach (var workflowDefinition in workflowDefinitions)
{ {
var cacheKey = cacheManager.CreateWorkflowVersionCacheKey(workflowDefinition.Id); var workflowGraph = await FindWorkflowGraphForDefinitionAsync(workflowDefinition, cancellationToken);
var workflowGraph = await GetFromCacheAsync( if (workflowGraph != null)
cacheKey,
async () => await MaterializeWorkflowAsync(workflowDefinition, cancellationToken),
wf => wf.Workflow.Identity.DefinitionId);
workflowGraphs.Add(workflowGraph); workflowGraphs.Add(workflowGraph);
} }
@ -114,23 +112,17 @@ public class CachingWorkflowDefinitionService(
/// <inheritdoc /> /// <inheritdoc />
public async Task<WorkflowGraphFindResult> TryFindWorkflowGraphAsync(string definitionId, VersionOptions versionOptions, CancellationToken cancellationToken = default) public async Task<WorkflowGraphFindResult> TryFindWorkflowGraphAsync(string definitionId, VersionOptions versionOptions, CancellationToken cancellationToken = default)
{ {
var cacheKey = cacheManager.CreateWorkflowVersionCacheKey(definitionId, versionOptions); var definition = await FindWorkflowDefinitionAsync(definitionId, versionOptions, cancellationToken);
var result = await GetFromCacheAsync(cacheKey, var workflowGraph = await FindWorkflowGraphForDefinitionAsync(definition, cancellationToken);
() => decoratedService.TryFindWorkflowGraphAsync(definitionId, versionOptions, cancellationToken), return new(definition, workflowGraph);
x => x.WorkflowDefinition?.DefinitionId);
return result;
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<WorkflowGraphFindResult> TryFindWorkflowGraphAsync(string definitionVersionId, CancellationToken cancellationToken = default) public async Task<WorkflowGraphFindResult> TryFindWorkflowGraphAsync(string definitionVersionId, CancellationToken cancellationToken = default)
{ {
var cacheKey = cacheManager.CreateWorkflowVersionCacheKey(definitionVersionId); var definition = await FindWorkflowDefinitionAsync(definitionVersionId, cancellationToken);
var result = await GetFromCacheAsync(cacheKey, var workflowGraph = await FindWorkflowGraphForDefinitionAsync(definition, cancellationToken);
() => decoratedService.TryFindWorkflowGraphAsync(definitionVersionId, cancellationToken), return new(definition, workflowGraph);
x => x.WorkflowDefinition?.DefinitionId);
return result;
} }
/// <inheritdoc /> /// <inheritdoc />
@ -143,12 +135,9 @@ public class CachingWorkflowDefinitionService(
/// <inheritdoc /> /// <inheritdoc />
public async Task<WorkflowGraphFindResult> TryFindWorkflowGraphAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) public async Task<WorkflowGraphFindResult> TryFindWorkflowGraphAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default)
{ {
var cacheKey = cacheManager.CreateWorkflowFilterCacheKey(filter); var definition = await FindWorkflowDefinitionAsync(filter, cancellationToken);
var result = await GetFromCacheAsync(cacheKey, var workflowGraph = await FindWorkflowGraphForDefinitionAsync(definition, cancellationToken);
() => decoratedService.TryFindWorkflowGraphAsync(filter, cancellationToken), return new(definition, workflowGraph);
x => x.WorkflowDefinition?.DefinitionId);
return result;
} }
/// <inheritdoc /> /// <inheritdoc />
@ -158,50 +147,54 @@ public class CachingWorkflowDefinitionService(
var results = new List<WorkflowGraphFindResult>(); var results = new List<WorkflowGraphFindResult>();
foreach (var workflowDefinition in workflowDefinitions) foreach (var workflowDefinition in workflowDefinitions)
{ {
if (!materializerRegistry.IsMaterializerAvailable(workflowDefinition.MaterializerName)) var workflowGraph = await FindWorkflowGraphForDefinitionAsync(workflowDefinition, cancellationToken);
{ results.Add(new WorkflowGraphFindResult(workflowDefinition, workflowGraph));
var unavailableResult = new WorkflowGraphFindResult(workflowDefinition, null);
results.Add(unavailableResult);
continue;
}
var cacheKey = cacheManager.CreateWorkflowVersionCacheKey(workflowDefinition.Id);
var workflowGraph = await FindFromCacheAsync(
cacheKey,
async () => await MaterializeWorkflowAsync(workflowDefinition, cancellationToken),
wf => wf.Workflow.Identity.DefinitionId);
var result = new WorkflowGraphFindResult(workflowDefinition, workflowGraph);
results.Add(result);
} }
return results; return results;
} }
private async Task<T?> FindFromCacheAsync<T>(string cacheKey, Func<Task<T?>> getObjectFunc, Func<T, string> getChangeTokenKeyFunc) where T : class private async Task<T?> FindFromCacheAsync<T>(string cacheKey, Func<Task<T?>> getObjectFunc, Func<T, string> getChangeTokenKeyFunc) where T : class
{
return await GetFromCacheAsync(
cacheKey,
getObjectFunc,
obj => obj != null ? getChangeTokenKeyFunc(obj) : null);
}
private async Task<T> GetFromCacheAsync<T>(string cacheKey, Func<Task<T>> getObjectFunc, Func<T, string?> getChangeTokenKeyFunc)
{ {
var cache = cacheManager.Cache; var cache = cacheManager.Cache;
return await cache.GetOrCreateAsync(cacheKey, async entry => return await cache.FindOrCreateAsync(cacheKey, async entry =>
{ {
entry.SetAbsoluteExpiration(cache.CachingOptions.Value.CacheDuration); entry.SetAbsoluteExpiration(cache.CachingOptions.Value.CacheDuration);
var obj = await getObjectFunc(); var obj = await getObjectFunc();
var changeTokenKeyInput = getChangeTokenKeyFunc(obj);
if (changeTokenKeyInput != null) if (obj != null)
{ {
var changeTokenKey = cacheManager.CreateWorkflowDefinitionChangeTokenKey(changeTokenKeyInput); var changeTokenKey = cacheManager.CreateWorkflowDefinitionChangeTokenKey(getChangeTokenKeyFunc(obj));
entry.AddExpirationToken(cache.GetToken(changeTokenKey)); entry.AddExpirationToken(cache.GetToken(changeTokenKey));
} }
return obj; return obj;
}); });
} }
/// <summary>
/// Returns the <see cref="WorkflowGraph"/> for the given definition, using the per-version-ID cache entry
/// that is shared with <see cref="FindWorkflowGraphAsync(string,CancellationToken)"/>.
/// Returns <c>null</c> when the definition is <c>null</c> or its materializer is unavailable.
/// </summary>
private async Task<WorkflowGraph?> FindWorkflowGraphForDefinitionAsync(WorkflowDefinition? definition, CancellationToken cancellationToken)
{
if (definition == null)
return null;
if (!materializerRegistry.IsMaterializerAvailable(definition.MaterializerName))
return null;
var cacheKey = cacheManager.CreateWorkflowVersionCacheKey(definition.Id);
var cache = cacheManager.Cache;
return await cache.GetOrCreateAsync(cacheKey, async entry =>
{
entry.SetAbsoluteExpiration(cache.CachingOptions.Value.CacheDuration);
var graph = await MaterializeWorkflowAsync(definition, cancellationToken);
var changeTokenKey = cacheManager.CreateWorkflowDefinitionChangeTokenKey(graph.Workflow.Identity.DefinitionId);
entry.AddExpirationToken(cache.GetToken(changeTokenKey));
return graph;
});
}
} }

View file

@ -11,6 +11,7 @@ using Elsa.Workflows.Features;
using Elsa.Workflows.Management.Activities.WorkflowDefinitionActivity; using Elsa.Workflows.Management.Activities.WorkflowDefinitionActivity;
using Elsa.Workflows.Management.Contracts; using Elsa.Workflows.Management.Contracts;
using Elsa.Workflows.Management.Entities; using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Extensions;
using Elsa.Workflows.Management.Features; using Elsa.Workflows.Management.Features;
using Elsa.Workflows.Management.Handlers.Notifications; using Elsa.Workflows.Management.Handlers.Notifications;
using Elsa.Workflows.Management.Mappers; using Elsa.Workflows.Management.Mappers;
@ -94,6 +95,7 @@ public class WorkflowManagementFeature : IShellFeature
.AddScoped<IWorkflowSerializer, WorkflowSerializer>() .AddScoped<IWorkflowSerializer, WorkflowSerializer>()
.AddScoped<IWorkflowValidator, WorkflowValidator>() .AddScoped<IWorkflowValidator, WorkflowValidator>()
.AddScoped<IWorkflowReferenceQuery, DefaultWorkflowReferenceQuery>() .AddScoped<IWorkflowReferenceQuery, DefaultWorkflowReferenceQuery>()
.AddScoped<IWorkflowReferenceGraphBuilder, WorkflowReferenceGraphBuilder>()
.AddScoped<IWorkflowDefinitionPublisher, WorkflowDefinitionPublisher>() .AddScoped<IWorkflowDefinitionPublisher, WorkflowDefinitionPublisher>()
.AddScoped<IWorkflowDefinitionImporter, WorkflowDefinitionImporter>() .AddScoped<IWorkflowDefinitionImporter, WorkflowDefinitionImporter>()
.AddScoped<IWorkflowDefinitionManager, WorkflowDefinitionManager>() .AddScoped<IWorkflowDefinitionManager, WorkflowDefinitionManager>()
@ -118,42 +120,14 @@ public class WorkflowManagementFeature : IShellFeature
.AddNotificationHandler<DeleteWorkflowInstances>() .AddNotificationHandler<DeleteWorkflowInstances>()
.AddNotificationHandler<RefreshActivityRegistry>() .AddNotificationHandler<RefreshActivityRegistry>()
.AddNotificationHandler<UpdateConsumingWorkflows>() .AddNotificationHandler<UpdateConsumingWorkflows>()
.AddNotificationHandler<ValidateWorkflow>() .AddNotificationHandler<ValidateWorkflow>();
;
AddActivitiesFrom<WorkflowsFeature>(); // Register built-in activities from the Workflows and WorkflowManagement assemblies.
AddActivitiesFrom<WorkflowManagementFeature>(); services
.AddActivitiesFrom<WorkflowsFeature>()
.AddActivitiesFrom<WorkflowManagementFeature>();
// Register the default variable descriptors declared on this feature.
services.Configure<ManagementOptions>(options => services.AddVariableDescriptors(VariableDescriptors);
{
foreach (var activityType in ActivityTypes.Distinct())
options.ActivityTypes.Add(activityType);
foreach (var descriptor in VariableDescriptors.DistinctBy(x => x.Type))
options.VariableDescriptors.Add(descriptor);
});
}
/// <summary>
/// A set of activity types to make available to the system.
/// </summary>
private HashSet<Type> ActivityTypes { get; } = [];
/// <summary>
/// Adds the specified activity types to the system.
/// </summary>
private WorkflowManagementFeature AddActivities(IEnumerable<Type> activityTypes)
{
ActivityTypes.AddRange(activityTypes);
return this;
}
private WorkflowManagementFeature AddActivitiesFrom<TMarker>()
{
var activityTypes = typeof(TMarker).Assembly.GetExportedTypes()
.Where(x => typeof(IActivity).IsAssignableFrom(x) && x is { IsAbstract: false, IsInterface: false, IsGenericType: false })
.ToList();
return AddActivities(activityTypes);
} }
} }

View file

@ -15,6 +15,6 @@ public class ElsaFeature : IShellFeature
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
// Register the shell-based feature provider to bridge shell features to the Elsa feature API // Register the shell-based feature provider to bridge shell features to the Elsa feature API
services.AddSingleton<Elsa.Features.Contracts.IInstalledFeatureProvider, Elsa.Features.Services.ShellInstalledFeatureProvider>(); services.AddSingleton<Features.Contracts.IInstalledFeatureProvider, Features.Services.ShellInstalledFeatureProvider>();
} }
} }

View file

@ -70,8 +70,10 @@ public class AutoUpdateTests : AppComponentTest
{ {
Id = ParentDefinitionVersionId Id = ParentDefinitionVersionId
}; };
var parentVersionCacheKey = _definitionCacheManager.CreateWorkflowFilterCacheKey(parentWorkflowDefinitionFilter); var parentDefinitionCacheKey = _definitionCacheManager.CreateWorkflowDefinitionFilterCacheKey(parentWorkflowDefinitionFilter);
Assert.True(_cache.TryGetValue(parentVersionCacheKey, out _)); var parentGraphCacheKey = _definitionCacheManager.CreateWorkflowVersionCacheKey(ParentDefinitionVersionId);
Assert.True(_cache.TryGetValue(parentDefinitionCacheKey, out _));
Assert.True(_cache.TryGetValue(parentGraphCacheKey, out _));
// Set change tokens. // Set change tokens.
_httpChangeToken = _workflowCacheManager.CreateWorkflowDefinitionChangeTokenKey(ParentDefinitionId); _httpChangeToken = _workflowCacheManager.CreateWorkflowDefinitionChangeTokenKey(ParentDefinitionId);
@ -88,7 +90,8 @@ public class AutoUpdateTests : AppComponentTest
Assert.False(_cache.TryGetValue($"http-workflow:{hash}", out _)); Assert.False(_cache.TryGetValue($"http-workflow:{hash}", out _));
Assert.False(_cache.TryGetValue($"IEnumerable`1:{hashedFilter}", out _)); Assert.False(_cache.TryGetValue($"IEnumerable`1:{hashedFilter}", out _));
Assert.False(_cache.TryGetValue(parentVersionCacheKey, out _)); Assert.False(_cache.TryGetValue(parentDefinitionCacheKey, out _));
Assert.False(_cache.TryGetValue(parentGraphCacheKey, out _));
} }
private void OnChangeTokenSignalTriggered(object? sender, TriggerChangeTokenSignalEventArgs args) private void OnChangeTokenSignalTriggered(object? sender, TriggerChangeTokenSignalEventArgs args)

View file

@ -15,6 +15,10 @@ namespace Elsa.Workflows.Management.UnitTests.Services;
public class CachingWorkflowDefinitionServiceTests public class CachingWorkflowDefinitionServiceTests
{ {
private const string DefaultDefinitionId = "def-1";
private const string DefaultVersionId = "version-id-1";
private const string DefaultMaterializerName = "materializer";
private readonly IWorkflowDefinitionService _decoratedService = Substitute.For<IWorkflowDefinitionService>(); private readonly IWorkflowDefinitionService _decoratedService = Substitute.For<IWorkflowDefinitionService>();
private readonly IWorkflowDefinitionCacheManager _cacheManager = Substitute.For<IWorkflowDefinitionCacheManager>(); private readonly IWorkflowDefinitionCacheManager _cacheManager = Substitute.For<IWorkflowDefinitionCacheManager>();
private readonly IWorkflowDefinitionStore _workflowDefinitionStore = Substitute.For<IWorkflowDefinitionStore>(); private readonly IWorkflowDefinitionStore _workflowDefinitionStore = Substitute.For<IWorkflowDefinitionStore>();
@ -30,11 +34,9 @@ public class CachingWorkflowDefinitionServiceTests
public async Task MaterializeWorkflowAsync_DelegatesToDecoratedService() public async Task MaterializeWorkflowAsync_DelegatesToDecoratedService()
{ {
// Arrange // Arrange
var definition = TestHelpers.CreateWorkflowDefinition("def-1", "materializer"); var definition = TestHelpers.CreateWorkflowDefinition(DefaultDefinitionId, DefaultMaterializerName);
var (_, workflowGraph) = CreateWorkflowAndGraph("def-1"); var (_, workflowGraph) = CreateWorkflowAndGraph(DefaultDefinitionId);
_decoratedService.MaterializeWorkflowAsync(definition, Arg.Any<CancellationToken>()).Returns(workflowGraph); _decoratedService.MaterializeWorkflowAsync(definition, Arg.Any<CancellationToken>()).Returns(workflowGraph);
var service = CreateService(); var service = CreateService();
// Act // Act
@ -49,42 +51,35 @@ public class CachingWorkflowDefinitionServiceTests
public async Task FindWorkflowDefinitionAsync_ByDefinitionIdAndVersionOptions_CreatesCacheKey() public async Task FindWorkflowDefinitionAsync_ByDefinitionIdAndVersionOptions_CreatesCacheKey()
{ {
// Arrange // Arrange
var definition = TestHelpers.CreateWorkflowDefinition("def-1", "materializer"); var definition = TestHelpers.CreateWorkflowDefinition(DefaultDefinitionId, DefaultMaterializerName);
var cacheKey = "cache-key-1"; _cacheManager.CreateWorkflowDefinitionVersionCacheKey(DefaultDefinitionId, VersionOptions.Published).Returns("cache-key");
_decoratedService.FindWorkflowDefinitionAsync(DefaultDefinitionId, VersionOptions.Published, Arg.Any<CancellationToken>()).Returns(definition);
_cacheManager.CreateWorkflowDefinitionVersionCacheKey("def-1", VersionOptions.Published).Returns(cacheKey);
_decoratedService.FindWorkflowDefinitionAsync("def-1", VersionOptions.Published, Arg.Any<CancellationToken>())
.Returns(definition);
var service = CreateService(); var service = CreateService();
// Act // Act
var result = await service.FindWorkflowDefinitionAsync("def-1", VersionOptions.Published); var result = await service.FindWorkflowDefinitionAsync(DefaultDefinitionId, VersionOptions.Published);
// Assert // Assert
Assert.Same(definition, result); Assert.Same(definition, result);
_cacheManager.Received(1).CreateWorkflowDefinitionVersionCacheKey("def-1", VersionOptions.Published); _cacheManager.Received(1).CreateWorkflowDefinitionVersionCacheKey(DefaultDefinitionId, VersionOptions.Published);
} }
[Fact] [Fact]
public async Task FindWorkflowDefinitionAsync_ByDefinitionVersionId_CreatesCacheKey() public async Task FindWorkflowDefinitionAsync_ByDefinitionVersionId_CreatesCacheKey()
{ {
// Arrange // Arrange
var definition = TestHelpers.CreateWorkflowDefinition("def-1", "materializer"); var definition = TestHelpers.CreateWorkflowDefinition(DefaultDefinitionId, DefaultMaterializerName);
definition.Id = "version-id-1"; definition.Id = DefaultVersionId;
var cacheKey = "cache-key-version-1"; _cacheManager.CreateWorkflowDefinitionVersionCacheKey(DefaultVersionId).Returns("cache-key");
_decoratedService.FindWorkflowDefinitionAsync(DefaultVersionId, Arg.Any<CancellationToken>()).Returns(definition);
_cacheManager.CreateWorkflowDefinitionVersionCacheKey("version-id-1").Returns(cacheKey);
_decoratedService.FindWorkflowDefinitionAsync("version-id-1", Arg.Any<CancellationToken>()).Returns(definition);
var service = CreateService(); var service = CreateService();
// Act // Act
var result = await service.FindWorkflowDefinitionAsync("version-id-1"); var result = await service.FindWorkflowDefinitionAsync(DefaultVersionId);
// Assert // Assert
Assert.Same(definition, result); Assert.Same(definition, result);
_cacheManager.Received(1).CreateWorkflowDefinitionVersionCacheKey("version-id-1"); _cacheManager.Received(1).CreateWorkflowDefinitionVersionCacheKey(DefaultVersionId);
} }
[Fact] [Fact]
@ -172,17 +167,18 @@ public class CachingWorkflowDefinitionServiceTests
} }
[Fact] [Fact]
public async Task FindWorkflowGraphAsync_ByHandle_ConvertsTFilterAndDelegates() public async Task FindWorkflowGraphAsync_ByHandle_ConvertsTFilterAndResolvesDefinitionAndGraph()
{ {
// Arrange // Arrange
var (_, workflowGraph) = CreateWorkflowAndGraph("def-1"); var definition = TestHelpers.CreateWorkflowDefinition(DefaultDefinitionId, DefaultMaterializerName);
var handle = WorkflowDefinitionHandle.ByDefinitionId("def-1", VersionOptions.Latest); definition.Id = DefaultVersionId;
var cacheKey = "cache-key-filter"; var (_, workflowGraph) = CreateWorkflowAndGraph(DefaultDefinitionId);
var handle = WorkflowDefinitionHandle.ByDefinitionId(DefaultDefinitionId, VersionOptions.Latest);
_cacheManager.CreateWorkflowFilterCacheKey(Arg.Any<WorkflowDefinitionFilter>()).Returns(cacheKey);
_decoratedService.FindWorkflowGraphAsync(Arg.Any<WorkflowDefinitionFilter>(), Arg.Any<CancellationToken>())
.Returns(workflowGraph);
_cacheManager.CreateWorkflowDefinitionFilterCacheKey(Arg.Any<WorkflowDefinitionFilter>()).Returns("cache-key-def");
_cacheManager.CreateWorkflowVersionCacheKey(DefaultVersionId).Returns("cache-key-graph");
_decoratedService.FindWorkflowDefinitionAsync(Arg.Any<WorkflowDefinitionFilter>(), Arg.Any<CancellationToken>()).Returns(definition);
SetupDefinitionAndGraphMaterialization(definition, workflowGraph);
var service = CreateService(); var service = CreateService();
// Act // Act
@ -190,20 +186,22 @@ public class CachingWorkflowDefinitionServiceTests
// Assert // Assert
Assert.Same(workflowGraph, result); Assert.Same(workflowGraph, result);
await _decoratedService.Received(1).FindWorkflowGraphAsync(Arg.Any<WorkflowDefinitionFilter>(), Arg.Any<CancellationToken>()); await _decoratedService.Received(1).FindWorkflowDefinitionAsync(Arg.Any<WorkflowDefinitionFilter>(), Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task FindWorkflowGraphAsync_ByFilter_CreatesCacheKey() public async Task FindWorkflowGraphAsync_ByFilter_ResolvesDefinitionFirstAndCachesGraph()
{ {
// Arrange // Arrange
var (_, workflowGraph) = CreateWorkflowAndGraph("def-1"); var definition = TestHelpers.CreateWorkflowDefinition(DefaultDefinitionId, DefaultMaterializerName);
var filter = new WorkflowDefinitionFilter { DefinitionId = "def-1" }; definition.Id = DefaultVersionId;
var cacheKey = "cache-key-filter"; var (_, workflowGraph) = CreateWorkflowAndGraph(DefaultDefinitionId);
var filter = new WorkflowDefinitionFilter { DefinitionId = DefaultDefinitionId };
_cacheManager.CreateWorkflowFilterCacheKey(filter).Returns(cacheKey);
_decoratedService.FindWorkflowGraphAsync(filter, Arg.Any<CancellationToken>()).Returns(workflowGraph);
_cacheManager.CreateWorkflowDefinitionFilterCacheKey(filter).Returns("cache-key-def");
_cacheManager.CreateWorkflowVersionCacheKey(DefaultVersionId).Returns("cache-key-graph");
_decoratedService.FindWorkflowDefinitionAsync(filter, Arg.Any<CancellationToken>()).Returns(definition);
SetupDefinitionAndGraphMaterialization(definition, workflowGraph);
var service = CreateService(); var service = CreateService();
// Act // Act
@ -211,31 +209,50 @@ public class CachingWorkflowDefinitionServiceTests
// Assert // Assert
Assert.Same(workflowGraph, result); Assert.Same(workflowGraph, result);
_cacheManager.Received(1).CreateWorkflowFilterCacheKey(filter); _cacheManager.Received(1).CreateWorkflowDefinitionFilterCacheKey(filter);
_cacheManager.Received(1).CreateWorkflowVersionCacheKey(DefaultVersionId);
}
[Fact]
public async Task FindWorkflowGraphAsync_ByFilter_WithUnavailableMaterializer_ReturnsNull()
{
// Arrange
const string unavailableMaterializer = "unavailable-materializer";
var definition = TestHelpers.CreateWorkflowDefinition(DefaultDefinitionId, unavailableMaterializer);
definition.Id = DefaultVersionId;
var filter = new WorkflowDefinitionFilter { DefinitionId = DefaultDefinitionId };
_cacheManager.CreateWorkflowDefinitionFilterCacheKey(filter).Returns("cache-key");
_decoratedService.FindWorkflowDefinitionAsync(filter, Arg.Any<CancellationToken>()).Returns(definition);
_materializerRegistry.IsMaterializerAvailable(unavailableMaterializer).Returns(false);
var service = CreateService();
// Act
var result = await service.FindWorkflowGraphAsync(filter);
// Assert
Assert.Null(result);
_cacheManager.Received(1).CreateWorkflowDefinitionFilterCacheKey(filter);
await _decoratedService.DidNotReceive().MaterializeWorkflowAsync(Arg.Any<WorkflowDefinition>(), Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task FindWorkflowGraphsAsync_WithMultipleDefinitions_CachesEachGraph() public async Task FindWorkflowGraphsAsync_WithMultipleDefinitions_CachesEachGraph()
{ {
// Arrange // Arrange
var definition1 = TestHelpers.CreateWorkflowDefinition("def-1", "materializer"); var definition1 = TestHelpers.CreateWorkflowDefinition("def-1", DefaultMaterializerName);
definition1.Id = "id-1"; definition1.Id = "id-1";
var definition2 = TestHelpers.CreateWorkflowDefinition("def-2", "materializer"); var definition2 = TestHelpers.CreateWorkflowDefinition("def-2", DefaultMaterializerName);
definition2.Id = "id-2"; definition2.Id = "id-2";
var definitions = new[] { definition1, definition2 };
var (_, graph1) = CreateWorkflowAndGraph("def-1"); var (_, graph1) = CreateWorkflowAndGraph("def-1");
var (_, graph2) = CreateWorkflowAndGraph("def-2"); var (_, graph2) = CreateWorkflowAndGraph("def-2");
var filter = new WorkflowDefinitionFilter(); var filter = new WorkflowDefinitionFilter();
_workflowDefinitionStore.FindManyAsync(filter, Arg.Any<CancellationToken>()).Returns(definitions);
_workflowDefinitionStore.FindManyAsync(filter, Arg.Any<CancellationToken>()).Returns(new[] { definition1, definition2 });
_cacheManager.CreateWorkflowVersionCacheKey("id-1").Returns("cache-key-1"); _cacheManager.CreateWorkflowVersionCacheKey("id-1").Returns("cache-key-1");
_cacheManager.CreateWorkflowVersionCacheKey("id-2").Returns("cache-key-2"); _cacheManager.CreateWorkflowVersionCacheKey("id-2").Returns("cache-key-2");
SetupDefinitionAndGraphMaterialization(definition1, graph1);
_decoratedService.MaterializeWorkflowAsync(definition1, Arg.Any<CancellationToken>()).Returns(graph1); SetupDefinitionAndGraphMaterialization(definition2, graph2);
_decoratedService.MaterializeWorkflowAsync(definition2, Arg.Any<CancellationToken>()).Returns(graph2);
var service = CreateService(); var service = CreateService();
// Act // Act
@ -251,106 +268,146 @@ public class CachingWorkflowDefinitionServiceTests
} }
[Fact] [Fact]
public async Task TryFindWorkflowGraphAsync_ByDefinitionIdAndVersionOptions_CreatesCacheKey() public async Task FindWorkflowGraphsAsync_SkipsDefinitionsWithUnavailableMaterializer()
{ {
// Arrange // Arrange
var findResult = CreateWorkflowGraphFindResult("def-1"); var definition1 = TestHelpers.CreateWorkflowDefinition("def-1", "available-materializer");
var cacheKey = "cache-key-try"; definition1.Id = "id-1";
var definition2 = TestHelpers.CreateWorkflowDefinition("def-2", "unavailable-materializer");
definition2.Id = "id-2";
var definitions = new[] { definition1, definition2 };
_cacheManager.CreateWorkflowVersionCacheKey("def-1", VersionOptions.Published).Returns(cacheKey); var (_, graph1) = CreateWorkflowAndGraph("def-1");
_decoratedService.TryFindWorkflowGraphAsync("def-1", VersionOptions.Published, Arg.Any<CancellationToken>())
.Returns(findResult); var filter = new WorkflowDefinitionFilter();
_workflowDefinitionStore.FindManyAsync(filter, Arg.Any<CancellationToken>()).Returns(definitions);
_cacheManager.CreateWorkflowVersionCacheKey("id-1").Returns("cache-key-1");
_materializerRegistry.IsMaterializerAvailable("available-materializer").Returns(true);
_materializerRegistry.IsMaterializerAvailable("unavailable-materializer").Returns(false);
_decoratedService.MaterializeWorkflowAsync(definition1, Arg.Any<CancellationToken>()).Returns(graph1);
var service = CreateService(); var service = CreateService();
// Act // Act
var result = await service.TryFindWorkflowGraphAsync("def-1", VersionOptions.Published); var result = await service.FindWorkflowGraphsAsync(filter);
// Assert // Assert
Assert.Same(findResult, result); var graphs = result.ToList();
_cacheManager.Received(1).CreateWorkflowVersionCacheKey("def-1", VersionOptions.Published); Assert.Single(graphs);
Assert.Same(graph1, graphs[0]);
await _decoratedService.DidNotReceive().MaterializeWorkflowAsync(definition2, Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task TryFindWorkflowGraphAsync_ByDefinitionVersionId_CreatesCacheKey() public async Task TryFindWorkflowGraphAsync_ByDefinitionIdAndVersionOptions_ResolvesDefinitionAndGraph()
{ {
// Arrange // Arrange
var findResult = CreateWorkflowGraphFindResult("def-1"); var definition = TestHelpers.CreateWorkflowDefinition(DefaultDefinitionId, DefaultMaterializerName);
var cacheKey = "cache-key-try-version"; definition.Id = DefaultVersionId;
var (_, workflowGraph) = CreateWorkflowAndGraph(DefaultDefinitionId);
_cacheManager.CreateWorkflowVersionCacheKey("version-id-1").Returns(cacheKey);
_decoratedService.TryFindWorkflowGraphAsync("version-id-1", Arg.Any<CancellationToken>()).Returns(findResult);
_cacheManager.CreateWorkflowDefinitionVersionCacheKey(DefaultDefinitionId, VersionOptions.Published).Returns("cache-key-def");
_cacheManager.CreateWorkflowVersionCacheKey(DefaultVersionId).Returns("cache-key-graph");
_decoratedService.FindWorkflowDefinitionAsync(DefaultDefinitionId, VersionOptions.Published, Arg.Any<CancellationToken>()).Returns(definition);
SetupDefinitionAndGraphMaterialization(definition, workflowGraph);
var service = CreateService(); var service = CreateService();
// Act // Act
var result = await service.TryFindWorkflowGraphAsync("version-id-1"); var result = await service.TryFindWorkflowGraphAsync(DefaultDefinitionId, VersionOptions.Published);
// Assert // Assert
Assert.Same(findResult, result); Assert.Same(definition, result.WorkflowDefinition);
_cacheManager.Received(1).CreateWorkflowVersionCacheKey("version-id-1"); Assert.Same(workflowGraph, result.WorkflowGraph);
_cacheManager.Received(1).CreateWorkflowDefinitionVersionCacheKey(DefaultDefinitionId, VersionOptions.Published);
_cacheManager.Received(1).CreateWorkflowVersionCacheKey(DefaultVersionId);
} }
[Fact] [Fact]
public async Task TryFindWorkflowGraphAsync_ByHandle_ConvertsTFilterAndDelegates() public async Task TryFindWorkflowGraphAsync_ByDefinitionVersionId_ResolvesDefinitionAndGraph()
{ {
// Arrange // Arrange
var findResult = CreateWorkflowGraphFindResult("def-1"); var definition = TestHelpers.CreateWorkflowDefinition(DefaultDefinitionId, DefaultMaterializerName);
var handle = WorkflowDefinitionHandle.ByDefinitionId("def-1", VersionOptions.Latest); definition.Id = DefaultVersionId;
var cacheKey = "cache-key-filter"; var (_, workflowGraph) = CreateWorkflowAndGraph(DefaultDefinitionId);
_cacheManager.CreateWorkflowFilterCacheKey(Arg.Any<WorkflowDefinitionFilter>()).Returns(cacheKey); _cacheManager.CreateWorkflowDefinitionVersionCacheKey(DefaultVersionId).Returns("cache-key-def");
_decoratedService.TryFindWorkflowGraphAsync(Arg.Any<WorkflowDefinitionFilter>(), Arg.Any<CancellationToken>()) _cacheManager.CreateWorkflowVersionCacheKey(DefaultVersionId).Returns("cache-key-graph");
.Returns(findResult); _decoratedService.FindWorkflowDefinitionAsync(DefaultVersionId, Arg.Any<CancellationToken>()).Returns(definition);
SetupDefinitionAndGraphMaterialization(definition, workflowGraph);
var service = CreateService();
// Act
var result = await service.TryFindWorkflowGraphAsync(DefaultVersionId);
// Assert
Assert.Same(definition, result.WorkflowDefinition);
Assert.Same(workflowGraph, result.WorkflowGraph);
_cacheManager.Received(1).CreateWorkflowDefinitionVersionCacheKey(DefaultVersionId);
_cacheManager.Received(1).CreateWorkflowVersionCacheKey(DefaultVersionId);
}
[Fact]
public async Task TryFindWorkflowGraphAsync_ByHandle_ConvertsTFilterAndResolvesDefinitionAndGraph()
{
// Arrange
var definition = TestHelpers.CreateWorkflowDefinition(DefaultDefinitionId, DefaultMaterializerName);
definition.Id = DefaultVersionId;
var (_, workflowGraph) = CreateWorkflowAndGraph(DefaultDefinitionId);
var handle = WorkflowDefinitionHandle.ByDefinitionId(DefaultDefinitionId, VersionOptions.Latest);
_cacheManager.CreateWorkflowDefinitionFilterCacheKey(Arg.Any<WorkflowDefinitionFilter>()).Returns("cache-key-def");
_cacheManager.CreateWorkflowVersionCacheKey(DefaultVersionId).Returns("cache-key-graph");
_decoratedService.FindWorkflowDefinitionAsync(Arg.Any<WorkflowDefinitionFilter>(), Arg.Any<CancellationToken>()).Returns(definition);
SetupDefinitionAndGraphMaterialization(definition, workflowGraph);
var service = CreateService(); var service = CreateService();
// Act // Act
var result = await service.TryFindWorkflowGraphAsync(handle); var result = await service.TryFindWorkflowGraphAsync(handle);
// Assert // Assert
Assert.Same(findResult, result); Assert.Same(definition, result.WorkflowDefinition);
await _decoratedService.Received(1).TryFindWorkflowGraphAsync(Arg.Any<WorkflowDefinitionFilter>(), Arg.Any<CancellationToken>()); Assert.Same(workflowGraph, result.WorkflowGraph);
await _decoratedService.Received(1).FindWorkflowDefinitionAsync(Arg.Any<WorkflowDefinitionFilter>(), Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task TryFindWorkflowGraphAsync_ByFilter_CreatesCacheKey() public async Task TryFindWorkflowGraphAsync_ByFilter_ResolvesDefinitionAndGraph()
{ {
// Arrange // Arrange
var findResult = CreateWorkflowGraphFindResult("def-1"); var definition = TestHelpers.CreateWorkflowDefinition(DefaultDefinitionId, DefaultMaterializerName);
var filter = new WorkflowDefinitionFilter { DefinitionId = "def-1" }; definition.Id = DefaultVersionId;
var cacheKey = "cache-key-filter"; var (_, workflowGraph) = CreateWorkflowAndGraph(DefaultDefinitionId);
var filter = new WorkflowDefinitionFilter { DefinitionId = DefaultDefinitionId };
_cacheManager.CreateWorkflowFilterCacheKey(filter).Returns(cacheKey);
_decoratedService.TryFindWorkflowGraphAsync(filter, Arg.Any<CancellationToken>()).Returns(findResult);
_cacheManager.CreateWorkflowDefinitionFilterCacheKey(filter).Returns("cache-key-def");
_cacheManager.CreateWorkflowVersionCacheKey(DefaultVersionId).Returns("cache-key-graph");
_decoratedService.FindWorkflowDefinitionAsync(filter, Arg.Any<CancellationToken>()).Returns(definition);
SetupDefinitionAndGraphMaterialization(definition, workflowGraph);
var service = CreateService(); var service = CreateService();
// Act // Act
var result = await service.TryFindWorkflowGraphAsync(filter); var result = await service.TryFindWorkflowGraphAsync(filter);
// Assert // Assert
Assert.Same(findResult, result); Assert.Same(definition, result.WorkflowDefinition);
_cacheManager.Received(1).CreateWorkflowFilterCacheKey(filter); Assert.Same(workflowGraph, result.WorkflowGraph);
_cacheManager.Received(1).CreateWorkflowDefinitionFilterCacheKey(filter);
_cacheManager.Received(1).CreateWorkflowVersionCacheKey(DefaultVersionId);
} }
[Fact] [Fact]
public async Task TryFindWorkflowGraphsAsync_WithAvailableMaterializer_CachesGraph() public async Task TryFindWorkflowGraphsAsync_WithAvailableMaterializer_CachesGraph()
{ {
// Arrange // Arrange
var definition = TestHelpers.CreateWorkflowDefinition("def-1", "materializer"); var definition = TestHelpers.CreateWorkflowDefinition(DefaultDefinitionId, DefaultMaterializerName);
definition.Id = "id-1"; definition.Id = "id-1";
var definitions = new[] { definition }; var (_, graph) = CreateWorkflowAndGraph(DefaultDefinitionId);
var (_, graph) = CreateWorkflowAndGraph("def-1");
var filter = new WorkflowDefinitionFilter(); var filter = new WorkflowDefinitionFilter();
_workflowDefinitionStore.FindManyAsync(filter, Arg.Any<CancellationToken>()).Returns(definitions);
_cacheManager.CreateWorkflowVersionCacheKey("id-1").Returns("cache-key-1");
_materializerRegistry.IsMaterializerAvailable("materializer").Returns(true);
_decoratedService.MaterializeWorkflowAsync(definition, Arg.Any<CancellationToken>()).Returns(graph);
_workflowDefinitionStore.FindManyAsync(filter, Arg.Any<CancellationToken>()).Returns(new[] { definition });
_cacheManager.CreateWorkflowVersionCacheKey("id-1").Returns("cache-key");
SetupDefinitionAndGraphMaterialization(definition, graph);
var service = CreateService(); var service = CreateService();
// Act // Act
@ -367,16 +424,14 @@ public class CachingWorkflowDefinitionServiceTests
public async Task TryFindWorkflowGraphsAsync_WithUnavailableMaterializer_ReturnsNullGraph() public async Task TryFindWorkflowGraphsAsync_WithUnavailableMaterializer_ReturnsNullGraph()
{ {
// Arrange // Arrange
var definition = TestHelpers.CreateWorkflowDefinition("def-1", "unavailable-materializer"); const string unavailableMaterializer = "unavailable-materializer";
var definition = TestHelpers.CreateWorkflowDefinition(DefaultDefinitionId, unavailableMaterializer);
definition.Id = "id-1"; definition.Id = "id-1";
var definitions = new[] { definition };
var filter = new WorkflowDefinitionFilter(); var filter = new WorkflowDefinitionFilter();
_workflowDefinitionStore.FindManyAsync(filter, Arg.Any<CancellationToken>()).Returns(definitions);
_cacheManager.CreateWorkflowVersionCacheKey("id-1").Returns("cache-key-1");
_materializerRegistry.IsMaterializerAvailable("unavailable-materializer").Returns(false);
_workflowDefinitionStore.FindManyAsync(filter, Arg.Any<CancellationToken>()).Returns(new[] { definition });
_cacheManager.CreateWorkflowVersionCacheKey("id-1").Returns("cache-key");
_materializerRegistry.IsMaterializerAvailable(unavailableMaterializer).Returns(false);
var service = CreateService(); var service = CreateService();
// Act // Act
@ -476,12 +531,16 @@ public class CachingWorkflowDefinitionServiceTests
} }
/// <summary> /// <summary>
/// Creates a workflow graph find result for testing. /// Sets up a complete workflow definition with graph materialization for testing scenarios
/// where definition needs to be resolved and materialized into a graph.
/// </summary> /// </summary>
private WorkflowGraphFindResult CreateWorkflowGraphFindResult(string definitionId = "def-1", string materializerName = "materializer") private void SetupDefinitionAndGraphMaterialization(
WorkflowDefinition definition,
WorkflowGraph workflowGraph,
string? materializerName = null)
{ {
var definition = TestHelpers.CreateWorkflowDefinition(definitionId, materializerName); materializerName ??= definition.MaterializerName;
var (_, workflowGraph) = CreateWorkflowAndGraph(definitionId); _materializerRegistry.IsMaterializerAvailable(materializerName).Returns(true);
return new(definition, workflowGraph); _decoratedService.MaterializeWorkflowAsync(definition, Arg.Any<CancellationToken>()).Returns(workflowGraph);
} }
} }