Merge branch 'backport/serdica-engine-fixes'

This commit is contained in:
master
2026-04-08 15:29:49 +03:00
16 changed files with 447 additions and 11 deletions

View File

@@ -14,6 +14,11 @@
<Project Path="__Libraries/StellaOps.Workflow.Signaling.Redis/StellaOps.Workflow.Signaling.Redis.csproj" />
</Folder>
<Project Path="../__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.csproj" />
<Folder Name="/__Plugins/">
<Project Path="__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Oracle/StellaOps.Workflow.Plugin.WorkflowStore.Oracle.csproj" />
<Project Path="__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Mongo/StellaOps.Workflow.Plugin.WorkflowStore.Mongo.csproj" />
<Project Path="__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Postgres/StellaOps.Workflow.Plugin.WorkflowStore.Postgres.csproj" />
</Folder>
<Project Path="StellaOps.Workflow.WebService/StellaOps.Workflow.WebService.csproj" />
<Folder Name="/__Tests/">
<Project Path="__Tests/StellaOps.Workflow.Engine.Tests/StellaOps.Workflow.Engine.Tests.csproj" />

View File

@@ -0,0 +1,45 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Resolves the authenticated actor's identity (ID + roles) from the server-side
/// request context. Implementations may query a database, read JWT claims, or
/// fall back to client-supplied values.
/// </summary>
public interface IWorkflowActorRoleResolver
{
/// <summary>
/// Returns the actor ID for the current request.
/// Implementations should prefer server-side identity over client-supplied values.
/// </summary>
string ResolveActorId(string? clientActorId);
/// <summary>
/// Resolves user roles from a trusted source (database, identity provider).
/// Falls back to <paramref name="clientRoles"/> when no server-side context
/// is available (e.g., during signal pump processing).
/// </summary>
Task<IReadOnlyCollection<string>> ResolveActorRolesAsync(
string actorId,
IReadOnlyCollection<string>? clientRoles,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default no-op resolver that passes through client-supplied values.
/// Used when no database-backed role resolution is configured.
/// </summary>
public sealed class NullWorkflowActorRoleResolver : IWorkflowActorRoleResolver
{
public string ResolveActorId(string? clientActorId)
=> clientActorId ?? string.Empty;
public Task<IReadOnlyCollection<string>> ResolveActorRolesAsync(
string actorId,
IReadOnlyCollection<string>? clientRoles,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyCollection<string>>(clientRoles ?? []);
}

View File

@@ -179,8 +179,10 @@ public static partial class WorkflowCanonicalDefinitionCompiler
},
ResultKey = resultKey,
TimeoutSeconds = timeoutSeconds,
WhenFailure = failureHandlers?.HasFailureBranch == true ? BuildSequence(failureHandlers.WhenFailure) : null,
WhenTimeout = failureHandlers?.HasTimeoutBranch == true ? BuildSequence(failureHandlers.WhenTimeout) : null,
// Preserve empty handlers (0 steps) as empty sequences — they mean "ignore failure and continue".
// Only set to null when no handler was provided at all.
WhenFailure = failureHandlers is not null ? BuildSequence(failureHandlers.WhenFailure) : null,
WhenTimeout = failureHandlers is not null ? BuildSequence(failureHandlers.WhenTimeout) : null,
};
}

View File

@@ -480,10 +480,12 @@ public static class WorkflowCanonicalExpressionRuntime
JsonValueKind.Number when element.TryGetInt64(out var int64Value) => int64Value,
JsonValueKind.Number when element.TryGetDecimal(out var decimalValue) => decimalValue,
JsonValueKind.Number when element.TryGetDouble(out var doubleValue) => doubleValue,
JsonValueKind.Object => element.EnumerateObject().ToDictionary(
x => x.Name,
x => ToRuntimeValue(x.Value),
StringComparer.OrdinalIgnoreCase),
JsonValueKind.Object => element.EnumerateObject()
.GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => ToRuntimeValue(g.Last().Value),
StringComparer.OrdinalIgnoreCase),
JsonValueKind.Array => element.EnumerateArray().Select(ToRuntimeValue).ToArray(),
_ => element.ToString(),
};

View File

@@ -726,6 +726,104 @@ public sealed class WorkflowFlowBuilder<TStartRequest>
null);
}
public WorkflowFlowBuilder<TStartRequest> Call(
string stepName,
Address address,
WorkflowExpressionDefinition payloadExpression,
Action<WorkflowFlowBuilder<TStartRequest>> whenFailure,
Action<WorkflowFlowBuilder<TStartRequest>>? whenTimeout = null)
{
return AddMicroserviceCall(
stepName,
address.MicroserviceName,
address.Command,
context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context),
payloadExpression,
null,
whenFailure,
whenTimeout);
}
public WorkflowFlowBuilder<TStartRequest> Call(
string stepName,
Address address,
WorkflowExpressionDefinition payloadExpression,
WorkflowHandledBranchAction onFailure,
WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None)
{
return AddMicroserviceCall(
stepName,
address.MicroserviceName,
address.Command,
context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context),
payloadExpression,
null,
null,
null,
onFailure,
onTimeout);
}
public WorkflowFlowBuilder<TStartRequest> Call<TResponse>(
string stepName,
Address address,
WorkflowExpressionDefinition payloadExpression,
string? resultKey = null)
{
ArgumentNullException.ThrowIfNull(payloadExpression);
return AddMicroserviceCall(
stepName,
address.MicroserviceName,
address.Command,
context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context),
payloadExpression,
resultKey ?? stepName,
null,
null);
}
public WorkflowFlowBuilder<TStartRequest> Call<TResponse>(
string stepName,
Address address,
WorkflowExpressionDefinition payloadExpression,
Action<WorkflowFlowBuilder<TStartRequest>> whenFailure,
Action<WorkflowFlowBuilder<TStartRequest>>? whenTimeout = null,
string? resultKey = null)
{
ArgumentNullException.ThrowIfNull(payloadExpression);
return AddMicroserviceCall(
stepName,
address.MicroserviceName,
address.Command,
context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context),
payloadExpression,
resultKey,
whenFailure,
whenTimeout);
}
public WorkflowFlowBuilder<TStartRequest> Call<TResponse>(
string stepName,
Address address,
WorkflowExpressionDefinition payloadExpression,
WorkflowHandledBranchAction onFailure,
WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None,
string? resultKey = null)
{
ArgumentNullException.ThrowIfNull(payloadExpression);
return AddMicroserviceCall(
stepName,
address.MicroserviceName,
address.Command,
context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context),
payloadExpression,
resultKey,
null,
null,
onFailure,
onTimeout);
}
public WorkflowFlowBuilder<TStartRequest> Call(
string stepName,
Address address,

View File

@@ -0,0 +1,35 @@
using System.Threading;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Propagates the authenticated actor ID through async workflow execution chains
/// (e.g., OnComplete -> Call -> transport) where the original request context
/// is not available. Implementations set ActorId and CallerUserId during
/// task-complete/start-workflow processing.
/// </summary>
public sealed class WorkflowExecutionActorContext
{
private static readonly AsyncLocal<string?> CurrentActorId = new();
private static readonly AsyncLocal<string?> CurrentCallerUserId = new();
/// <summary>
/// The actor ID from the task operation request (e.g., numeric user account ID).
/// Used for role resolution and task assignment.
/// </summary>
public string? ActorId
{
get => CurrentActorId.Value;
set => CurrentActorId.Value = value;
}
/// <summary>
/// The authenticated caller's user ID extracted from the HTTP request context.
/// Used by transport implementations for backend service calls.
/// </summary>
public string? CallerUserId
{
get => CurrentCallerUserId.Value;
set => CurrentCallerUserId.Value = value;
}
}

View File

@@ -78,6 +78,10 @@ public static class WorkflowRegistrationServiceCollectionExtensions
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
// Allow floating-point JSON numbers (e.g., 201000256548.0) to deserialize into integer
// types (long?, int?). This is needed because the workflow state round-trips through
// JSON serialization which may add ".0" to integer values.
NumberHandling = global::System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString,
};
public static IServiceCollection AddWorkflowRegistration<TWorkflow>(
@@ -190,7 +194,11 @@ public static class WorkflowRegistrationServiceCollectionExtensions
return new Dictionary<string, object?>(payload, StringComparer.OrdinalIgnoreCase);
}
var json = JsonSerializer.Serialize(payload, SerializerOptions);
// Normalize decimal-valued integers (e.g., 201000256548.0 → 201000256548)
// before serialization. This is needed because the workflow state may store
// integer values as decimals after JSON round-trips.
var normalized = NormalizePayloadNumbers(payload);
var json = JsonSerializer.Serialize(normalized, SerializerOptions);
return JsonSerializer.Deserialize(json, startRequestType, SerializerOptions)
?? throw new InvalidOperationException(
$"Unable to bind workflow payload to '{startRequestType.FullName}'.");
@@ -291,5 +299,27 @@ public static class WorkflowRegistrationServiceCollectionExtensions
};
}
private static IDictionary<string, object?> NormalizePayloadNumbers(IDictionary<string, object?> payload)
{
var result = new Dictionary<string, object?>(payload.Count, StringComparer.OrdinalIgnoreCase);
foreach (var kvp in payload)
{
result[kvp.Key] = NormalizeValue(kvp.Value);
}
return result;
}
private static object? NormalizeValue(object? value)
{
return value switch
{
decimal d when d == Math.Truncate(d) && d >= long.MinValue && d <= long.MaxValue => (long)d,
double d when d == Math.Truncate(d) && d >= long.MinValue && d <= long.MaxValue => (long)d,
IDictionary<string, object?> dict => NormalizePayloadNumbers(dict),
object[] arr => arr.Select(NormalizeValue).ToArray(),
_ => value,
};
}
private sealed record WorkflowBusinessReferencePartProperty(PropertyInfo Property, string PartName);
}

View File

@@ -16,4 +16,6 @@ public sealed record StartWorkflowResponse
public required string WorkflowName { get; init; }
public required string WorkflowVersion { get; init; }
public WorkflowBusinessReference? BusinessReference { get; init; }
public IReadOnlyCollection<WorkflowTaskSummary> NextTasks { get; init; } = [];
public IDictionary<string, object?> WorkflowState { get; init; } = new Dictionary<string, object?>();
}

View File

@@ -87,6 +87,9 @@ public sealed record WorkflowTaskCompleteResponse
{
public required string WorkflowTaskId { get; init; }
public required bool Completed { get; init; }
public string? WorkflowInstanceId { get; init; }
public IReadOnlyCollection<WorkflowTaskSummary> NextTasks { get; init; } = [];
public IDictionary<string, object?> WorkflowState { get; init; } = new Dictionary<string, object?>();
}
public sealed record WorkflowTaskAssignRequest

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.EntityFrameworkCore;
using StellaOps.Workflow.Abstractions;
@@ -10,10 +11,27 @@ public static class OracleWorkflowDataStoreExtensions
public static IServiceCollection AddWorkflowOracleDataStore(
this IServiceCollection services, IConfiguration configuration)
{
// Register WorkflowDbContext with Oracle provider
// Register OracleWorkflowRuntimeStateStore
// Register OracleWorkflowHostedJobLockService
// Register EF-based projection/retention stores
services.AddWorkflowModule("workflow-store.oracle", "1.0.0");
services.AddSingleton<IWorkflowBackendRegistrationMarker>(
new WorkflowBackendRegistrationMarker(WorkflowBackendNames.Oracle));
if (!string.Equals(configuration.GetWorkflowBackendProvider(), WorkflowBackendNames.Oracle, StringComparison.OrdinalIgnoreCase))
{
return services;
}
services.AddDbContext<WorkflowDbContext>(options =>
{
var connectionString = configuration.GetConnectionString("WorkflowOracle")
?? configuration.GetConnectionString("Default");
if (!string.IsNullOrWhiteSpace(connectionString))
{
options.UseOracle(connectionString);
}
});
services.Replace(ServiceDescriptor.Scoped<IWorkflowRuntimeStateStore, OracleWorkflowRuntimeStateStore>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowHostedJobLockService, OracleWorkflowHostedJobLockService>());
return services;
}
}

View File

@@ -0,0 +1,16 @@
using StellaOps.DependencyInjection;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.DataStore.MongoDB;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Workflow.Plugin.WorkflowStore.Mongo;
public sealed class ServiceRegistrator : IDependencyInjectionRoutine
{
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
{
return services.AddWorkflowMongoDataStore(configuration);
}
}

View File

@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDynamicLoading>true</EnableDynamicLoading>
<Nullable>enable</Nullable>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
<CopyPluginBinariesToPath Condition="'$(CopyPluginBinariesToPath)' == ''">$([System.IO.Path]::Combine($(MSBuildProjectDirectory),'..','..','StellaOps.Workflow.WebService','PluginBinaries','$(MSBuildProjectName)'))</CopyPluginBinariesToPath>
</PropertyGroup>
<Target Name="CopyPluginBinaries" AfterTargets="Build">
<ItemGroup>
<BuiltFiles Include="$(TargetDir)**\*.*" />
</ItemGroup>
<RemoveDir Directories="$(CopyPluginBinariesToPath)" Condition="Exists('$(CopyPluginBinariesToPath)')" />
<MakeDir Directories="$(CopyPluginBinariesToPath)" />
<Copy SourceFiles="@(BuiltFiles)" DestinationFolder="$(CopyPluginBinariesToPath)" SkipUnchangedFiles="false" />
</Target>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.DataStore.MongoDB\StellaOps.Workflow.DataStore.MongoDB.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Abstractions\StellaOps.Workflow.Abstractions.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,45 @@
using StellaOps.DependencyInjection;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.DataStore.Oracle;
using StellaOps.Workflow.Signaling.OracleAq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Workflow.Plugin.WorkflowStore.Oracle;
public sealed class ServiceRegistrator : IDependencyInjectionRoutine
{
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
{
services.AddWorkflowOracleDataStore(configuration);
if (!string.Equals(configuration.GetWorkflowBackendProvider(), WorkflowBackendNames.Oracle, StringComparison.OrdinalIgnoreCase))
{
return services;
}
var useNativeSignalDriver = string.Equals(
configuration.GetWorkflowSignalDriverProvider(),
WorkflowSignalDriverNames.Native,
StringComparison.OrdinalIgnoreCase);
services.AddScoped<IOracleAqTransport, OracleManagedAqTransport>();
services.AddScoped<OracleAqWorkflowSignalBus>();
services.AddScoped<OracleAqWorkflowScheduleBus>();
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalStore>(sp => sp.GetRequiredService<OracleAqWorkflowSignalBus>()));
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalClaimStore>(sp => sp.GetRequiredService<OracleAqWorkflowSignalBus>()));
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalScheduler>(sp => sp.GetRequiredService<OracleAqWorkflowScheduleBus>()));
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalDeadLetterStore, OracleAqWorkflowSignalDeadLetterStore>());
if (useNativeSignalDriver)
{
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalDriver>(sp => sp.GetRequiredService<OracleAqWorkflowSignalBus>()));
}
services.AddSingleton<IWorkflowSignalDriverRegistrationMarker>(
new WorkflowSignalDriverRegistrationMarker(WorkflowSignalDriverNames.Native));
return services;
}
}

View File

@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDynamicLoading>true</EnableDynamicLoading>
<Nullable>enable</Nullable>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
<CopyPluginBinariesToPath Condition="'$(CopyPluginBinariesToPath)' == ''">$([System.IO.Path]::Combine($(MSBuildProjectDirectory),'..','..','StellaOps.Workflow.WebService','PluginBinaries','$(MSBuildProjectName)'))</CopyPluginBinariesToPath>
</PropertyGroup>
<Target Name="CopyPluginBinaries" AfterTargets="Build">
<ItemGroup>
<BuiltFiles Include="$(TargetDir)**\*.*" />
</ItemGroup>
<RemoveDir Directories="$(CopyPluginBinariesToPath)" Condition="Exists('$(CopyPluginBinariesToPath)')" />
<MakeDir Directories="$(CopyPluginBinariesToPath)" />
<Copy SourceFiles="@(BuiltFiles)" DestinationFolder="$(CopyPluginBinariesToPath)" SkipUnchangedFiles="false" />
</Target>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Engine\StellaOps.Workflow.Engine.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.DataStore.Oracle\StellaOps.Workflow.DataStore.Oracle.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Signaling.OracleAq\StellaOps.Workflow.Signaling.OracleAq.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Abstractions\StellaOps.Workflow.Abstractions.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
using StellaOps.DependencyInjection;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.DataStore.PostgreSQL;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Workflow.Plugin.WorkflowStore.Postgres;
public sealed class ServiceRegistrator : IDependencyInjectionRoutine
{
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
{
return services.AddWorkflowPostgresDataStore(configuration);
}
}

View File

@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDynamicLoading>true</EnableDynamicLoading>
<Nullable>enable</Nullable>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
<CopyPluginBinariesToPath Condition="'$(CopyPluginBinariesToPath)' == ''">$([System.IO.Path]::Combine($(MSBuildProjectDirectory),'..','..','StellaOps.Workflow.WebService','PluginBinaries','$(MSBuildProjectName)'))</CopyPluginBinariesToPath>
</PropertyGroup>
<Target Name="CopyPluginBinaries" AfterTargets="Build">
<ItemGroup>
<BuiltFiles Include="$(TargetDir)**\*.*" />
</ItemGroup>
<RemoveDir Directories="$(CopyPluginBinariesToPath)" Condition="Exists('$(CopyPluginBinariesToPath)')" />
<MakeDir Directories="$(CopyPluginBinariesToPath)" />
<Copy SourceFiles="@(BuiltFiles)" DestinationFolder="$(CopyPluginBinariesToPath)" SkipUnchangedFiles="false" />
</Target>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.DataStore.PostgreSQL\StellaOps.Workflow.DataStore.PostgreSQL.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Abstractions\StellaOps.Workflow.Abstractions.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
</Project>