Add StellaOps.Workflow engine: 14 libraries, WebService, 8 test projects

Extract product-agnostic workflow engine from Ablera.Serdica.Workflow into
standalone StellaOps.Workflow.* libraries targeting net10.0.

Libraries (14):
- Contracts, Abstractions (compiler, decompiler, expression runtime)
- Engine (execution, signaling, scheduling, projections, hosted services)
- ElkSharp (generic graph layout algorithm)
- Renderer.ElkSharp, Renderer.ElkJs, Renderer.Msagl, Renderer.Svg
- Signaling.Redis, Signaling.OracleAq
- DataStore.MongoDB, DataStore.PostgreSQL, DataStore.Oracle

WebService: ASP.NET Core Minimal API with 22 endpoints

Tests (8 projects, 109 tests pass):
- Engine.Tests (105 pass), WebService.Tests (4 E2E pass)
- Renderer.Tests, DataStore.MongoDB/Oracle/PostgreSQL.Tests
- Signaling.Redis.Tests, IntegrationTests.Shared

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-20 19:14:44 +02:00
parent e56f9a114a
commit f5b5f24d95
422 changed files with 85428 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
using System;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using StackExchange.Redis;
using NUnit.Framework;
namespace StellaOps.Workflow.Signaling.Redis.Tests;
public sealed class RedisDockerFixture : IDisposable
{
private readonly string containerName = $"stella-workflow-redis-{Guid.NewGuid():N}";
private readonly int hostPort = GetFreeTcpPort();
private bool started;
public string ConnectionString => $"127.0.0.1:{hostPort},abortConnect=false,connectRetry=5,connectTimeout=1000";
public async Task StartOrIgnoreAsync(CancellationToken cancellationToken = default)
{
if (started)
{
return;
}
if (!await CanUseDockerAsync(cancellationToken))
{
Assert.Ignore("Docker is not available. Redis-backed workflow integration tests require a local Docker daemon.");
}
var runExitCode = await RunDockerCommandAsync(
$"run -d --name {containerName} -p {hostPort}:6379 redis:7-alpine",
ignoreErrors: false,
cancellationToken);
if (runExitCode != 0)
{
Assert.Ignore("Unable to start Redis Docker container for workflow integration tests.");
}
started = true;
try
{
await WaitUntilReadyAsync(cancellationToken);
}
catch
{
Dispose();
throw;
}
}
public void Dispose()
{
if (!started)
{
return;
}
try
{
RunDockerCommandAsync($"rm -f {containerName}", ignoreErrors: true, CancellationToken.None)
.GetAwaiter()
.GetResult();
}
catch
{
}
finally
{
started = false;
}
}
private async Task WaitUntilReadyAsync(CancellationToken cancellationToken)
{
var timeoutAt = DateTime.UtcNow.AddSeconds(30);
while (DateTime.UtcNow < timeoutAt)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await using var connection = await ConnectionMultiplexer.ConnectAsync(ConnectionString);
var db = connection.GetDatabase();
await db.PingAsync();
return;
}
catch
{
await Task.Delay(500, cancellationToken);
}
}
Dispose();
Assert.Ignore("Redis Docker container did not become ready in time for workflow integration tests.");
}
private static async Task<bool> CanUseDockerAsync(CancellationToken cancellationToken)
{
return await RunDockerCommandAsync("version --format {{.Server.Version}}", ignoreErrors: true, cancellationToken) == 0;
}
private static async Task<int> RunDockerCommandAsync(
string arguments,
bool ignoreErrors,
CancellationToken cancellationToken)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "docker",
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
},
};
try
{
if (!process.Start())
{
return -1;
}
await process.WaitForExitAsync(cancellationToken);
return process.ExitCode;
}
catch when (ignoreErrors)
{
return -1;
}
}
private static int GetFreeTcpPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
finally
{
listener.Stop();
}
}
}

View File

@@ -0,0 +1,137 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Signaling.Redis;
using FluentAssertions;
using Microsoft.Extensions.Options;
using NUnit.Framework;
using StackExchange.Redis;
namespace StellaOps.Workflow.Signaling.Redis.Tests;
[TestFixture]
[Category("Integration")]
public class RedisWorkflowSignalDriverIntegrationTests
{
private RedisDockerFixture? fixture;
private string? channelName;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new RedisDockerFixture();
await fixture.StartOrIgnoreAsync();
channelName = $"stella:test:workflow:wake:{Guid.NewGuid():N}";
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task RedisWorkflowSignalDriver_ShouldWakeAndClaimSignal()
{
await using var connection = await ConnectionMultiplexer.ConnectAsync(fixture!.ConnectionString);
var claimStore = new RecordingSignalClaimStore();
claimStore.NextClaims.Enqueue(null);
claimStore.NextClaims.Enqueue(new RecordingSignalLease(CreateEnvelope("redis-driver-1")));
var driverOptions = Options.Create(new RedisWorkflowSignalDriverOptions
{
ChannelName = channelName!,
BlockingWaitSeconds = 5,
});
await using var wakeSubscription = new RedisWorkflowWakeSubscription(connection, driverOptions);
var driver = new RedisWorkflowSignalDriver(
connection,
claimStore,
wakeSubscription,
driverOptions);
var receiveTask = driver.ReceiveAsync("consumer-a");
await Task.Delay(250);
await driver.NotifySignalAvailableAsync(CreateNotification("redis-driver-1"));
await using var lease = await receiveTask;
lease.Should().NotBeNull();
lease!.Envelope.SignalId.Should().Be("redis-driver-1");
claimStore.ConsumerNames.Should().Contain("consumer-a");
}
[Test]
public async Task RedisWorkflowSignalDriver_ShouldUsePostCommitDispatchMode()
{
await using var connection = await ConnectionMultiplexer.ConnectAsync(fixture!.ConnectionString);
var claimStore = new RecordingSignalClaimStore();
var driverOptions = Options.Create(new RedisWorkflowSignalDriverOptions
{
ChannelName = channelName!,
BlockingWaitSeconds = 5,
});
await using var wakeSubscription = new RedisWorkflowWakeSubscription(connection, driverOptions);
var driver = new RedisWorkflowSignalDriver(
connection,
claimStore,
wakeSubscription,
driverOptions);
driver.DispatchMode.Should().Be(WorkflowSignalDriverDispatchMode.PostCommitNotification);
}
private static WorkflowSignalEnvelope CreateEnvelope(string signalId)
{
return new WorkflowSignalEnvelope
{
SignalId = signalId,
WorkflowInstanceId = $"wf-{signalId}",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.ExternalSignal,
ExpectedVersion = 1,
};
}
private static WorkflowSignalWakeNotification CreateNotification(string signalId)
{
return new WorkflowSignalWakeNotification
{
SignalId = signalId,
WorkflowInstanceId = $"wf-{signalId}",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.ExternalSignal,
};
}
private sealed class RecordingSignalClaimStore : IWorkflowSignalClaimStore
{
public Queue<IWorkflowSignalLease?> NextClaims { get; } = new();
public ConcurrentBag<string> ConsumerNames { get; } = [];
public Task<IWorkflowSignalLease?> TryClaimAsync(
string consumerName,
CancellationToken cancellationToken = default)
{
ConsumerNames.Add(consumerName);
return Task.FromResult(NextClaims.Count == 0 ? null : NextClaims.Dequeue());
}
}
private sealed class RecordingSignalLease(WorkflowSignalEnvelope envelope) : IWorkflowSignalLease
{
public WorkflowSignalEnvelope Envelope { get; } = envelope;
public int DeliveryCount => 1;
public Task CompleteAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task AbandonAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task DeadLetterAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<UseXunitV3>false</UseXunitV3>
<ConcelierSharedTestsPath></ConcelierSharedTestsPath>
<NoWarn>CS8601;CS8602;CS8604;NU1015</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="4.2.2" />
<PackageReference Include="NUnit.Analyzers" Version="4.3.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="StackExchange.Redis" Version="2.10.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Abstractions\StellaOps.Workflow.Abstractions.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Contracts\StellaOps.Workflow.Contracts.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Engine\StellaOps.Workflow.Engine.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Signaling.Redis\StellaOps.Workflow.Signaling.Redis.csproj" />
</ItemGroup>
</Project>