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,251 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.MongoDB.Tests.Performance;
using StellaOps.Workflow.DataStore.MongoDB;
using StellaOps.Workflow.Engine.Services;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests;
[TestFixture]
[NonParallelizable]
public class MongoBulstradWorkflowIntegrationTests
{
private MongoDockerFixture? fixture;
private WorkflowStoreMongoOptions? options;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new MongoDockerFixture();
await fixture.StartOrIgnoreAsync();
options = new WorkflowStoreMongoOptions
{
ConnectionStringName = "WorkflowMongo",
DatabaseName = "workflow_mongo_bulstrad_test",
BlockingWaitSeconds = 1,
};
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetDatabaseAsync(options!.DatabaseName);
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task QuoteOrAplCancel_WhenServicesSucceedAcrossRestartedProviders_ShouldCompleteWithoutTasks()
{
var transports = MongoPerformanceTestSupport.CreateQuoteOrAplCancelSuccessTransports();
string workflowInstanceId;
using (var provider = CreateProvider(transports))
{
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "QuoteOrAplCancel",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 996601L,
},
});
workflowInstanceId = startResponse.WorkflowInstanceId;
}
using var resumedProvider = CreateProvider(transports);
var resumedRuntimeService = resumedProvider.GetRequiredService<WorkflowRuntimeService>();
var instance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowInstanceId,
});
var tasks = await resumedRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = workflowInstanceId,
});
tasks.Tasks.Should().BeEmpty();
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine);
ReadBool(instance.WorkflowState, "releaseDocNumbersFailed").Should().BeFalse();
ReadBool(instance.WorkflowState, "cancelApplicationFailed").Should().BeFalse();
transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}")
.Should().Equal(
"Envelope:bst_blanknumbersrelease",
"Envelope:pas_annexprocessing_cancelaplorqt");
}
[Test]
public async Task InsisIntegrationNew_WhenTransferRequiresRetryAcrossRestartedProviders_ShouldCreateRetryTask()
{
var transports = CreateInsisIntegrationNewRetryTransports();
string workflowInstanceId;
using (var provider = CreateProvider(transports))
{
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "InsisIntegrationNew",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 995601L,
["srAnnexId"] = 885601L,
["srCustId"] = 775601L,
},
});
workflowInstanceId = startResponse.WorkflowInstanceId;
}
using var resumedProvider = CreateProvider(transports);
var resumedRuntimeService = resumedProvider.GetRequiredService<WorkflowRuntimeService>();
var instance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowInstanceId,
});
var tasks = await resumedRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = workflowInstanceId,
Status = WorkflowTaskStatuses.Open,
});
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Open);
instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine);
instance.WorkflowState["nextStep"]!.ToString().Should().Be("RETRY");
instance.WorkflowState["taskDescription"]!.ToString().Should().Be("INSIS retry required");
tasks.Tasks.Should().ContainSingle();
tasks.Tasks.Single().TaskName.Should().Be("Retry");
tasks.Tasks.Single().TaskType.Should().Be("PolicyIntegrationPartialFailure");
tasks.Tasks.Single().TaskRoles.Should().Contain("APR_ANNEX");
tasks.Tasks.Single().Payload["taskDescription"]!.ToString().Should().Be("INSIS retry required");
transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}")
.Should().Equal(
"Envelope:bst_integration_processsendpolicyrequest",
"Envelope:pas_polannexes_get");
}
[Test]
public async Task QuotationConfirm_WhenConvertedToPolicyAcrossRestartedProviders_ShouldContinueWithPdfGenerator()
{
var transports = MongoPerformanceTestSupport.CreateQuotationConfirmConvertToPolicyTransports();
string workflowInstanceId;
using (var provider = CreateProvider(transports))
{
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "QuotationConfirm",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 996612L,
["srAnnexId"] = 886612L,
["srCustId"] = 776612L,
},
});
workflowInstanceId = startResponse.WorkflowInstanceId;
}
using (var provider = CreateProvider(transports))
{
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var quotationTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = workflowInstanceId,
Status = WorkflowTaskStatuses.Open,
})).Tasks.Should().ContainSingle().Subject;
await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
{
WorkflowTaskId = quotationTask.WorkflowTaskId,
ActorId = "mongo-user",
ActorRoles = ["DBA"],
Payload = new Dictionary<string, object?>
{
["answer"] = "confirm",
},
});
}
using var resumedProvider = CreateProvider(transports);
var resumedRuntimeService = resumedProvider.GetRequiredService<WorkflowRuntimeService>();
var processedSignals = await MongoPerformanceTestSupport.DrainSignalsUntilIdleAsync(resumedProvider, TimeSpan.FromSeconds(45));
var quotationInstance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowInstanceId,
});
var pdfInstances = await resumedRuntimeService.GetInstancesAsync(new WorkflowInstancesGetRequest
{
WorkflowName = "PdfGenerator",
BusinessReferenceKey = "996612",
});
var pdfInstance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = pdfInstances.Instances.Should().ContainSingle().Subject.WorkflowInstanceId,
});
processedSignals.Should().BeGreaterThan(0);
quotationInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
quotationInstance.WorkflowState["nextStep"]!.ToString().Should().Be("ConvertToPolicy");
pdfInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
pdfInstance.WorkflowState["stage"]!.ToString().Should().Be("POL");
ReadBool(pdfInstance.WorkflowState, "printFailed").Should().BeFalse();
transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}")
.Should().Equal(
"Envelope:pas_polannexes_get",
"Envelope:pas_polreg_checkuwrules",
"Envelope:pas_polreg_convertqttopoldefault",
"Envelope:bst_integration_printpolicydocuments");
}
private ServiceProvider CreateProvider(WorkflowTransportScripts transports)
{
var configuration = MongoPerformanceTestSupport.CreateConfiguration(options!, fixture!.ConnectionString, includeAssignmentRoles: true);
return MongoPerformanceTestSupport.CreateBulstradProvider(configuration, transports);
}
private static WorkflowTransportScripts CreateInsisIntegrationNewRetryTransports()
{
var transports = new WorkflowTransportScripts();
transports.LegacyRabbit
.Respond("bst_integration_processsendpolicyrequest", new
{
policySubstatus = "PENDING_RETRY",
})
.Respond("pas_polannexes_get", new
{
shortDescription = "INSIS retry required",
});
return transports;
}
private static bool ReadBool(IDictionary<string, object?> state, string key)
{
return state[key] switch
{
bool boolean => boolean,
_ => bool.Parse(state[key]!.ToString()!),
};
}
}

View File

@@ -0,0 +1,187 @@
using System;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests;
internal sealed class MongoDockerFixture : IDisposable
{
private readonly string containerName = $"stella-workflow-mongo-{Guid.NewGuid():N}";
private readonly int hostPort = GetFreeTcpPort();
private const string ReplicaSetName = "rs0";
private bool started;
private string BootstrapConnectionString => $"mongodb://127.0.0.1:{hostPort}/?directConnection=true";
public string ConnectionString => $"mongodb://127.0.0.1:{hostPort}/?replicaSet={ReplicaSetName}&directConnection=true";
public async Task StartOrIgnoreAsync(CancellationToken cancellationToken = default)
{
if (started)
{
return;
}
if (!await CanUseDockerAsync(cancellationToken))
{
Assert.Ignore("Docker is not available. Mongo-backed workflow integration tests require a local Docker daemon.");
}
var runExitCode = await RunDockerCommandAsync(
$"run -d --name {containerName} -p {hostPort}:27017 mongo:7.0 --replSet {ReplicaSetName} --bind_ip_all",
ignoreErrors: false,
cancellationToken);
if (runExitCode != 0)
{
Assert.Ignore("Unable to start MongoDB Docker container for workflow integration tests.");
}
started = true;
await WaitUntilServerIsReachableAsync(cancellationToken);
await InitializeReplicaSetAsync(cancellationToken);
await WaitUntilReplicaSetPrimaryAsync(cancellationToken);
}
public async Task ResetDatabaseAsync(string databaseName, CancellationToken cancellationToken = default)
{
var client = new MongoClient(ConnectionString);
await client.DropDatabaseAsync(databaseName, cancellationToken);
}
private async Task WaitUntilServerIsReachableAsync(CancellationToken cancellationToken)
{
var timeoutAt = DateTime.UtcNow.AddSeconds(30);
while (DateTime.UtcNow < timeoutAt)
{
cancellationToken.ThrowIfCancellationRequested();
if (await RunDockerCommandAsync(
$"exec {containerName} mongosh --quiet --eval \"db.adminCommand({{ ping: 1 }}).ok\"",
ignoreErrors: true,
cancellationToken) == 0)
{
return;
}
await Task.Delay(500, cancellationToken);
}
Dispose();
Assert.Ignore("MongoDB Docker container did not become ready in time for workflow integration tests.");
}
private async Task InitializeReplicaSetAsync(CancellationToken cancellationToken)
{
var command =
$"exec {containerName} mongosh --quiet --eval \"try {{ rs.initiate({{ _id: '{ReplicaSetName}', members: [{{ _id: 0, host: 'localhost:27017' }}] }}) }} catch (e) {{ if (!e.message.includes('already')) throw e; }}\"";
if (await RunDockerCommandAsync(command, ignoreErrors: true, cancellationToken) != 0)
{
Dispose();
Assert.Ignore("MongoDB replica set initialization failed for workflow integration tests.");
}
}
private async Task WaitUntilReplicaSetPrimaryAsync(CancellationToken cancellationToken)
{
var timeoutAt = DateTime.UtcNow.AddSeconds(30);
while (DateTime.UtcNow < timeoutAt)
{
cancellationToken.ThrowIfCancellationRequested();
if (await RunDockerCommandAsync(
$"exec {containerName} mongosh --quiet --eval \"db.hello().isWritablePrimary ? quit(0) : quit(1)\"",
ignoreErrors: true,
cancellationToken) == 0)
{
return;
}
await Task.Delay(500, cancellationToken);
}
Dispose();
Assert.Ignore("MongoDB replica set did not become primary in time for workflow integration tests.");
}
public void Dispose()
{
if (!started)
{
return;
}
try
{
RunDockerCommandAsync($"rm -f {containerName}", ignoreErrors: true, CancellationToken.None)
.GetAwaiter()
.GetResult();
}
catch
{
}
finally
{
started = false;
}
}
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,13 @@
using System.Threading;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests;
internal static class MongoSerializerRegistrationCoordinator
{
private static int configured;
public static bool TryConfigure()
{
return Interlocked.Exchange(ref configured, 1) == 0;
}
}

View File

@@ -0,0 +1,212 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.MongoDB;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests;
[TestFixture]
[Category("Integration")]
public class MongoWorkflowProjectionIntegrationTests
{
private MongoDockerFixture? fixture;
private WorkflowStoreMongoOptions? options;
private IConfiguration? configuration;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new MongoDockerFixture();
await fixture.StartOrIgnoreAsync();
options = new WorkflowStoreMongoOptions
{
ConnectionStringName = "WorkflowMongo",
DatabaseName = "workflow_mongo_projection_test",
};
configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"ConnectionStrings:{options.ConnectionStringName}"] = fixture.ConnectionString,
["WorkflowRetention:OpenStaleAfterDays"] = "30",
["WorkflowRetention:CompletedPurgeAfterDays"] = "180",
})
.Build();
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetDatabaseAsync(options!.DatabaseName);
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task ProjectionStore_ShouldCreateAssignCompleteAndInspectWorkflow()
{
var store = CreateStore();
var definition = new WorkflowDefinitionDescriptor
{
WorkflowName = "ApproveApplication",
WorkflowVersion = "1.0.0",
DisplayName = "Approve Application",
WorkflowRoles = ["uw"],
};
var businessReference = new WorkflowBusinessReference
{
Key = "policy",
Parts = new Dictionary<string, object?>
{
["policyId"] = 42L,
},
};
var started = await store.CreateWorkflowAsync(
definition,
businessReference,
new WorkflowStartExecutionPlan
{
InstanceStatus = "Open",
WorkflowState = new Dictionary<string, JsonElement>
{
["phase"] = JsonSerializer.SerializeToElement("start"),
},
Tasks =
[
new WorkflowExecutionTaskPlan
{
TaskName = "Review",
TaskType = "Human",
Route = "review",
TaskRoles = ["uw.review"],
Payload = new Dictionary<string, JsonElement>
{
["requestId"] = JsonSerializer.SerializeToElement("REQ-1"),
},
}
],
});
var task = (await store.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = started.WorkflowInstanceId,
})).Single();
task.TaskName.Should().Be("Review");
var assigned = await store.AssignTaskAsync(task.WorkflowTaskId, "actor-1", "actor-1");
assigned.Assignee.Should().Be("actor-1");
assigned.Status.Should().Be("Assigned");
await store.ApplyTaskCompletionAsync(
task.WorkflowTaskId,
"actor-1",
new Dictionary<string, object?> { ["approved"] = true },
new WorkflowTaskCompletionPlan
{
InstanceStatus = "Completed",
WorkflowState = new Dictionary<string, JsonElement>
{
["phase"] = JsonSerializer.SerializeToElement("done"),
},
},
businessReference);
var details = await store.GetInstanceDetailsAsync(started.WorkflowInstanceId);
details.Should().NotBeNull();
details!.Instance.Status.Should().Be("Completed");
ReadText(details.WorkflowState["phase"]).Should().Be("done");
details.Tasks.Should().ContainSingle();
details.Tasks.Single().Status.Should().Be("Completed");
details.TaskEvents.Select(x => x.EventType).Should().Contain(["Created", "Assigned", "Completed"]);
}
[Test]
public async Task ProjectionStore_ShouldExposeSyntheticChildProjectionInstanceDetails()
{
var store = CreateStore();
var definition = new WorkflowDefinitionDescriptor
{
WorkflowName = "ParentWorkflow",
WorkflowVersion = "1.0.0",
DisplayName = "Parent Workflow",
WorkflowRoles = ["uw"],
};
const string projectionId = "proj-child-1";
var started = await store.CreateWorkflowAsync(
definition,
businessReference: null,
new WorkflowStartExecutionPlan
{
Tasks =
[
new WorkflowExecutionTaskPlan
{
WorkflowName = "ReviewPolicyOpenForChange",
WorkflowVersion = "1.0.0",
TaskName = "Review Child",
TaskType = "Human",
Route = "child-review",
Payload = new Dictionary<string, JsonElement>
{
[WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey] =
JsonSerializer.SerializeToElement(projectionId),
},
}
],
});
var childDetails = await store.GetInstanceDetailsAsync(projectionId);
childDetails.Should().NotBeNull();
childDetails!.Instance.WorkflowInstanceId.Should().Be(projectionId);
childDetails.Tasks.Should().ContainSingle();
childDetails.Tasks.Single().TaskName.Should().Be("Review Child");
childDetails.TaskEvents.Should().ContainSingle();
childDetails.TaskEvents.Single().EventType.Should().Be("Created");
var rootDetails = await store.GetInstanceDetailsAsync(started.WorkflowInstanceId);
rootDetails.Should().NotBeNull();
rootDetails!.Tasks.Should().ContainSingle();
}
private MongoWorkflowProjectionStore CreateStore()
{
return new MongoWorkflowProjectionStore(
new MongoWorkflowDatabase(
new MongoClient(fixture!.ConnectionString),
configuration!,
Options.Create(options!),
new MongoWorkflowMutationSessionAccessor(),
new StellaOps.Workflow.Engine.Services.WorkflowMutationScopeAccessor()),
configuration!);
}
private static string? ReadText(object? value)
{
return value switch
{
string text => text,
JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.String => jsonElement.GetString(),
JsonElement jsonElement => jsonElement.ToString(),
_ => value?.ToString(),
};
}
}

View File

@@ -0,0 +1,181 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.MongoDB;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests;
[TestFixture]
[Category("Integration")]
public class MongoWorkflowSignalIntegrationTests
{
private MongoDockerFixture? fixture;
private WorkflowStoreMongoOptions? options;
private IConfiguration? configuration;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new MongoDockerFixture();
await fixture.StartOrIgnoreAsync();
options = new WorkflowStoreMongoOptions
{
ConnectionStringName = "WorkflowMongo",
DatabaseName = "workflow_mongo_signal_test",
BlockingWaitSeconds = 1,
};
configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"ConnectionStrings:{options.ConnectionStringName}"] = fixture.ConnectionString,
})
.Build();
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetDatabaseAsync(options!.DatabaseName);
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task SignalBus_ShouldPublishReceiveDeadLetterAndReplaySignals()
{
var database = CreateDatabase();
var signalStore = new MongoWorkflowSignalStore(database);
var signalDriver = new MongoWorkflowSignalBus(signalStore);
var deadLetters = new MongoWorkflowSignalDeadLetterStore(signalStore);
var envelope = CreateEnvelope("signal-mongo-1");
await signalStore.PublishAsync(envelope);
await signalDriver.NotifySignalAvailableAsync(CreateWakeNotification(envelope));
await using var lease = await signalDriver.ReceiveAsync("consumer-a");
lease.Should().NotBeNull();
lease!.Envelope.WorkflowInstanceId.Should().Be(envelope.WorkflowInstanceId);
lease.DeliveryCount.Should().Be(1);
await lease.DeadLetterAsync();
var messages = await deadLetters.GetMessagesAsync(new WorkflowSignalDeadLettersGetRequest
{
SignalId = envelope.SignalId,
});
messages.Messages.Should().ContainSingle();
messages.Messages.Single().WorkflowInstanceId.Should().Be(envelope.WorkflowInstanceId);
var replay = await deadLetters.ReplayAsync(new WorkflowSignalDeadLetterReplayRequest
{
SignalId = envelope.SignalId,
});
replay.Replayed.Should().BeTrue();
await using var replayedLease = await signalDriver.ReceiveAsync("consumer-b");
replayedLease.Should().NotBeNull();
replayedLease!.Envelope.SignalId.Should().Be(envelope.SignalId);
await replayedLease.CompleteAsync();
}
[Test]
public async Task ScheduleBus_ShouldReleaseSignalWhenDueAtIsReached()
{
var database = CreateDatabase();
var signalStore = new MongoWorkflowSignalStore(database);
var signalDriver = new MongoWorkflowSignalBus(signalStore);
var scheduleBus = new MongoWorkflowScheduleBus(signalStore);
var envelope = CreateEnvelope("signal-mongo-2") with
{
DueAtUtc = DateTime.UtcNow.AddMilliseconds(250),
};
await scheduleBus.ScheduleAsync(envelope, envelope.DueAtUtc!.Value);
IWorkflowSignalLease? lease = null;
var deadlineUtc = DateTime.UtcNow.AddSeconds(5);
while (lease is null && DateTime.UtcNow < deadlineUtc)
{
lease = await signalDriver.ReceiveAsync("consumer-schedule");
}
await using var asyncLease = lease;
asyncLease.Should().NotBeNull();
asyncLease!.Envelope.SignalId.Should().Be(envelope.SignalId);
await asyncLease.CompleteAsync();
}
[Test]
public async Task SignalBus_WhenNoSignalAvailable_ShouldReturnNullAfterBlockingWindow()
{
var database = CreateDatabase();
var signalStore = new MongoWorkflowSignalStore(database);
var signalDriver = new MongoWorkflowSignalBus(signalStore);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var startedAtUtc = DateTime.UtcNow;
await using var lease = await signalDriver.ReceiveAsync("consumer-empty", cts.Token);
lease.Should().BeNull();
(DateTime.UtcNow - startedAtUtc).Should().BeLessThan(TimeSpan.FromSeconds(3));
}
private MongoWorkflowDatabase CreateDatabase()
{
return new MongoWorkflowDatabase(
new MongoClient(fixture!.ConnectionString),
configuration!,
Options.Create(options!),
new MongoWorkflowMutationSessionAccessor(),
new StellaOps.Workflow.Engine.Services.WorkflowMutationScopeAccessor());
}
private static WorkflowSignalEnvelope CreateEnvelope(string signalId)
{
return new WorkflowSignalEnvelope
{
SignalId = signalId,
WorkflowInstanceId = $"wf-{signalId}",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.ExternalSignal,
ExpectedVersion = 1,
WaitingToken = "wait-1",
Payload = new Dictionary<string, JsonElement>
{
["name"] = JsonSerializer.SerializeToElement("documents-uploaded"),
},
};
}
private static WorkflowSignalWakeNotification CreateWakeNotification(WorkflowSignalEnvelope envelope)
{
return new WorkflowSignalWakeNotification
{
SignalId = envelope.SignalId,
WorkflowInstanceId = envelope.WorkflowInstanceId,
RuntimeProvider = envelope.RuntimeProvider,
SignalType = envelope.SignalType,
DueAtUtc = envelope.DueAtUtc,
};
}
}

View File

@@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.MongoDB;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests;
[TestFixture]
[Category("Integration")]
public class MongoWorkflowStoreIntegrationTests
{
private MongoDockerFixture? fixture;
private WorkflowStoreMongoOptions? options;
private IConfiguration? configuration;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new MongoDockerFixture();
await fixture.StartOrIgnoreAsync();
options = new WorkflowStoreMongoOptions
{
ConnectionStringName = "WorkflowMongo",
DatabaseName = "workflow_mongo_store_test",
};
configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"ConnectionStrings:{options.ConnectionStringName}"] = fixture.ConnectionString,
})
.Build();
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetDatabaseAsync(options!.DatabaseName);
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task RuntimeStateStore_ShouldPersistAndVersionRuntimeStates()
{
var database = CreateDatabase();
var store = new MongoWorkflowRuntimeStateStore(database);
var initialState = new WorkflowRuntimeStateRecord
{
WorkflowInstanceId = "wf-mongo-1",
WorkflowName = "ApproveApplication",
WorkflowVersion = "1.0.0",
Version = 1,
BusinessReference = new WorkflowBusinessReference
{
Key = "policy",
Parts = new Dictionary<string, object?>
{
["policyId"] = 123L,
},
},
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
RuntimeInstanceId = "runtime-1",
RuntimeStatus = "WaitingForTask",
StateJson = """{"phase":"start"}""",
CreatedOnUtc = DateTime.UtcNow,
LastUpdatedOnUtc = DateTime.UtcNow,
};
await store.UpsertAsync(initialState);
await store.UpsertAsync(initialState with
{
Version = 2,
RuntimeStatus = "Completed",
StateJson = """{"phase":"done"}""",
CompletedOnUtc = DateTime.UtcNow,
LastUpdatedOnUtc = DateTime.UtcNow,
});
var reloaded = await store.GetAsync(initialState.WorkflowInstanceId);
reloaded.Should().NotBeNull();
reloaded!.Version.Should().Be(2);
reloaded.RuntimeStatus.Should().Be("Completed");
reloaded.StateJson.Should().Contain("done");
ReadLong(reloaded.BusinessReference!.Parts["policyId"]).Should().Be(123L);
}
[Test]
public async Task RuntimeStateStore_WhenVersionConflicts_ShouldThrowConcurrencyException()
{
var database = CreateDatabase();
var store = new MongoWorkflowRuntimeStateStore(database);
var state = new WorkflowRuntimeStateRecord
{
WorkflowInstanceId = "wf-mongo-2",
WorkflowName = "ApproveApplication",
WorkflowVersion = "1.0.0",
Version = 1,
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
RuntimeInstanceId = "runtime-2",
RuntimeStatus = "Open",
StateJson = "{}",
CreatedOnUtc = DateTime.UtcNow,
LastUpdatedOnUtc = DateTime.UtcNow,
};
await store.UpsertAsync(state);
var act = () => store.UpsertAsync(state with
{
Version = 3,
LastUpdatedOnUtc = DateTime.UtcNow,
});
await act.Should().ThrowAsync<WorkflowRuntimeStateConcurrencyException>();
}
[Test]
public async Task HostedJobLockService_ShouldAcquireAndReleaseLocks()
{
var database = CreateDatabase();
var service = new MongoWorkflowHostedJobLockService(database);
var acquiredOnUtc = DateTime.UtcNow;
var firstAcquire = await service.TryAcquireAsync("retention", "node-a", acquiredOnUtc, TimeSpan.FromMinutes(5));
var secondAcquire = await service.TryAcquireAsync("retention", "node-b", acquiredOnUtc, TimeSpan.FromMinutes(5));
firstAcquire.Should().BeTrue();
secondAcquire.Should().BeFalse();
await service.ReleaseAsync("retention", "node-a");
var thirdAcquire = await service.TryAcquireAsync("retention", "node-b", acquiredOnUtc.AddMinutes(1), TimeSpan.FromMinutes(5));
thirdAcquire.Should().BeTrue();
}
[Test]
public async Task MutationCoordinator_WhenScopeIsNotCommitted_ShouldRollbackRuntimeStateChanges()
{
var database = CreateDatabase();
var coordinator = new MongoWorkflowMutationCoordinator(database);
var store = new MongoWorkflowRuntimeStateStore(database);
var state = new WorkflowRuntimeStateRecord
{
WorkflowInstanceId = "wf-mongo-tx-1",
WorkflowName = "ApproveApplication",
WorkflowVersion = "1.0.0",
Version = 1,
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
RuntimeInstanceId = "runtime-tx-1",
RuntimeStatus = "Open",
StateJson = "{}",
CreatedOnUtc = DateTime.UtcNow,
LastUpdatedOnUtc = DateTime.UtcNow,
};
await using (await coordinator.BeginAsync())
{
await store.UpsertAsync(state);
}
(await store.GetAsync(state.WorkflowInstanceId)).Should().BeNull();
}
[Test]
public async Task MutationCoordinator_WhenCommitted_ShouldPersistRuntimeStateChanges()
{
var database = CreateDatabase();
var coordinator = new MongoWorkflowMutationCoordinator(database);
var store = new MongoWorkflowRuntimeStateStore(database);
var state = new WorkflowRuntimeStateRecord
{
WorkflowInstanceId = "wf-mongo-tx-2",
WorkflowName = "ApproveApplication",
WorkflowVersion = "1.0.0",
Version = 1,
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
RuntimeInstanceId = "runtime-tx-2",
RuntimeStatus = "Open",
StateJson = "{}",
CreatedOnUtc = DateTime.UtcNow,
LastUpdatedOnUtc = DateTime.UtcNow,
};
await using (var scope = await coordinator.BeginAsync())
{
await store.UpsertAsync(state);
await scope.CommitAsync();
}
var reloaded = await store.GetAsync(state.WorkflowInstanceId);
reloaded.Should().NotBeNull();
reloaded!.WorkflowInstanceId.Should().Be(state.WorkflowInstanceId);
}
private MongoWorkflowDatabase CreateDatabase()
{
return new MongoWorkflowDatabase(
new MongoClient(fixture!.ConnectionString),
configuration!,
Options.Create(options!),
new MongoWorkflowMutationSessionAccessor(),
new StellaOps.Workflow.Engine.Services.WorkflowMutationScopeAccessor());
}
private static long ReadLong(object? value)
{
return value switch
{
long longValue => longValue,
int intValue => intValue,
JsonElement jsonElement => jsonElement.GetInt64(),
_ => Convert.ToInt64(value),
};
}
}

View File

@@ -0,0 +1,92 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.DataStore.MongoDB;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests;
[TestFixture]
[Category("Integration")]
public class MongoWorkflowWakeOutboxIntegrationTests
{
private MongoDockerFixture? fixture;
private WorkflowStoreMongoOptions? options;
private IConfiguration? configuration;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new MongoDockerFixture();
await fixture.StartOrIgnoreAsync();
options = new WorkflowStoreMongoOptions
{
ConnectionStringName = "WorkflowMongo",
DatabaseName = "workflow_mongo_wake_outbox_test",
BlockingWaitSeconds = 1,
};
configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"ConnectionStrings:{options.ConnectionStringName}"] = fixture.ConnectionString,
})
.Build();
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetDatabaseAsync(options!.DatabaseName);
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task WakeOutbox_ShouldEnqueueReceiveAndCompleteNotification()
{
var database = new MongoWorkflowDatabase(
new MongoClient(fixture!.ConnectionString),
configuration!,
Options.Create(options!),
new MongoWorkflowMutationSessionAccessor(),
new StellaOps.Workflow.Engine.Services.WorkflowMutationScopeAccessor());
var outbox = new MongoWorkflowWakeOutbox(database);
var notification = CreateNotification("mongo-outbox-1");
await outbox.EnqueueAsync(notification);
await using var lease = await outbox.ReceiveAsync("publisher-a");
lease.Should().NotBeNull();
lease!.Notification.SignalId.Should().Be(notification.SignalId);
await lease.CompleteAsync();
await using var nextLease = await outbox.ReceiveAsync("publisher-a");
nextLease.Should().BeNull();
}
private static WorkflowSignalWakeNotification CreateNotification(string signalId)
{
return new WorkflowSignalWakeNotification
{
SignalId = signalId,
WorkflowInstanceId = $"wf-{signalId}",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.ExternalSignal,
};
}
}

View File

@@ -0,0 +1,160 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.MongoDB;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests.Performance;
[TestFixture]
[NonParallelizable]
[Category("Performance")]
[Category(WorkflowPerformanceCategories.Capacity)]
public class MongoPerformanceCapacityTests
{
private MongoDockerFixture? fixture;
private WorkflowStoreMongoOptions? options;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new MongoDockerFixture();
await fixture.StartOrIgnoreAsync();
options = MongoPerformanceTestSupport.CreateOptions("workflow_perf_capacity");
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetDatabaseAsync(options!.DatabaseName);
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[TestCase(1)]
[TestCase(4)]
[TestCase(8)]
[TestCase(16)]
public async Task MongoEnginePerfCapacity_WhenSignalRoundTripRunsAtDifferentConcurrency_ShouldWriteArtifacts(int concurrency)
{
var workflowCount = 16 * concurrency;
var workerCount = Math.Min(concurrency, 8);
var databaseName = options!.DatabaseName;
var configuration = MongoPerformanceTestSupport.CreateConfiguration(options, fixture!.ConnectionString);
using var provider = MongoPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, mongoMetrics) = await MongoPerformanceTestSupport.MeasureWithMongoMetricsAsync(
fixture.ConnectionString,
databaseName,
async () =>
{
var starts = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
concurrency,
async index =>
{
var started = DateTime.UtcNow;
var response = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "MongoPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 994000L + (concurrency * 1000L) + index,
},
}));
return (Index: index, Response: response, StartedAtUtc: started);
});
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
starts,
concurrency,
async start =>
{
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = start.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 884000L + start.Index,
},
}));
return true;
});
var totalProcessedSignals = await MongoPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(
provider,
TimeSpan.FromSeconds(60),
workerCount);
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
starts,
concurrency,
async start =>
{
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = start.Response.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
endToEndLatencies.Add(DateTime.UtcNow - start.StartedAtUtc);
return true;
});
return totalProcessedSignals;
});
var completedAtUtc = DateTime.UtcNow;
processedSignals.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = $"mongo-signal-roundtrip-capacity-c{concurrency}",
Tier = WorkflowPerformanceCategories.Capacity,
EnvironmentName = "mongo-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies)!,
BackendMetrics = mongoMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "MongoPerfSignalRoundTripWorkflow",
["ladder"] = "1,4,8,16",
["workerCount"] = workerCount.ToString(),
},
});
}
}

View File

@@ -0,0 +1,165 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.MongoDB;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests.Performance;
[TestFixture]
[NonParallelizable]
[Category("Performance")]
[Category(WorkflowPerformanceCategories.Latency)]
public class MongoPerformanceLatencyTests
{
private MongoDockerFixture? fixture;
private WorkflowStoreMongoOptions? options;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new MongoDockerFixture();
await fixture.StartOrIgnoreAsync();
options = MongoPerformanceTestSupport.CreateOptions("workflow_perf_latency");
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetDatabaseAsync(options!.DatabaseName);
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task MongoEnginePerfLatency_WhenSignalRoundTripRunsSerially_ShouldWriteArtifacts()
{
const int workflowCount = 16;
var databaseName = options!.DatabaseName;
var configuration = MongoPerformanceTestSupport.CreateConfiguration(options, fixture!.ConnectionString);
using var provider = MongoPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var startLatencies = new ConcurrentBag<TimeSpan>();
var signalPublishLatencies = new ConcurrentBag<TimeSpan>();
var signalToCompletionLatencies = new ConcurrentBag<TimeSpan>();
var signalToFirstCompletionLatencies = new ConcurrentBag<TimeSpan>();
var drainToIdleOverhangLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, mongoMetrics) = await MongoPerformanceTestSupport.MeasureWithMongoMetricsAsync(
fixture.ConnectionString,
databaseName,
async () =>
{
var totalProcessedSignals = 0;
for (var index = 0; index < workflowCount; index++)
{
var workflowStartedAtUtc = DateTime.UtcNow;
var startStartedAtUtc = DateTime.UtcNow;
var startResponse = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "MongoPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 997000L + index,
},
}));
startLatencies.Add(DateTime.UtcNow - startStartedAtUtc);
var signalPublishedAtUtc = DateTime.UtcNow;
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 887000L + index,
},
}));
signalPublishLatencies.Add(DateTime.UtcNow - signalPublishedAtUtc);
var drain = await MongoPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleDetailedAsync(
provider,
TimeSpan.FromSeconds(20),
workerCount: 1);
totalProcessedSignals += drain.ProcessedCount;
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
drain.FirstProcessedAtUtc.Should().NotBeNull();
drain.LastProcessedAtUtc.Should().NotBeNull();
endToEndLatencies.Add(drain.CompletedAtUtc - workflowStartedAtUtc);
signalToFirstCompletionLatencies.Add(drain.FirstProcessedAtUtc!.Value - signalPublishedAtUtc);
drainToIdleOverhangLatencies.Add(drain.CompletedAtUtc - drain.LastProcessedAtUtc!.Value);
signalToCompletionLatencies.Add(drain.CompletedAtUtc - signalPublishedAtUtc);
}
return totalProcessedSignals;
});
var completedAtUtc = DateTime.UtcNow;
processedSignals.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "mongo-signal-roundtrip-latency-serial",
Tier = WorkflowPerformanceCategories.Latency,
EnvironmentName = "mongo-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies)!,
PhaseLatencySummaries = new Dictionary<string, WorkflowPerformanceLatencySummary>
{
["drainToIdleOverhang"] = WorkflowPerformanceLatencySummary.FromSamples(drainToIdleOverhangLatencies)!,
["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!,
["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!,
["signalToFirstCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToFirstCompletionLatencies)!,
["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!,
},
BackendMetrics = mongoMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "MongoPerfSignalRoundTripWorkflow",
["workerCount"] = "1",
["measurementKind"] = "serial-latency",
},
});
}
}

View File

@@ -0,0 +1,245 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Driver;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests.Performance;
internal static class MongoPerformanceMetricsCollector
{
public static async Task<MongoPerformanceMetrics> CaptureAsync(
string connectionString,
string databaseName,
CancellationToken cancellationToken = default)
{
var client = new MongoClient(connectionString);
var admin = client.GetDatabase("admin");
var database = client.GetDatabase(databaseName);
var serverStatus = await admin.RunCommandAsync<BsonDocument>(
new BsonDocument("serverStatus", 1),
cancellationToken: cancellationToken);
var dbStats = await database.RunCommandAsync<BsonDocument>(
new BsonDocument("dbStats", 1),
cancellationToken: cancellationToken);
var topOperations = await ReadTopOperationsAsync(admin, cancellationToken);
return new MongoPerformanceMetrics
{
CapturedAtUtc = DateTime.UtcNow,
HostName = GetString(serverStatus, "host"),
ServerVersion = GetString(serverStatus, "version"),
DatabaseName = databaseName,
CounterStats = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase)
{
["opcounters.insert"] = GetNestedInt64(serverStatus, "opcounters", "insert"),
["opcounters.query"] = GetNestedInt64(serverStatus, "opcounters", "query"),
["opcounters.update"] = GetNestedInt64(serverStatus, "opcounters", "update"),
["opcounters.delete"] = GetNestedInt64(serverStatus, "opcounters", "delete"),
["opcounters.getmore"] = GetNestedInt64(serverStatus, "opcounters", "getmore"),
["opcounters.command"] = GetNestedInt64(serverStatus, "opcounters", "command"),
["metrics.document.returned"] = GetNestedInt64(serverStatus, "metrics", "document", "returned"),
["metrics.document.inserted"] = GetNestedInt64(serverStatus, "metrics", "document", "inserted"),
["metrics.document.updated"] = GetNestedInt64(serverStatus, "metrics", "document", "updated"),
["metrics.document.deleted"] = GetNestedInt64(serverStatus, "metrics", "document", "deleted"),
["network.bytesIn"] = GetNestedInt64(serverStatus, "network", "bytesIn"),
["network.bytesOut"] = GetNestedInt64(serverStatus, "network", "bytesOut"),
["network.numRequests"] = GetNestedInt64(serverStatus, "network", "numRequests"),
["transactions.totalCommitted"] = GetNestedInt64(serverStatus, "transactions", "totalCommitted"),
["transactions.totalAborted"] = GetNestedInt64(serverStatus, "transactions", "totalAborted"),
["transactions.totalStarted"] = GetNestedInt64(serverStatus, "transactions", "totalStarted"),
["dbStats.objects"] = GetInt64(dbStats, "objects"),
["dbStats.dataSize"] = GetInt64(dbStats, "dataSize"),
["dbStats.storageSize"] = GetInt64(dbStats, "storageSize"),
["dbStats.indexSize"] = GetInt64(dbStats, "indexSize"),
},
DurationStats = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase)
{
["opLatencies.reads.latency"] = GetNestedInt64(serverStatus, "opLatencies", "reads", "latency"),
["opLatencies.writes.latency"] = GetNestedInt64(serverStatus, "opLatencies", "writes", "latency"),
["opLatencies.commands.latency"] = GetNestedInt64(serverStatus, "opLatencies", "commands", "latency"),
},
TopOperations = topOperations,
ConnectionsCurrent = (int)GetNestedInt64(serverStatus, "connections", "current"),
ConnectionsAvailable = (int)GetNestedInt64(serverStatus, "connections", "available"),
QueueReaders = (int)GetNestedInt64(serverStatus, "globalLock", "currentQueue", "readers"),
QueueWriters = (int)GetNestedInt64(serverStatus, "globalLock", "currentQueue", "writers"),
QueueTotal = (int)GetNestedInt64(serverStatus, "globalLock", "currentQueue", "total"),
};
}
private static async Task<IReadOnlyList<WorkflowPerformanceWaitMetric>> ReadTopOperationsAsync(
IMongoDatabase adminDatabase,
CancellationToken cancellationToken)
{
try
{
var currentOp = await adminDatabase.RunCommandAsync<BsonDocument>(
new BsonDocument
{
["currentOp"] = 1,
["$all"] = true,
["idleConnections"] = false,
["idleSessions"] = false,
},
cancellationToken: cancellationToken);
if (!currentOp.TryGetValue("inprog", out var inProgressValue) || inProgressValue is not BsonArray inProgress)
{
return [];
}
return inProgress
.OfType<BsonDocument>()
.Select(document =>
{
var operation = GetString(document, "op");
if (string.IsNullOrWhiteSpace(operation))
{
operation = "unknown";
}
var waitingForLock = GetBool(document, "waitingForLock");
var descriptor = GetString(document, "desc");
var name = waitingForLock
? $"WaitingForLock:{operation}"
: $"CurrentOp:{operation}";
if (!string.IsNullOrWhiteSpace(descriptor)
&& descriptor.Contains("$changeStream", StringComparison.OrdinalIgnoreCase))
{
name = waitingForLock
? "WaitingForLock:changeStream"
: "CurrentOp:changeStream";
}
return name;
})
.GroupBy(name => name, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(group => group.Count())
.ThenBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.Take(8)
.Select(group => new WorkflowPerformanceWaitMetric
{
Name = group.Key,
TotalCount = group.Count(),
DurationMicroseconds = 0,
})
.ToArray();
}
catch
{
return [];
}
}
private static string GetString(BsonDocument document, string name)
{
return document.TryGetValue(name, out var value) && value.BsonType == BsonType.String
? value.AsString
: string.Empty;
}
private static bool GetBool(BsonDocument document, string name)
{
return document.TryGetValue(name, out var value)
&& value.BsonType == BsonType.Boolean
&& value.AsBoolean;
}
private static long GetInt64(BsonDocument document, string name)
{
return document.TryGetValue(name, out var value)
? ConvertToInt64(value)
: 0L;
}
private static long GetNestedInt64(BsonDocument document, params string[] path)
{
BsonValue current = document;
foreach (var segment in path)
{
if (current is not BsonDocument currentDocument
|| !currentDocument.TryGetValue(segment, out current))
{
return 0L;
}
}
return ConvertToInt64(current);
}
private static long ConvertToInt64(BsonValue value)
{
return value.BsonType switch
{
BsonType.Int32 => value.AsInt32,
BsonType.Int64 => value.AsInt64,
BsonType.Double => (long)Math.Round(value.AsDouble, MidpointRounding.AwayFromZero),
BsonType.Decimal128 => (long)Math.Round((double)value.AsDecimal128, MidpointRounding.AwayFromZero),
BsonType.String when long.TryParse(value.AsString, NumberStyles.Any, CultureInfo.InvariantCulture, out var number) => number,
_ => 0L,
};
}
}
internal sealed record MongoPerformanceMetrics
{
public required DateTime CapturedAtUtc { get; init; }
public required string HostName { get; init; }
public required string ServerVersion { get; init; }
public required string DatabaseName { get; init; }
public required IReadOnlyDictionary<string, long> CounterStats { get; init; }
public required IReadOnlyDictionary<string, long> DurationStats { get; init; }
public required IReadOnlyList<WorkflowPerformanceWaitMetric> TopOperations { get; init; }
public required int ConnectionsCurrent { get; init; }
public required int ConnectionsAvailable { get; init; }
public required int QueueReaders { get; init; }
public required int QueueWriters { get; init; }
public required int QueueTotal { get; init; }
}
internal sealed record MongoPerformanceDelta
{
public required string HostName { get; init; }
public required string ServerVersion { get; init; }
public required string DatabaseName { get; init; }
public required IReadOnlyDictionary<string, long> CounterDeltas { get; init; }
public required IReadOnlyDictionary<string, long> DurationDeltas { get; init; }
public required IReadOnlyList<WorkflowPerformanceWaitMetric> TopOperations { get; init; }
public required int ConnectionsCurrent { get; init; }
public required int ConnectionsAvailable { get; init; }
public required int QueueReaders { get; init; }
public required int QueueWriters { get; init; }
public required int QueueTotal { get; init; }
public static MongoPerformanceDelta Create(MongoPerformanceMetrics before, MongoPerformanceMetrics after)
{
return new MongoPerformanceDelta
{
HostName = after.HostName,
ServerVersion = after.ServerVersion,
DatabaseName = after.DatabaseName,
CounterDeltas = after.CounterStats
.ToDictionary(
pair => pair.Key,
pair => pair.Value - before.CounterStats.GetValueOrDefault(pair.Key),
StringComparer.OrdinalIgnoreCase),
DurationDeltas = after.DurationStats
.ToDictionary(
pair => pair.Key,
pair => pair.Value - before.DurationStats.GetValueOrDefault(pair.Key),
StringComparer.OrdinalIgnoreCase),
TopOperations = after.TopOperations,
ConnectionsCurrent = after.ConnectionsCurrent,
ConnectionsAvailable = after.ConnectionsAvailable,
QueueReaders = after.QueueReaders,
QueueWriters = after.QueueWriters,
QueueTotal = after.QueueTotal,
};
}
}

View File

@@ -0,0 +1,401 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.MongoDB;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests.Performance;
[TestFixture]
[NonParallelizable]
[Category("Performance")]
[Category(WorkflowPerformanceCategories.Nightly)]
public class MongoPerformanceNightlyTests
{
private MongoDockerFixture? fixture;
private WorkflowStoreMongoOptions? options;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new MongoDockerFixture();
await fixture.StartOrIgnoreAsync();
options = MongoPerformanceTestSupport.CreateOptions("workflow_perf_nightly");
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetDatabaseAsync(options!.DatabaseName);
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task MongoTransportPerfNightly_WhenImmediateBurstQueued_ShouldDrainAndWriteArtifacts()
{
const int messageCount = 120;
var databaseName = options!.DatabaseName;
var configuration = MongoPerformanceTestSupport.CreateConfiguration(options, fixture!.ConnectionString);
using var provider = MongoPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var (transportResult, mongoMetrics) = await MongoPerformanceTestSupport.MeasureWithMongoMetricsAsync(
fixture.ConnectionString,
databaseName,
() => MongoPerformanceTestSupport.RunImmediateTransportBurstAsync(
provider,
messageCount,
timeout: TimeSpan.FromSeconds(30),
correlationPrefix: "mongo-perf-immediate-nightly"));
var completedAtUtc = DateTime.UtcNow;
transportResult.ProcessedCorrelations.Should().HaveCount(messageCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "mongo-immediate-burst-nightly",
Tier = WorkflowPerformanceCategories.Nightly,
EnvironmentName = "mongo-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = messageCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
SignalsPublished = messageCount,
SignalsProcessed = messageCount,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(transportResult.ReceiveLatencies)!,
BackendMetrics = mongoMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["databaseName"] = options!.DatabaseName,
["signalCollection"] = options.SignalQueueCollectionName,
["messageCount"] = messageCount.ToString(),
},
});
}
[Test]
public async Task MongoTransportPerfNightly_WhenDelayedBurstQueued_ShouldDrainAndWriteArtifacts()
{
const int messageCount = 48;
const int delaySeconds = 2;
var databaseName = options!.DatabaseName;
var configuration = MongoPerformanceTestSupport.CreateConfiguration(options, fixture!.ConnectionString);
using var provider = MongoPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var (transportResult, mongoMetrics) = await MongoPerformanceTestSupport.MeasureWithMongoMetricsAsync(
fixture.ConnectionString,
databaseName,
() => MongoPerformanceTestSupport.RunDelayedTransportBurstAsync(
provider,
messageCount,
delaySeconds,
timeout: TimeSpan.FromSeconds(45),
correlationPrefix: "mongo-perf-delayed-nightly"));
var completedAtUtc = DateTime.UtcNow;
transportResult.ProcessedCorrelations.Should().HaveCount(messageCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "mongo-delayed-burst-nightly",
Tier = WorkflowPerformanceCategories.Nightly,
EnvironmentName = "mongo-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = messageCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
SignalsPublished = messageCount,
SignalsProcessed = messageCount,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(transportResult.ReceiveLatencies)!,
BackendMetrics = mongoMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["databaseName"] = options!.DatabaseName,
["messageCount"] = messageCount.ToString(),
["delaySeconds"] = delaySeconds.ToString(),
},
});
}
[Test]
public async Task MongoEnginePerfNightly_WhenSyntheticExternalResumeRunsInBurst_ShouldWriteArtifacts()
{
const int workflowCount = 36;
const int concurrency = 8;
const int workerCount = 8;
var databaseName = options!.DatabaseName;
var configuration = MongoPerformanceTestSupport.CreateConfiguration(options, fixture!.ConnectionString);
using var provider = MongoPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var (result, mongoMetrics) = await MongoPerformanceTestSupport.MeasureWithMongoMetricsAsync(
fixture.ConnectionString,
databaseName,
async () =>
{
var starts = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
concurrency,
async index =>
{
var started = DateTime.UtcNow;
var response = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "MongoPerfExternalSignalWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 999000L + index,
},
}));
return (Index: index, Response: response, StartedAtUtc: started);
});
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
starts,
concurrency,
async start =>
{
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = start.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 889000L + start.Index,
},
}));
return true;
});
var processedSignals = await MongoPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(
provider,
TimeSpan.FromSeconds(60),
workerCount);
var completedTasks = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
starts,
concurrency,
async start =>
{
var tasks = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = start.Response.WorkflowInstanceId,
Status = WorkflowTaskStatuses.Open,
}));
var task = tasks.Tasks.Should().ContainSingle().Subject;
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
{
WorkflowTaskId = task.WorkflowTaskId,
ActorId = "mongo-perf",
ActorRoles = ["DBA"],
Payload = new Dictionary<string, object?>(),
}));
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = start.Response.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
endToEndLatencies.Add(DateTime.UtcNow - start.StartedAtUtc);
return true;
});
return (ProcessedSignals: processedSignals, CompletedTasks: completedTasks.Count(result => result));
});
var completedAtUtc = DateTime.UtcNow;
result.ProcessedSignals.Should().Be(workflowCount);
result.CompletedTasks.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "mongo-synthetic-external-resume-nightly",
Tier = WorkflowPerformanceCategories.Nightly,
EnvironmentName = "mongo-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
TasksActivated = workflowCount,
TasksCompleted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = result.ProcessedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies)!,
BackendMetrics = mongoMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "MongoPerfExternalSignalWorkflow",
["workerCount"] = workerCount.ToString(),
},
});
}
[Test]
public async Task MongoBulstradPerfNightly_WhenQuotationConfirmConvertToPolicyRunsInBurst_ShouldWriteArtifacts()
{
const int workflowCount = 12;
const int concurrency = 4;
var databaseName = options!.DatabaseName;
var configuration = MongoPerformanceTestSupport.CreateConfiguration(options, fixture!.ConnectionString, includeAssignmentRoles: true);
var transports = MongoPerformanceTestSupport.CreateQuotationConfirmConvertToPolicyTransports();
using var provider = MongoPerformanceTestSupport.CreateBulstradProvider(configuration, transports);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, mongoMetrics) = await MongoPerformanceTestSupport.MeasureWithMongoMetricsAsync(
fixture.ConnectionString,
databaseName,
async () =>
{
var startResponses = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
concurrency,
async index =>
{
var started = DateTime.UtcNow;
var response = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "QuotationConfirm",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 999500L + index,
["srAnnexId"] = 1999500L + index,
["srCustId"] = 2999500L + index,
},
}));
return (Index: index, Response: response, StartedAtUtc: started);
});
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
startResponses,
concurrency,
async startResponse =>
{
var quotationTask = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
Status = WorkflowTaskStatuses.Open,
}));
var task = quotationTask.Tasks.Should().ContainSingle().Subject;
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
{
WorkflowTaskId = task.WorkflowTaskId,
ActorId = "mongo-perf-user",
ActorRoles = ["DBA"],
Payload = new Dictionary<string, object?>
{
["answer"] = "confirm",
},
}));
return true;
});
var processed = await MongoPerformanceTestSupport.DrainSignalsUntilIdleAsync(provider, TimeSpan.FromSeconds(45));
foreach (var startResponse in startResponses)
{
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
}));
var pdfInstances = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest
{
WorkflowName = "PdfGenerator",
BusinessReferenceKey = (999500L + startResponse.Index).ToString(),
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
instance.WorkflowState["nextStep"]!.ToString().Should().Be("ConvertToPolicy");
pdfInstances.Instances.Should().ContainSingle();
endToEndLatencies.Add(DateTime.UtcNow - startResponse.StartedAtUtc);
}
return processed;
});
var completedAtUtc = DateTime.UtcNow;
transports.LegacyRabbit.Invocations.Count.Should().Be(workflowCount * 4);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "mongo-bulstrad-quotation-confirm-convert-to-policy-nightly",
Tier = WorkflowPerformanceCategories.Nightly,
EnvironmentName = "mongo-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
TasksActivated = workflowCount,
TasksCompleted = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies)!,
BackendMetrics = mongoMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "QuotationConfirm",
["legacyRabbitInvocationCount"] = transports.LegacyRabbit.Invocations.Count.ToString(),
},
});
}
}

View File

@@ -0,0 +1,238 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.MongoDB;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests.Performance;
[TestFixture]
[NonParallelizable]
[Category("Performance")]
[Category(WorkflowPerformanceCategories.Smoke)]
public class MongoPerformanceSmokeTests
{
private MongoDockerFixture? fixture;
private WorkflowStoreMongoOptions? options;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new MongoDockerFixture();
await fixture.StartOrIgnoreAsync();
options = MongoPerformanceTestSupport.CreateOptions("workflow_perf_smoke");
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetDatabaseAsync(options!.DatabaseName);
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task MongoTransportPerfSmoke_WhenImmediateBurstQueued_ShouldDrainAndWriteArtifacts()
{
const int messageCount = 24;
var databaseName = options!.DatabaseName;
var configuration = MongoPerformanceTestSupport.CreateConfiguration(options, fixture!.ConnectionString);
using var provider = MongoPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var (transportResult, mongoMetrics) = await MongoPerformanceTestSupport.MeasureWithMongoMetricsAsync(
fixture.ConnectionString,
databaseName,
() => MongoPerformanceTestSupport.RunImmediateTransportBurstAsync(
provider,
messageCount,
timeout: TimeSpan.FromSeconds(20),
correlationPrefix: "mongo-perf-immediate"));
var completedAtUtc = DateTime.UtcNow;
transportResult.ProcessedCorrelations.Should().HaveCount(messageCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "mongo-immediate-burst-smoke",
Tier = WorkflowPerformanceCategories.Smoke,
EnvironmentName = "mongo-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = messageCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
SignalsPublished = messageCount,
SignalsProcessed = messageCount,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(transportResult.ReceiveLatencies)!,
BackendMetrics = mongoMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["databaseName"] = options!.DatabaseName,
["signalCollection"] = options.SignalQueueCollectionName,
["messageCount"] = messageCount.ToString(),
},
});
}
[Test]
public async Task MongoTransportPerfSmoke_WhenDelayedBurstQueued_ShouldDrainAndWriteArtifacts()
{
const int messageCount = 12;
const int delaySeconds = 2;
var databaseName = options!.DatabaseName;
var configuration = MongoPerformanceTestSupport.CreateConfiguration(options, fixture!.ConnectionString);
using var provider = MongoPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var (transportResult, mongoMetrics) = await MongoPerformanceTestSupport.MeasureWithMongoMetricsAsync(
fixture.ConnectionString,
databaseName,
() => MongoPerformanceTestSupport.RunDelayedTransportBurstAsync(
provider,
messageCount,
delaySeconds,
timeout: TimeSpan.FromSeconds(30),
correlationPrefix: "mongo-perf-delayed"));
var completedAtUtc = DateTime.UtcNow;
transportResult.ProcessedCorrelations.Should().HaveCount(messageCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "mongo-delayed-burst-smoke",
Tier = WorkflowPerformanceCategories.Smoke,
EnvironmentName = "mongo-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = messageCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
SignalsPublished = messageCount,
SignalsProcessed = messageCount,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(transportResult.ReceiveLatencies)!,
BackendMetrics = mongoMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["databaseName"] = options!.DatabaseName,
["messageCount"] = messageCount.ToString(),
["delaySeconds"] = delaySeconds.ToString(),
},
});
}
[Test]
public async Task MongoBulstradPerfSmoke_WhenQuoteOrAplCancelBurstStarted_ShouldCompleteAndWriteArtifacts()
{
const int workflowCount = 10;
const int concurrency = 4;
var databaseName = options!.DatabaseName;
var configuration = MongoPerformanceTestSupport.CreateConfiguration(options, fixture!.ConnectionString, includeAssignmentRoles: true);
var transports = MongoPerformanceTestSupport.CreateQuoteOrAplCancelSuccessTransports();
using var provider = MongoPerformanceTestSupport.CreateBulstradProvider(configuration, transports);
var startedAtUtc = DateTime.UtcNow;
var startLatencies = new ConcurrentBag<TimeSpan>();
var (bulstradResult, mongoMetrics) = await MongoPerformanceTestSupport.MeasureWithMongoMetricsAsync(
fixture.ConnectionString,
databaseName,
async () =>
{
var workflowIds = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
concurrency,
async index =>
{
var stopwatch = Stopwatch.StartNew();
var response = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "QuoteOrAplCancel",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 996000L + index,
},
}));
stopwatch.Stop();
startLatencies.Add(stopwatch.Elapsed);
return response.WorkflowInstanceId;
});
var completedInstances = 0;
foreach (var workflowId in workflowIds)
{
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
completedInstances++;
}
var openTasks = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowName = "QuoteOrAplCancel",
Status = WorkflowTaskStatuses.Open,
}));
return (CompletedInstances: completedInstances, OpenTaskCount: openTasks.Tasks.Count);
});
var completedAtUtc = DateTime.UtcNow;
bulstradResult.CompletedInstances.Should().Be(workflowCount);
bulstradResult.OpenTaskCount.Should().Be(0);
transports.LegacyRabbit.Invocations.Count.Should().Be(workflowCount * 2);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "mongo-bulstrad-quote-or-apl-cancel-smoke",
Tier = WorkflowPerformanceCategories.Smoke,
EnvironmentName = "mongo-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!,
BackendMetrics = mongoMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "QuoteOrAplCancel",
["legacyRabbitInvocationCount"] = transports.LegacyRabbit.Invocations.Count.ToString(),
},
});
}
}

View File

@@ -0,0 +1,171 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.MongoDB;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests.Performance;
[TestFixture]
[NonParallelizable]
[Category("Performance")]
[Category(WorkflowPerformanceCategories.Soak)]
public class MongoPerformanceSoakTests
{
private MongoDockerFixture? fixture;
private WorkflowStoreMongoOptions? options;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new MongoDockerFixture();
await fixture.StartOrIgnoreAsync();
options = MongoPerformanceTestSupport.CreateOptions("workflow_perf_soak");
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetDatabaseAsync(options!.DatabaseName);
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task MongoEnginePerfSoak_WhenSyntheticSignalRoundTripRunsInWaves_ShouldStayStableAndWriteArtifacts()
{
const int waveCount = 6;
const int workflowsPerWave = 18;
const int concurrency = 8;
const int workerCount = 8;
var databaseName = options!.DatabaseName;
var configuration = MongoPerformanceTestSupport.CreateConfiguration(options, fixture!.ConnectionString);
using var provider = MongoPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var (soakResult, mongoMetrics) = await MongoPerformanceTestSupport.MeasureWithMongoMetricsAsync(
fixture.ConnectionString,
databaseName,
async () =>
{
var totalProcessedSignals = 0;
var totalCompletedInstances = 0;
for (var waveIndex = 0; waveIndex < waveCount; waveIndex++)
{
var waveStarts = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowsPerWave),
concurrency,
async index =>
{
var started = DateTime.UtcNow;
var response = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "MongoPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 996500L + (waveIndex * 1000L) + index,
},
}));
return (Index: index, Response: response, StartedAtUtc: started);
});
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
waveStarts,
concurrency,
async waveStart =>
{
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = waveStart.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 886500L + (waveIndex * 1000L) + waveStart.Index,
},
}));
return true;
});
totalProcessedSignals += await MongoPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(
provider,
TimeSpan.FromSeconds(45),
workerCount);
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
waveStarts,
workerCount,
async waveStart =>
{
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = waveStart.Response.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
endToEndLatencies.Add(DateTime.UtcNow - waveStart.StartedAtUtc);
return true;
});
totalCompletedInstances += waveStarts.Count;
}
return (ProcessedSignals: totalProcessedSignals, CompletedInstances: totalCompletedInstances);
});
var completedAtUtc = DateTime.UtcNow;
var operationCount = waveCount * workflowsPerWave;
soakResult.CompletedInstances.Should().Be(operationCount);
soakResult.ProcessedSignals.Should().Be(operationCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "mongo-signal-roundtrip-soak",
Tier = WorkflowPerformanceCategories.Soak,
EnvironmentName = "mongo-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = operationCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = operationCount,
SignalsPublished = operationCount,
SignalsProcessed = soakResult.ProcessedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies)!,
BackendMetrics = mongoMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "MongoPerfSignalRoundTripWorkflow",
["waveCount"] = waveCount.ToString(),
["workflowsPerWave"] = workflowsPerWave.ToString(),
["workerCount"] = workerCount.ToString(),
},
});
}
}

View File

@@ -0,0 +1,413 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Helpers;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Signaling.Redis;
using StellaOps.Workflow.Engine.HostedServices;
using StellaOps.Workflow.DataStore.MongoDB;
using StellaOps.Workflow.Engine.Services;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using BulstradWorkflowRegistrator = StellaOps.Workflow.Engine.Workflows.Bulstrad.ServiceRegistrator;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests.Performance;
internal static class MongoPerformanceTestSupport
{
public static WorkflowStoreMongoOptions CreateOptions(string databaseName)
{
return new WorkflowStoreMongoOptions
{
ConnectionStringName = "WorkflowMongo",
DatabaseName = databaseName,
BlockingWaitSeconds = 1,
};
}
public static IConfiguration CreateConfiguration(
WorkflowStoreMongoOptions options,
string connectionString,
bool includeAssignmentRoles = false,
string signalDriverProvider = WorkflowSignalDriverNames.Native,
string? redisConnectionString = null,
string? redisChannelName = null)
{
var values = new Dictionary<string, string?>
{
["WorkflowBackend:Provider"] = WorkflowBackendNames.Mongo,
["WorkflowSignalDriver:Provider"] = signalDriverProvider,
[$"{WorkflowStoreMongoOptions.SectionName}:ConnectionStringName"] = options.ConnectionStringName,
[$"{WorkflowStoreMongoOptions.SectionName}:DatabaseName"] = options.DatabaseName,
[$"{WorkflowStoreMongoOptions.SectionName}:BlockingWaitSeconds"] = options.BlockingWaitSeconds.ToString(),
[$"{WorkflowStoreMongoOptions.SectionName}:ClaimTimeoutSeconds"] = options.ClaimTimeoutSeconds.ToString(),
[$"ConnectionStrings:{options.ConnectionStringName}"] = connectionString,
["WorkflowRuntime:DefaultProvider"] = WorkflowRuntimeProviderNames.Engine,
["WorkflowRuntime:EnabledProviders:0"] = WorkflowRuntimeProviderNames.Engine,
["WorkflowAq:ConsumerName"] = "workflow-service",
["WorkflowAq:MaxDeliveryAttempts"] = "3",
["WorkflowRetention:OpenStaleAfterDays"] = "30",
["WorkflowRetention:CompletedPurgeAfterDays"] = "180",
};
if (string.Equals(signalDriverProvider, WorkflowSignalDriverNames.Redis, StringComparison.OrdinalIgnoreCase))
{
values["RedisConfig:ServerUrl"] = redisConnectionString
?? throw new InvalidOperationException("Redis connection string is required when Redis signal driver is selected.");
values[$"{RedisWorkflowSignalDriverOptions.SectionName}:ChannelName"] =
redisChannelName ?? $"stella:test:workflow:perf:mongo:{options.DatabaseName}";
values[$"{RedisWorkflowSignalDriverOptions.SectionName}:BlockingWaitSeconds"] = "1";
}
if (includeAssignmentRoles)
{
values["GenericAssignmentPermissions:AdminRoles:0"] = "DBA";
values["GenericAssignmentPermissions:AdminRoles:1"] = "APR_APPL";
}
return new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
}
public static ServiceProvider CreateTransportProvider(IConfiguration configuration)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddWorkflowCoreServices(configuration);
services.AddWorkflowMongoDataStore(configuration);
if (string.Equals(configuration.GetWorkflowSignalDriverProvider(), WorkflowSignalDriverNames.Redis, StringComparison.OrdinalIgnoreCase))
{
services.AddWorkflowRedisSignaling(configuration);
}
services.AddWorkflowRegistration<MongoPerfExternalSignalWorkflow, MongoPerfStartRequest>();
services.AddWorkflowRegistration<MongoPerfSignalRoundTripWorkflow, MongoPerfStartRequest>();
var provider = services.BuildServiceProvider();
ServiceProviderAccessor.Initialize(provider);
return provider;
}
public static ServiceProvider CreateBulstradProvider(
IConfiguration configuration,
WorkflowTransportScripts transports)
{
var services = new ServiceCollection();
services.AddLogging();
new BulstradWorkflowRegistrator().RegisterServices(services, configuration);
services.AddWorkflowCoreServices(configuration);
services.AddWorkflowModule("transport.legacy-rabbit", "1.0.0");
services.AddWorkflowMongoDataStore(configuration);
if (string.Equals(configuration.GetWorkflowSignalDriverProvider(), WorkflowSignalDriverNames.Redis, StringComparison.OrdinalIgnoreCase))
{
services.AddWorkflowRedisSignaling(configuration);
}
services.Replace(ServiceDescriptor.Scoped<IWorkflowLegacyRabbitTransport>(_ => transports.LegacyRabbit));
services.Replace(ServiceDescriptor.Scoped<IWorkflowMicroserviceTransport>(_ => transports.Microservice));
services.Replace(ServiceDescriptor.Scoped<IWorkflowGraphqlTransport>(_ => transports.Graphql));
services.Replace(ServiceDescriptor.Scoped<IWorkflowHttpTransport>(_ => transports.Http));
var provider = services.BuildServiceProvider();
ServiceProviderAccessor.Initialize(provider);
return provider;
}
public static async Task<int> DrainSignalsUntilIdleAsync(
IServiceProvider provider,
TimeSpan timeout,
string consumerName = "workflow-service")
{
var telemetry = await DrainSignalsWithWorkersUntilIdleDetailedAsync(provider, timeout, workerCount: 1, consumerNamePrefix: consumerName);
return telemetry.ProcessedCount;
}
public static async Task<int> DrainSignalsWithWorkersUntilIdleAsync(
IServiceProvider provider,
TimeSpan timeout,
int workerCount,
string consumerNamePrefix = "workflow-service")
{
var telemetry = await DrainSignalsWithWorkersUntilIdleDetailedAsync(provider, timeout, workerCount, consumerNamePrefix);
return telemetry.ProcessedCount;
}
public static async Task<WorkflowSignalDrainTelemetry> DrainSignalsWithWorkersUntilIdleDetailedAsync(
IServiceProvider provider,
TimeSpan timeout,
int workerCount,
string consumerNamePrefix = "workflow-service")
{
ArgumentOutOfRangeException.ThrowIfLessThan(workerCount, 1);
using var scope = provider.CreateScope();
var worker = scope.ServiceProvider.GetRequiredService<WorkflowSignalPumpWorker>();
var timeoutAt = DateTime.UtcNow.Add(timeout);
var startedAtUtc = DateTime.UtcNow;
var processedCount = 0;
var consecutiveEmptyRounds = 0;
var totalRounds = 0;
DateTime? firstProcessedAtUtc = null;
DateTime? lastProcessedAtUtc = null;
while (DateTime.UtcNow < timeoutAt && consecutiveEmptyRounds < 3)
{
totalRounds++;
var workerNames = Enumerable
.Range(0, workerCount)
.Select(index => workerCount == 1
? consumerNamePrefix
: $"{consumerNamePrefix}-{index + 1}")
.ToArray();
var roundResults = await Task.WhenAll(workerNames.Select(workerName => worker.RunOnceAsync(workerName, CancellationToken.None)));
var roundProcessedCount = roundResults.Count(result => result);
if (roundProcessedCount > 0)
{
var processedAtUtc = DateTime.UtcNow;
processedCount += roundProcessedCount;
consecutiveEmptyRounds = 0;
firstProcessedAtUtc ??= processedAtUtc;
lastProcessedAtUtc = processedAtUtc;
continue;
}
consecutiveEmptyRounds++;
}
return new WorkflowSignalDrainTelemetry
{
ProcessedCount = processedCount,
TotalRounds = totalRounds,
IdleEmptyRounds = consecutiveEmptyRounds,
StartedAtUtc = startedAtUtc,
CompletedAtUtc = DateTime.UtcNow,
FirstProcessedAtUtc = firstProcessedAtUtc,
LastProcessedAtUtc = lastProcessedAtUtc,
};
}
public static async Task<(T Result, MongoPerformanceDelta Metrics)> MeasureWithMongoMetricsAsync<T>(
string connectionString,
string databaseName,
Func<Task<T>> action,
CancellationToken cancellationToken = default)
{
var before = await MongoPerformanceMetricsCollector.CaptureAsync(connectionString, databaseName, cancellationToken);
var result = await action();
var after = await MongoPerformanceMetricsCollector.CaptureAsync(connectionString, databaseName, cancellationToken);
return (result, MongoPerformanceDelta.Create(before, after));
}
public static async Task<MongoTransportBurstResult> RunImmediateTransportBurstAsync(
IServiceProvider provider,
int messageCount,
TimeSpan timeout,
string correlationPrefix)
{
using var scope = provider.CreateScope();
var signalBus = scope.ServiceProvider.GetRequiredService<IWorkflowSignalBus>();
return await RunTransportBurstAsync(signalBus, scheduleBus: null, messageCount, delaySeconds: 0, timeout, correlationPrefix);
}
public static async Task<MongoTransportBurstResult> RunDelayedTransportBurstAsync(
IServiceProvider provider,
int messageCount,
int delaySeconds,
TimeSpan timeout,
string correlationPrefix)
{
using var scope = provider.CreateScope();
var signalBus = scope.ServiceProvider.GetRequiredService<IWorkflowSignalBus>();
var scheduleBus = scope.ServiceProvider.GetRequiredService<IWorkflowScheduleBus>();
return await RunTransportBurstAsync(signalBus, scheduleBus, messageCount, delaySeconds, timeout, correlationPrefix);
}
private static async Task<MongoTransportBurstResult> RunTransportBurstAsync(
IWorkflowSignalBus signalBus,
IWorkflowScheduleBus? scheduleBus,
int messageCount,
int delaySeconds,
TimeSpan timeout,
string correlationPrefix)
{
var publishedAt = new Dictionary<string, DateTime>(StringComparer.Ordinal);
for (var index = 0; index < messageCount; index++)
{
var correlation = $"{correlationPrefix}-{index}-{Guid.NewGuid():N}";
publishedAt[correlation] = DateTime.UtcNow;
var envelope = CreateTransportEnvelope(correlation, delaySeconds);
if (delaySeconds <= 0)
{
await signalBus.PublishAsync(envelope);
continue;
}
await scheduleBus!.ScheduleAsync(envelope, envelope.DueAtUtc!.Value);
}
var receiveLatencies = new List<TimeSpan>(messageCount);
var processedCorrelations = new HashSet<string>(StringComparer.Ordinal);
var timeoutAt = DateTime.UtcNow.Add(timeout);
while (processedCorrelations.Count < messageCount && DateTime.UtcNow < timeoutAt)
{
await using var lease = await signalBus.ReceiveAsync("mongo-perf-transport", CancellationToken.None);
if (lease is null)
{
continue;
}
if (publishedAt.TryGetValue(lease.Envelope.SignalId, out var publishedMoment))
{
processedCorrelations.Add(lease.Envelope.SignalId);
receiveLatencies.Add(DateTime.UtcNow - publishedMoment);
}
await lease.CompleteAsync(CancellationToken.None);
}
return new MongoTransportBurstResult
{
ReceiveLatencies = receiveLatencies,
ProcessedCorrelations = processedCorrelations,
};
}
private static WorkflowSignalEnvelope CreateTransportEnvelope(string correlation, int delaySeconds)
{
return new WorkflowSignalEnvelope
{
SignalId = correlation,
WorkflowInstanceId = $"wf-{correlation}",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.ExternalSignal,
ExpectedVersion = 1,
WaitingToken = "perf-transport",
DueAtUtc = delaySeconds > 0 ? DateTime.UtcNow.AddSeconds(delaySeconds) : null,
Payload = new Dictionary<string, JsonElement>
{
["name"] = JsonSerializer.SerializeToElement("documents-uploaded"),
},
};
}
public static WorkflowTransportScripts CreateQuoteOrAplCancelSuccessTransports()
{
var transports = new WorkflowTransportScripts();
transports.LegacyRabbit
.Respond("bst_blanknumbersrelease", new { released = true })
.Respond("pas_annexprocessing_cancelaplorqt", new { cancelled = true });
return transports;
}
public static WorkflowTransportScripts CreateQuotationConfirmConvertToPolicyTransports()
{
var transports = new WorkflowTransportScripts();
transports.LegacyRabbit
.Respond("pas_polannexes_get", new
{
shortDescription = "Quote for policy conversion",
})
.Respond("pas_polreg_checkuwrules", new
{
nextStep = "ConvertToPolicy",
})
.Respond("pas_polreg_convertqttopoldefault", new
{
converted = true,
})
.Respond("bst_integration_printpolicydocuments", new
{
printed = true,
});
return transports;
}
private sealed record MongoPerfStartRequest
{
[WorkflowBusinessId]
[WorkflowBusinessReferencePart("policyId")]
public long SrPolicyId { get; init; }
}
private sealed class MongoPerfExternalSignalWorkflow : IDeclarativeWorkflow<MongoPerfStartRequest>
{
public const string WorkflowNameValue = "MongoPerfExternalSignalWorkflow";
public string WorkflowName => WorkflowNameValue;
public string WorkflowVersion => "1.0.0";
public string DisplayName => "MongoDB Performance External Signal Workflow";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<MongoPerfStartRequest> Spec { get; } = WorkflowSpec.For<MongoPerfStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")),
WorkflowExpr.Prop("documentId", WorkflowExpr.Number(0L))))
.AddTask(
WorkflowHumanTask.For<MongoPerfStartRequest>(
"Mongo Perf Review",
"MongoPerfReview",
"business/policies",
["DBA"])
.WithPayload(
WorkflowExpr.Obj(
WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase")),
WorkflowExpr.Prop("documentId", WorkflowExpr.Path("state.documentId"))))
.OnComplete(flow => flow.Complete()))
.StartWith(flow => flow
.Set("phase", "waiting-external")
.WaitForSignal("Wait For Perf Upload", WorkflowExpr.String("documents-uploaded"), resultKey: "uploadSignal")
.Set("documentId", WorkflowExpr.Path("result.uploadSignal.documentId"))
.Set("phase", "after-external")
.ActivateTask("Mongo Perf Review"))
.Build();
}
private sealed class MongoPerfSignalRoundTripWorkflow : IDeclarativeWorkflow<MongoPerfStartRequest>
{
public const string WorkflowNameValue = "MongoPerfSignalRoundTripWorkflow";
public string WorkflowName => WorkflowNameValue;
public string WorkflowVersion => "1.0.0";
public string DisplayName => "MongoDB Performance Signal Round Trip Workflow";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<MongoPerfStartRequest> Spec { get; } = WorkflowSpec.For<MongoPerfStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")),
WorkflowExpr.Prop("documentId", WorkflowExpr.Number(0L))))
.StartWith(flow => flow
.Set("phase", "waiting-external")
.WaitForSignal("Wait For Perf Upload Completion", WorkflowExpr.String("documents-uploaded"), resultKey: "uploadSignal")
.Set("documentId", WorkflowExpr.Path("result.uploadSignal.documentId"))
.Set("phase", "completed")
.Complete())
.Build();
}
public sealed record MongoTransportBurstResult
{
public required IReadOnlyList<TimeSpan> ReceiveLatencies { get; init; }
public required IReadOnlyCollection<string> ProcessedCorrelations { get; init; }
}
}

View File

@@ -0,0 +1,199 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.MongoDB;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests.Performance;
[TestFixture]
[NonParallelizable]
[Category("Performance")]
[Category(WorkflowPerformanceCategories.Throughput)]
public class MongoPerformanceThroughputTests
{
private MongoDockerFixture? fixture;
private WorkflowStoreMongoOptions? options;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new MongoDockerFixture();
await fixture.StartOrIgnoreAsync();
options = MongoPerformanceTestSupport.CreateOptions("workflow_perf_throughput");
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetDatabaseAsync(options!.DatabaseName);
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task MongoEnginePerfThroughput_WhenSignalRoundTripRunsWithParallelWorkers_ShouldWriteArtifacts()
{
const int workflowCount = 96;
const int operationConcurrency = 16;
const int workerCount = 8;
var databaseName = options!.DatabaseName;
var configuration = MongoPerformanceTestSupport.CreateConfiguration(options, fixture!.ConnectionString);
using var provider = MongoPerformanceTestSupport.CreateTransportProvider(configuration);
var warmupResponse = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "MongoPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 998999L,
},
}));
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = warmupResponse.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 868999L,
},
}));
await MongoPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(provider, TimeSpan.FromSeconds(20), workerCount: 2);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var startLatencies = new ConcurrentBag<TimeSpan>();
var signalPublishLatencies = new ConcurrentBag<TimeSpan>();
var signalToCompletionLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, mongoMetrics) = await MongoPerformanceTestSupport.MeasureWithMongoMetricsAsync(
fixture.ConnectionString,
databaseName,
async () =>
{
var startResponses = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
operationConcurrency,
async index =>
{
var operationStartedAtUtc = DateTime.UtcNow;
var startMeasureStartedAtUtc = operationStartedAtUtc;
var response = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "MongoPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 998000L + index,
},
}));
startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc);
return (Index: index, Response: response, StartedAtUtc: operationStartedAtUtc);
});
var signalRaisedAtUtc = new ConcurrentDictionary<string, DateTime>(StringComparer.Ordinal);
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
startResponses,
operationConcurrency,
async startResponse =>
{
var publishStartedAtUtc = DateTime.UtcNow;
signalRaisedAtUtc[startResponse.Response.WorkflowInstanceId] = publishStartedAtUtc;
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 868000L + startResponse.Index,
},
}));
signalPublishLatencies.Add(DateTime.UtcNow - publishStartedAtUtc);
return true;
});
var totalProcessedSignals = await MongoPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(
provider,
TimeSpan.FromSeconds(60),
workerCount);
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
startResponses,
operationConcurrency,
async startResponse =>
{
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
var completedAtUtc = DateTime.UtcNow;
endToEndLatencies.Add(completedAtUtc - startResponse.StartedAtUtc);
signalToCompletionLatencies.Add(completedAtUtc - signalRaisedAtUtc[startResponse.Response.WorkflowInstanceId]);
return true;
});
return totalProcessedSignals;
});
var completedAtUtc = DateTime.UtcNow;
processedSignals.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "mongo-signal-roundtrip-throughput-parallel",
Tier = WorkflowPerformanceCategories.Throughput,
EnvironmentName = "mongo-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = operationConcurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies)!,
PhaseLatencySummaries = new Dictionary<string, WorkflowPerformanceLatencySummary>
{
["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!,
["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!,
["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!,
},
BackendMetrics = mongoMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "MongoPerfSignalRoundTripWorkflow",
["workerCount"] = workerCount.ToString(),
["measurementKind"] = "steady-throughput",
},
});
}
}

View File

@@ -0,0 +1,340 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using StellaOps.Workflow.Signaling.Redis.Tests;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.MongoDB;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests.Performance;
[TestFixture]
[NonParallelizable]
[Category("Performance")]
public class MongoRedisSignalDriverPerformanceTests
{
private MongoDockerFixture? mongoFixture;
private RedisDockerFixture? redisFixture;
private WorkflowStoreMongoOptions? options;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
mongoFixture = new MongoDockerFixture();
await mongoFixture.StartOrIgnoreAsync();
redisFixture = new RedisDockerFixture();
await redisFixture.StartOrIgnoreAsync();
options = MongoPerformanceTestSupport.CreateOptions("workflow_perf_redis_driver");
}
[SetUp]
public async Task SetUpAsync()
{
await mongoFixture!.ResetDatabaseAsync(options!.DatabaseName);
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
redisFixture?.Dispose();
mongoFixture?.Dispose();
}
[Test]
[Category(WorkflowPerformanceCategories.Latency)]
public async Task MongoEnginePerfLatency_WhenRedisWakeDriverRunsSerially_ShouldWriteArtifacts()
{
const int workflowCount = 16;
var databaseName = options!.DatabaseName;
var configuration = MongoPerformanceTestSupport.CreateConfiguration(
options,
mongoFixture!.ConnectionString,
signalDriverProvider: WorkflowSignalDriverNames.Redis,
redisConnectionString: redisFixture!.ConnectionString,
redisChannelName: $"stella:test:workflow:perf:mongo:latency:{Guid.NewGuid():N}");
using var provider = MongoPerformanceTestSupport.CreateTransportProvider(configuration);
await using var hostedServices = await WorkflowEnginePerformanceSupport.StartHostedServicesAsync(provider);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var startLatencies = new ConcurrentBag<TimeSpan>();
var signalPublishLatencies = new ConcurrentBag<TimeSpan>();
var signalToCompletionLatencies = new ConcurrentBag<TimeSpan>();
var signalToFirstCompletionLatencies = new ConcurrentBag<TimeSpan>();
var drainToIdleOverhangLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, mongoMetrics) = await MongoPerformanceTestSupport.MeasureWithMongoMetricsAsync(
mongoFixture.ConnectionString,
databaseName,
async () =>
{
var totalProcessedSignals = 0;
for (var index = 0; index < workflowCount; index++)
{
var workflowStartedAtUtc = DateTime.UtcNow;
var startStartedAtUtc = DateTime.UtcNow;
var startResponse = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "MongoPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 999000L + index,
},
}));
startLatencies.Add(DateTime.UtcNow - startStartedAtUtc);
var signalPublishedAtUtc = DateTime.UtcNow;
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 889000L + index,
},
}));
signalPublishLatencies.Add(DateTime.UtcNow - signalPublishedAtUtc);
var drain = await MongoPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleDetailedAsync(
provider,
TimeSpan.FromSeconds(20),
workerCount: 1);
totalProcessedSignals += drain.ProcessedCount;
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
drain.FirstProcessedAtUtc.Should().NotBeNull();
drain.LastProcessedAtUtc.Should().NotBeNull();
endToEndLatencies.Add(drain.CompletedAtUtc - workflowStartedAtUtc);
signalToFirstCompletionLatencies.Add(drain.FirstProcessedAtUtc!.Value - signalPublishedAtUtc);
drainToIdleOverhangLatencies.Add(drain.CompletedAtUtc - drain.LastProcessedAtUtc!.Value);
signalToCompletionLatencies.Add(drain.CompletedAtUtc - signalPublishedAtUtc);
}
return totalProcessedSignals;
});
var completedAtUtc = DateTime.UtcNow;
processedSignals.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "mongo-redis-signal-roundtrip-latency-serial",
Tier = WorkflowPerformanceCategories.Latency,
EnvironmentName = "mongo-docker+redis-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies)!,
PhaseLatencySummaries = new Dictionary<string, WorkflowPerformanceLatencySummary>
{
["drainToIdleOverhang"] = WorkflowPerformanceLatencySummary.FromSamples(drainToIdleOverhangLatencies)!,
["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!,
["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!,
["signalToFirstCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToFirstCompletionLatencies)!,
["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!,
},
BackendMetrics = mongoMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "MongoPerfSignalRoundTripWorkflow",
["workerCount"] = "1",
["measurementKind"] = "serial-latency",
["signalDriver"] = WorkflowSignalDriverNames.Redis,
},
});
}
[Test]
[Category(WorkflowPerformanceCategories.Throughput)]
public async Task MongoEnginePerfThroughput_WhenRedisWakeDriverRunsWithParallelWorkers_ShouldWriteArtifacts()
{
const int workflowCount = 96;
const int operationConcurrency = 16;
const int workerCount = 8;
var databaseName = options!.DatabaseName;
var configuration = MongoPerformanceTestSupport.CreateConfiguration(
options,
mongoFixture!.ConnectionString,
signalDriverProvider: WorkflowSignalDriverNames.Redis,
redisConnectionString: redisFixture!.ConnectionString,
redisChannelName: $"stella:test:workflow:perf:mongo:throughput:{Guid.NewGuid():N}");
using var provider = MongoPerformanceTestSupport.CreateTransportProvider(configuration);
await using var hostedServices = await WorkflowEnginePerformanceSupport.StartHostedServicesAsync(provider);
var warmupResponse = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "MongoPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 999999L,
},
}));
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = warmupResponse.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 889999L,
},
}));
await MongoPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(provider, TimeSpan.FromSeconds(20), workerCount: 2);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var startLatencies = new ConcurrentBag<TimeSpan>();
var signalPublishLatencies = new ConcurrentBag<TimeSpan>();
var signalToCompletionLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, mongoMetrics) = await MongoPerformanceTestSupport.MeasureWithMongoMetricsAsync(
mongoFixture.ConnectionString,
databaseName,
async () =>
{
var startResponses = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
operationConcurrency,
async index =>
{
var operationStartedAtUtc = DateTime.UtcNow;
var startMeasureStartedAtUtc = operationStartedAtUtc;
var response = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "MongoPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 999100L + index,
},
}));
startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc);
return (Index: index, Response: response, StartedAtUtc: operationStartedAtUtc);
});
var signalRaisedAtUtc = new ConcurrentDictionary<string, DateTime>(StringComparer.Ordinal);
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
startResponses,
operationConcurrency,
async startResponse =>
{
var publishStartedAtUtc = DateTime.UtcNow;
signalRaisedAtUtc[startResponse.Response.WorkflowInstanceId] = publishStartedAtUtc;
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 889100L + startResponse.Index,
},
}));
signalPublishLatencies.Add(DateTime.UtcNow - publishStartedAtUtc);
return true;
});
var totalProcessedSignals = await MongoPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(
provider,
TimeSpan.FromSeconds(60),
workerCount);
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
startResponses,
operationConcurrency,
async startResponse =>
{
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
var completedAtUtc = DateTime.UtcNow;
endToEndLatencies.Add(completedAtUtc - startResponse.StartedAtUtc);
signalToCompletionLatencies.Add(completedAtUtc - signalRaisedAtUtc[startResponse.Response.WorkflowInstanceId]);
return true;
});
return totalProcessedSignals;
});
var completedAtUtc = DateTime.UtcNow;
processedSignals.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "mongo-redis-signal-roundtrip-throughput-parallel",
Tier = WorkflowPerformanceCategories.Throughput,
EnvironmentName = "mongo-docker+redis-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = operationConcurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies)!,
PhaseLatencySummaries = new Dictionary<string, WorkflowPerformanceLatencySummary>
{
["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!,
["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!,
["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!,
},
BackendMetrics = mongoMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "MongoPerfSignalRoundTripWorkflow",
["workerCount"] = workerCount.ToString(),
["measurementKind"] = "steady-throughput",
["signalDriver"] = WorkflowSignalDriverNames.Redis,
},
});
}
}

View File

@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
namespace StellaOps.Workflow.DataStore.MongoDB.Tests.Performance;
internal static class MongoWorkflowPerformanceMetricsExtensions
{
public static WorkflowPerformanceBackendMetrics ToBackendMetrics(this MongoPerformanceDelta delta)
{
ArgumentNullException.ThrowIfNull(delta);
return new WorkflowPerformanceBackendMetrics
{
BackendName = "MongoDB",
InstanceName = delta.DatabaseName,
HostName = delta.HostName,
Version = delta.ServerVersion,
CounterDeltas = new Dictionary<string, long>(delta.CounterDeltas, StringComparer.OrdinalIgnoreCase),
DurationDeltas = new Dictionary<string, long>(delta.DurationDeltas, StringComparer.OrdinalIgnoreCase),
Metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["connectionsCurrent"] = delta.ConnectionsCurrent.ToString(CultureInfo.InvariantCulture),
["connectionsAvailable"] = delta.ConnectionsAvailable.ToString(CultureInfo.InvariantCulture),
["queueReaders"] = delta.QueueReaders.ToString(CultureInfo.InvariantCulture),
["queueWriters"] = delta.QueueWriters.ToString(CultureInfo.InvariantCulture),
["queueTotal"] = delta.QueueTotal.ToString(CultureInfo.InvariantCulture),
},
TopWaitDeltas = delta.TopOperations,
};
}
}

View File

@@ -0,0 +1,51 @@
<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="MongoDB.Driver" Version="3.4.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<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" />
</ItemGroup>
<!-- TODO: These files reference Serdica/Bulstrad-specific workflow types (Engine.Helpers, Engine.Workflows.Bulstrad,
WorkflowTransportScripts) that are not in this repository. Re-enable when those types are ported to Stella. -->
<ItemGroup>
<Compile Remove="MongoBulstradWorkflowIntegrationTests.cs" />
<Compile Remove="Performance\MongoPerformanceTestSupport.cs" />
<Compile Remove="Performance\MongoPerformanceCapacityTests.cs" />
<Compile Remove="Performance\MongoPerformanceLatencyTests.cs" />
<Compile Remove="Performance\MongoPerformanceMetricsCollector.cs" />
<Compile Remove="Performance\MongoPerformanceNightlyTests.cs" />
<Compile Remove="Performance\MongoPerformanceSmokeTests.cs" />
<Compile Remove="Performance\MongoPerformanceSoakTests.cs" />
<Compile Remove="Performance\MongoPerformanceThroughputTests.cs" />
<Compile Remove="Performance\MongoRedisSignalDriverPerformanceTests.cs" />
<Compile Remove="Performance\MongoWorkflowPerformanceMetricsExtensions.cs" />
</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.DataStore.MongoDB\StellaOps.Workflow.DataStore.MongoDB.csproj" />
<ProjectReference Include="..\StellaOps.Workflow.IntegrationTests.Shared\StellaOps.Workflow.IntegrationTests.Shared.csproj" />
<ProjectReference Include="..\StellaOps.Workflow.Signaling.Redis.Tests\StellaOps.Workflow.Signaling.Redis.Tests.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,51 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Workflow.DataStore.Oracle.Tests;
internal static class OracleAqIntegrationLifetime
{
private static readonly SemaphoreSlim Sync = new(1, 1);
private static OracleDockerFixture? fixture;
public static OracleDockerFixture Fixture =>
fixture ?? throw new global::System.InvalidOperationException("The Oracle AQ fixture has not been started.");
public static async Task EnsureStartedAsync()
{
if (fixture is not null)
{
return;
}
await Sync.WaitAsync();
try
{
if (fixture is not null)
{
return;
}
fixture = new OracleDockerFixture();
await fixture.StartOrIgnoreAsync();
}
finally
{
Sync.Release();
}
}
public static async Task DisposeAsync()
{
await Sync.WaitAsync();
try
{
fixture?.Dispose();
fixture = null;
}
finally
{
Sync.Release();
}
}
}

View File

@@ -0,0 +1,21 @@
using System.Threading.Tasks;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.Oracle.Tests;
[SetUpFixture]
public sealed class OracleAqIntegrationSuiteFixture
{
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
await OracleAqIntegrationLifetime.EnsureStartedAsync();
}
[OneTimeTearDown]
public async Task OneTimeTearDownAsync()
{
await OracleAqIntegrationLifetime.DisposeAsync();
}
}

View File

@@ -0,0 +1,156 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.Oracle.Tests.Performance;
[TestFixture]
[Category("Integration")]
[NonParallelizable]
[Category(WorkflowPerformanceCategories.Capacity)]
public class OracleAqPerformanceCapacityTests
{
[Test]
public async Task OracleAqEnginePerfCapacity_WhenSyntheticSignalRoundTripRunsAcrossConcurrencyLadder_ShouldWriteArtifacts()
{
var concurrencyLadder = new[] { 1, 4, 8, 16 };
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
using var provider = OracleAqPerformanceTestSupport.CreateTransportProvider(queueSet);
var warmupStart = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "OracleAqPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 993999L,
},
}));
await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = warmupStart.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 869998L,
},
}));
await OracleAqPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(provider, TimeSpan.FromSeconds(20), workerCount: 2);
foreach (var concurrency in concurrencyLadder)
{
var workflowCount = concurrency * 16;
var workerCount = Math.Min(concurrency, 8);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var (capacityResult, oracleMetrics) = await OracleAqPerformanceTestSupport.MeasureWithOracleMetricsAsync(async () =>
{
var startResponses = await OracleAqPerformanceTestSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
concurrency,
async index =>
{
var started = DateTime.UtcNow;
var response = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "OracleAqPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 994000L + (concurrency * 1000L) + index,
},
}));
return (Index: index, Response: response, StartedAtUtc: started);
});
await OracleAqPerformanceTestSupport.RunConcurrentAsync(
startResponses,
concurrency,
async startResponse =>
{
await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 870000L + (concurrency * 1000L) + startResponse.Index,
},
}));
return true;
});
var processedSignals = await OracleAqPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(
provider,
TimeSpan.FromSeconds(90),
workerCount);
await OracleAqPerformanceTestSupport.RunConcurrentAsync(
startResponses,
workerCount,
async startResponse =>
{
var instance = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
endToEndLatencies.Add(DateTime.UtcNow - startResponse.StartedAtUtc);
return true;
});
return processedSignals;
});
var completedAtUtc = DateTime.UtcNow;
capacityResult.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = $"oracle-aq-signal-roundtrip-capacity-c{concurrency}",
Tier = WorkflowPerformanceCategories.Capacity,
EnvironmentName = "oracle-aq-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = capacityResult,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
BackendMetrics = oracleMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "OracleAqPerfSignalRoundTripWorkflow",
["queueName"] = queueSet.SignalQueueName,
["ladder"] = string.Join(",", concurrencyLadder),
["workerCount"] = workerCount.ToString(),
},
});
}
}
}

View File

@@ -0,0 +1,138 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.Oracle.Tests.Performance;
[TestFixture]
[Category("Integration")]
[NonParallelizable]
[Category(WorkflowPerformanceCategories.Latency)]
public class OracleAqPerformanceLatencyTests
{
[Test]
public async Task OracleAqEnginePerfLatency_WhenSignalRoundTripRunsSerially_ShouldWriteArtifacts()
{
const int workflowCount = 16;
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
using var provider = OracleAqPerformanceTestSupport.CreateTransportProvider(queueSet);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var startLatencies = new ConcurrentBag<TimeSpan>();
var signalPublishLatencies = new ConcurrentBag<TimeSpan>();
var signalToCompletionLatencies = new ConcurrentBag<TimeSpan>();
var signalToFirstCompletionLatencies = new ConcurrentBag<TimeSpan>();
var drainToIdleOverhangLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, oracleMetrics) = await OracleAqPerformanceTestSupport.MeasureWithOracleMetricsAsync(async () =>
{
var totalProcessedSignals = 0;
for (var index = 0; index < workflowCount; index++)
{
var operationStartedAtUtc = DateTime.UtcNow;
var startMeasureStartedAtUtc = DateTime.UtcNow;
var startResponse = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "OracleAqPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 992000L + index,
},
}));
startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc);
var signalRaisedAtUtc = DateTime.UtcNow;
var signalPublishStartedAtUtc = signalRaisedAtUtc;
await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 862000L + index,
},
}));
signalPublishLatencies.Add(DateTime.UtcNow - signalPublishStartedAtUtc);
var drain = await OracleAqPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleDetailedAsync(
provider,
TimeSpan.FromSeconds(30),
workerCount: 1);
totalProcessedSignals += drain.ProcessedCount;
var instance = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
drain.FirstProcessedAtUtc.Should().NotBeNull();
drain.LastProcessedAtUtc.Should().NotBeNull();
signalToFirstCompletionLatencies.Add(drain.FirstProcessedAtUtc!.Value - signalRaisedAtUtc);
drainToIdleOverhangLatencies.Add(drain.CompletedAtUtc - drain.LastProcessedAtUtc!.Value);
signalToCompletionLatencies.Add(drain.CompletedAtUtc - signalRaisedAtUtc);
endToEndLatencies.Add(drain.CompletedAtUtc - operationStartedAtUtc);
}
return totalProcessedSignals;
});
var completedAtUtc = DateTime.UtcNow;
processedSignals.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "oracle-aq-signal-roundtrip-latency-serial",
Tier = WorkflowPerformanceCategories.Latency,
EnvironmentName = "oracle-aq-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
PhaseLatencySummaries = new Dictionary<string, WorkflowPerformanceLatencySummary>
{
["drainToIdleOverhang"] = WorkflowPerformanceLatencySummary.FromSamples(drainToIdleOverhangLatencies)!,
["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!,
["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!,
["signalToFirstCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToFirstCompletionLatencies)!,
["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!,
},
BackendMetrics = oracleMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "OracleAqPerfSignalRoundTripWorkflow",
["queueName"] = queueSet.SignalQueueName,
["workerCount"] = "1",
["measurementKind"] = "serial-latency",
},
});
}
}

View File

@@ -0,0 +1,366 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.Oracle.Tests.Performance;
[TestFixture]
[Category("Integration")]
[NonParallelizable]
[Category(WorkflowPerformanceCategories.Nightly)]
public class OracleAqPerformanceNightlyTests
{
[Test]
public async Task OracleAqTransportPerfNightly_WhenImmediateBurstQueuedAtScale_ShouldDrainAndWriteArtifacts()
{
const int messageCount = 120;
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
using var provider = OracleAqPerformanceTestSupport.CreateTransportProvider(queueSet);
var startedAtUtc = DateTime.UtcNow;
var (transportResult, oracleMetrics) = await OracleAqPerformanceTestSupport.MeasureWithOracleMetricsAsync(
() => OracleAqPerformanceTestSupport.RunImmediateTransportBurstAsync(
provider,
queueSet.SignalQueueName,
queueSet.DeadLetterQueueName,
messageCount,
timeout: TimeSpan.FromSeconds(45),
correlationPrefix: "perf-nightly-immediate"));
var completedAtUtc = DateTime.UtcNow;
transportResult.ProcessedCorrelations.Should().HaveCount(messageCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "oracle-aq-immediate-burst-nightly",
Tier = WorkflowPerformanceCategories.Nightly,
EnvironmentName = "oracle-aq-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = messageCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
SignalsPublished = messageCount,
SignalsProcessed = transportResult.ProcessedCorrelations.Count,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(transportResult.ReceiveLatencies),
BackendMetrics = oracleMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["queueName"] = queueSet.SignalQueueName,
["messageCount"] = messageCount.ToString(),
},
});
}
[Test]
public async Task OracleAqTransportPerfNightly_WhenDelayedBurstQueuedAtScale_ShouldDrainAndWriteArtifacts()
{
const int messageCount = 48;
const int delaySeconds = 2;
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
using var provider = OracleAqPerformanceTestSupport.CreateTransportProvider(queueSet);
var startedAtUtc = DateTime.UtcNow;
var (transportResult, oracleMetrics) = await OracleAqPerformanceTestSupport.MeasureWithOracleMetricsAsync(
() => OracleAqPerformanceTestSupport.RunDelayedTransportBurstAsync(
provider,
queueSet.SignalQueueName,
queueSet.DeadLetterQueueName,
messageCount,
delaySeconds,
timeout: TimeSpan.FromSeconds(60),
correlationPrefix: "perf-nightly-delayed"));
var completedAtUtc = DateTime.UtcNow;
transportResult.ProcessedCorrelations.Should().HaveCount(messageCount);
transportResult.ReceiveLatencies.Should().OnlyContain(latency => latency > TimeSpan.FromSeconds(1));
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "oracle-aq-delayed-burst-nightly",
Tier = WorkflowPerformanceCategories.Nightly,
EnvironmentName = "oracle-aq-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = messageCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
SignalsPublished = messageCount,
SignalsProcessed = transportResult.ProcessedCorrelations.Count,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(transportResult.ReceiveLatencies),
BackendMetrics = oracleMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["queueName"] = queueSet.SignalQueueName,
["messageCount"] = messageCount.ToString(),
["delaySeconds"] = delaySeconds.ToString(),
},
});
}
[Test]
public async Task OracleAqEnginePerfNightly_WhenSyntheticExternalSignalBacklogResumedAtScale_ShouldDrainAndWriteArtifacts()
{
const int workflowCount = 36;
const int concurrency = 8;
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
using var provider = OracleAqPerformanceTestSupport.CreateTransportProvider(queueSet);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var (engineResult, oracleMetrics) = await OracleAqPerformanceTestSupport.MeasureWithOracleMetricsAsync(async () =>
{
var startResponses = await OracleAqPerformanceTestSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
concurrency,
async index =>
{
var response = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "OracleAqPerfExternalSignalWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 999000L + index,
},
}));
return (Index: index, Response: response, StartedAtUtc: DateTime.UtcNow);
});
var raisedSignalsAt = new ConcurrentDictionary<string, DateTime>(StringComparer.Ordinal);
await OracleAqPerformanceTestSupport.RunConcurrentAsync(
startResponses,
concurrency,
async startResponse =>
{
raisedSignalsAt[startResponse.Response.WorkflowInstanceId] = DateTime.UtcNow;
await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 990000L + startResponse.Index,
},
}));
return true;
});
var processedSignals = await OracleAqPerformanceTestSupport.DrainSignalsUntilIdleAsync(provider, TimeSpan.FromSeconds(60));
foreach (var startResponse in startResponses)
{
var openTasks = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
Status = WorkflowTaskStatuses.Open,
}));
openTasks.Tasks.Should().ContainSingle();
endToEndLatencies.Add(DateTime.UtcNow - raisedSignalsAt[startResponse.Response.WorkflowInstanceId]);
}
return processedSignals;
});
var completedAtUtc = DateTime.UtcNow;
engineResult.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "oracle-aq-synthetic-external-resume-nightly",
Tier = WorkflowPerformanceCategories.Nightly,
EnvironmentName = "oracle-aq-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
TasksActivated = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = engineResult,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
BackendMetrics = oracleMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "OracleAqPerfExternalSignalWorkflow",
["queueName"] = queueSet.SignalQueueName,
},
});
}
[Test]
public async Task OracleAqBulstradPerfNightly_WhenQuotationConfirmConvertToPolicyBurstCompleted_ShouldWriteArtifacts()
{
const int workflowCount = 12;
const int concurrency = 4;
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
var transports = CreateQuotationConfirmConvertToPolicyTransports();
using var provider = OracleAqPerformanceTestSupport.CreateBulstradProvider(queueSet, transports);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var (bulstradResult, oracleMetrics) = await OracleAqPerformanceTestSupport.MeasureWithOracleMetricsAsync(async () =>
{
var startResponses = await OracleAqPerformanceTestSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
concurrency,
async index =>
{
var started = DateTime.UtcNow;
var response = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "QuotationConfirm",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 996500L + index,
["srAnnexId"] = 886500L + index,
["srCustId"] = 776500L + index,
},
}));
return (Index: index, Response: response, StartedAtUtc: started);
});
await OracleAqPerformanceTestSupport.RunConcurrentAsync(
startResponses,
concurrency,
async startResponse =>
{
var quotationTask = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
Status = WorkflowTaskStatuses.Open,
}));
var task = quotationTask.Tasks.Should().ContainSingle().Subject;
await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
{
WorkflowTaskId = task.WorkflowTaskId,
ActorId = "perf-user",
ActorRoles = ["DBA"],
Payload = new Dictionary<string, object?>
{
["answer"] = "confirm",
},
}));
return true;
});
var processedSignals = await OracleAqPerformanceTestSupport.DrainSignalsUntilIdleAsync(provider, TimeSpan.FromSeconds(60));
foreach (var startResponse in startResponses)
{
var quotationInstance = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
}));
var pdfInstances = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest
{
WorkflowName = "PdfGenerator",
BusinessReferenceKey = (996500L + startResponse.Index).ToString(),
}));
quotationInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
quotationInstance.WorkflowState["nextStep"]!.ToString().Should().Be("ConvertToPolicy");
pdfInstances.Instances.Should().ContainSingle();
endToEndLatencies.Add(DateTime.UtcNow - startResponse.StartedAtUtc);
}
return processedSignals;
});
var completedAtUtc = DateTime.UtcNow;
transports.LegacyRabbit.Invocations.Count.Should().Be(workflowCount * 4);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "oracle-aq-bulstrad-quotation-confirm-convert-to-policy-nightly",
Tier = WorkflowPerformanceCategories.Nightly,
EnvironmentName = "oracle-aq-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
TasksActivated = workflowCount,
TasksCompleted = workflowCount,
SignalsProcessed = bulstradResult,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
BackendMetrics = oracleMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "QuotationConfirm",
["legacyRabbitInvocationCount"] = transports.LegacyRabbit.Invocations.Count.ToString(),
["expectedInvocationCount"] = (workflowCount * 4).ToString(),
},
});
}
private static WorkflowTransportScripts CreateQuotationConfirmConvertToPolicyTransports()
{
var transports = new WorkflowTransportScripts();
transports.LegacyRabbit
.Respond("pas_polannexes_get", new
{
shortDescription = "Quote for policy conversion",
})
.Respond("pas_polreg_checkuwrules", new
{
nextStep = "ConvertToPolicy",
})
.Respond("pas_polreg_convertqttopoldefault", new
{
converted = true,
})
.Respond("bst_integration_printpolicydocuments", new
{
printed = true,
});
return transports;
}
}

View File

@@ -0,0 +1,332 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.DataStore.Oracle;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Engine.Services;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.Oracle.Tests.Performance;
[TestFixture]
[Category("Integration")]
[NonParallelizable]
[Category(WorkflowPerformanceCategories.Smoke)]
public class OracleAqPerformanceSmokeTests
{
[Test]
public async Task OracleAqTransportPerfSmoke_WhenImmediateBurstQueued_ShouldDrainAndWriteArtifacts()
{
const int messageCount = 24;
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
using var provider = OracleAqPerformanceTestSupport.CreateTransportProvider(queueSet);
var startedAtUtc = DateTime.UtcNow;
var (transportResult, oracleMetrics) = await OracleAqPerformanceTestSupport.MeasureWithOracleMetricsAsync(
() => OracleAqPerformanceTestSupport.RunImmediateTransportBurstAsync(
provider,
queueSet.SignalQueueName,
queueSet.DeadLetterQueueName,
messageCount,
timeout: TimeSpan.FromSeconds(30),
correlationPrefix: "perf-immediate"));
var completedAtUtc = DateTime.UtcNow;
transportResult.ProcessedCorrelations.Should().HaveCount(messageCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "oracle-aq-immediate-burst-smoke",
Tier = WorkflowPerformanceCategories.Smoke,
EnvironmentName = "oracle-aq-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = messageCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
SignalsPublished = messageCount,
SignalsProcessed = messageCount,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(transportResult.ReceiveLatencies),
BackendMetrics = oracleMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["queueName"] = queueSet.SignalQueueName,
["messageCount"] = messageCount.ToString(),
},
});
transportResult.ReceiveLatencies.Should().HaveCount(messageCount);
transportResult.ReceiveLatencies.Max().Should().BeLessThan(TimeSpan.FromSeconds(30));
}
[Test]
public async Task OracleAqTransportPerfSmoke_WhenDelayedBurstQueued_ShouldDrainAndWriteArtifacts()
{
const int messageCount = 12;
const int delaySeconds = 2;
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
using var provider = OracleAqPerformanceTestSupport.CreateTransportProvider(queueSet);
var startedAtUtc = DateTime.UtcNow;
var (transportResult, oracleMetrics) = await OracleAqPerformanceTestSupport.MeasureWithOracleMetricsAsync(
() => OracleAqPerformanceTestSupport.RunDelayedTransportBurstAsync(
provider,
queueSet.SignalQueueName,
queueSet.DeadLetterQueueName,
messageCount,
delaySeconds,
timeout: TimeSpan.FromSeconds(45),
correlationPrefix: "perf-delayed"));
var completedAtUtc = DateTime.UtcNow;
transportResult.ProcessedCorrelations.Should().HaveCount(messageCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "oracle-aq-delayed-burst-smoke",
Tier = WorkflowPerformanceCategories.Smoke,
EnvironmentName = "oracle-aq-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = messageCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
SignalsPublished = messageCount,
SignalsProcessed = messageCount,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(transportResult.ReceiveLatencies),
BackendMetrics = oracleMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["queueName"] = queueSet.SignalQueueName,
["messageCount"] = messageCount.ToString(),
["delaySeconds"] = delaySeconds.ToString(),
},
});
transportResult.ReceiveLatencies.Should().OnlyContain(latency => latency > TimeSpan.FromSeconds(1));
transportResult.ReceiveLatencies.Max().Should().BeLessThan(TimeSpan.FromSeconds(45));
}
[Test]
public async Task OracleAqEnginePerfSmoke_WhenSyntheticExternalSignalBacklogResumed_ShouldDrainAndWriteArtifacts()
{
const int workflowCount = 12;
const int concurrency = 4;
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
using var provider = OracleAqPerformanceTestSupport.CreateTransportProvider(queueSet);
var startedAtUtc = DateTime.UtcNow;
var startLatencies = new ConcurrentBag<TimeSpan>();
var (engineResult, oracleMetrics) = await OracleAqPerformanceTestSupport.MeasureWithOracleMetricsAsync(async () =>
{
var startResponses = await OracleAqPerformanceTestSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
concurrency,
async index =>
{
var stopwatch = Stopwatch.StartNew();
var response = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "OracleAqPerfExternalSignalWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 998000L + index,
},
}));
stopwatch.Stop();
startLatencies.Add(stopwatch.Elapsed);
return response;
});
var signalRaisedAt = new ConcurrentDictionary<string, DateTime>(StringComparer.Ordinal);
await OracleAqPerformanceTestSupport.RunConcurrentAsync(
startResponses,
concurrency,
async startResponse =>
{
signalRaisedAt[startResponse.WorkflowInstanceId] = DateTime.UtcNow;
await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 889000L + Math.Abs(startResponse.WorkflowInstanceId.GetHashCode()),
},
}));
return true;
});
var processedSignals = await OracleAqPerformanceTestSupport.DrainSignalsUntilIdleAsync(provider, TimeSpan.FromSeconds(45));
var verificationMoments = new List<TimeSpan>(workflowCount);
var openTaskCount = 0;
foreach (var startResponse in startResponses)
{
var openTasks = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
Status = WorkflowTaskStatuses.Open,
}));
openTasks.Tasks.Should().ContainSingle();
openTasks.Tasks.Single().TaskName.Should().Be("Oracle Perf Review");
openTaskCount += openTasks.Tasks.Count;
verificationMoments.Add(DateTime.UtcNow - signalRaisedAt[startResponse.WorkflowInstanceId]);
}
return (ProcessedSignals: processedSignals, VerificationMoments: verificationMoments, OpenTaskCount: openTaskCount);
});
var completedAtUtc = DateTime.UtcNow;
engineResult.ProcessedSignals.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "oracle-aq-synthetic-external-resume-smoke",
Tier = WorkflowPerformanceCategories.Smoke,
EnvironmentName = "oracle-aq-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
TasksActivated = engineResult.OpenTaskCount,
SignalsPublished = workflowCount,
SignalsProcessed = engineResult.ProcessedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(engineResult.VerificationMoments),
BackendMetrics = oracleMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["queueName"] = queueSet.SignalQueueName,
["workflowName"] = "OracleAqPerfExternalSignalWorkflow",
},
});
engineResult.OpenTaskCount.Should().Be(workflowCount);
}
[Test]
public async Task OracleAqBulstradPerfSmoke_WhenQuoteOrAplCancelBurstStarted_ShouldCompleteAndWriteArtifacts()
{
const int workflowCount = 10;
const int concurrency = 4;
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
var transports = CreateQuoteOrAplCancelSuccessTransports();
using var provider = OracleAqPerformanceTestSupport.CreateBulstradProvider(queueSet, transports);
var startedAtUtc = DateTime.UtcNow;
var startLatencies = new ConcurrentBag<TimeSpan>();
var (bulstradResult, oracleMetrics) = await OracleAqPerformanceTestSupport.MeasureWithOracleMetricsAsync(async () =>
{
var workflowIds = await OracleAqPerformanceTestSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
concurrency,
async index =>
{
var stopwatch = Stopwatch.StartNew();
var response = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "QuoteOrAplCancel",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 997000L + index,
},
}));
stopwatch.Stop();
startLatencies.Add(stopwatch.Elapsed);
return response.WorkflowInstanceId;
});
var completedInstances = 0;
foreach (var workflowId in workflowIds)
{
var instance = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
completedInstances++;
}
var openTasks = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowName = "QuoteOrAplCancel",
Status = WorkflowTaskStatuses.Open,
}));
return (CompletedInstances: completedInstances, OpenTaskCount: openTasks.Tasks.Count);
});
var completedAtUtc = DateTime.UtcNow;
bulstradResult.OpenTaskCount.Should().Be(0);
bulstradResult.CompletedInstances.Should().Be(workflowCount);
transports.LegacyRabbit.Invocations.Count.Should().Be(workflowCount * 2);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "oracle-aq-bulstrad-quote-or-apl-cancel-smoke",
Tier = WorkflowPerformanceCategories.Smoke,
EnvironmentName = "oracle-aq-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(startLatencies),
BackendMetrics = oracleMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "QuoteOrAplCancel",
["legacyRabbitInvocationCount"] = transports.LegacyRabbit.Invocations.Count.ToString(),
},
});
}
private static WorkflowTransportScripts CreateQuoteOrAplCancelSuccessTransports()
{
var transports = new WorkflowTransportScripts();
transports.LegacyRabbit
.Respond("bst_blanknumbersrelease", new { released = true })
.Respond("pas_annexprocessing_cancelaplorqt", new { cancelled = true });
return transports;
}
}

View File

@@ -0,0 +1,142 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.Oracle.Tests.Performance;
[TestFixture]
[Category("Integration")]
[NonParallelizable]
[Category(WorkflowPerformanceCategories.Soak)]
public class OracleAqPerformanceSoakTests
{
[Test]
public async Task OracleAqEnginePerfSoak_WhenSyntheticSignalRoundTripRunsInWaves_ShouldStayStableAndWriteArtifacts()
{
const int waveCount = 6;
const int workflowsPerWave = 18;
const int concurrency = 8;
const int workerCount = 8;
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
using var provider = OracleAqPerformanceTestSupport.CreateTransportProvider(queueSet);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var (soakResult, oracleMetrics) = await OracleAqPerformanceTestSupport.MeasureWithOracleMetricsAsync(async () =>
{
var totalProcessedSignals = 0;
var totalCompletedInstances = 0;
for (var waveIndex = 0; waveIndex < waveCount; waveIndex++)
{
var waveStarts = await OracleAqPerformanceTestSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowsPerWave),
concurrency,
async index =>
{
var started = DateTime.UtcNow;
var response = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "OracleAqPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 995000L + (waveIndex * 1000L) + index,
},
}));
return (Index: index, Response: response, StartedAtUtc: started);
});
await OracleAqPerformanceTestSupport.RunConcurrentAsync(
waveStarts,
concurrency,
async waveStart =>
{
await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = waveStart.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 880000L + (waveIndex * 1000L) + waveStart.Index,
},
}));
return true;
});
totalProcessedSignals += await OracleAqPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(
provider,
TimeSpan.FromSeconds(75),
workerCount);
await OracleAqPerformanceTestSupport.RunConcurrentAsync(
waveStarts,
workerCount,
async waveStart =>
{
var instance = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = waveStart.Response.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
endToEndLatencies.Add(DateTime.UtcNow - waveStart.StartedAtUtc);
return true;
});
totalCompletedInstances += waveStarts.Count;
}
return (ProcessedSignals: totalProcessedSignals, CompletedInstances: totalCompletedInstances);
});
var completedAtUtc = DateTime.UtcNow;
var operationCount = waveCount * workflowsPerWave;
soakResult.CompletedInstances.Should().Be(operationCount);
soakResult.ProcessedSignals.Should().Be(operationCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "oracle-aq-signal-roundtrip-soak",
Tier = WorkflowPerformanceCategories.Soak,
EnvironmentName = "oracle-aq-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = operationCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = operationCount,
SignalsPublished = operationCount,
SignalsProcessed = soakResult.ProcessedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
BackendMetrics = oracleMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "OracleAqPerfSignalRoundTripWorkflow",
["queueName"] = queueSet.SignalQueueName,
["waveCount"] = waveCount.ToString(),
["workflowsPerWave"] = workflowsPerWave.ToString(),
["workerCount"] = workerCount.ToString(),
},
});
}
}

View File

@@ -0,0 +1,428 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Helpers;
using StellaOps.Workflow.DataStore.Oracle;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Engine.Signaling;
using StellaOps.Workflow.Engine.HostedServices;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using StellaOps.Workflow.Signaling.Redis;
using StellaOps.Workflow.Engine.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using BulstradWorkflowRegistrator = StellaOps.Workflow.Engine.Workflows.Bulstrad.ServiceRegistrator;
namespace StellaOps.Workflow.DataStore.Oracle.Tests.Performance;
internal static class OracleAqPerformanceTestSupport
{
public static ServiceProvider CreateTransportProvider(
OracleAqQueueSet queueSet,
string signalDriverProvider = WorkflowSignalDriverNames.Native,
string? redisConnectionString = null,
string? redisChannelName = null)
{
var services = CreateBaseServiceCollection(queueSet);
var configuration = CreateConfiguration(
queueSet,
signalDriverProvider: signalDriverProvider,
redisConnectionString: redisConnectionString,
redisChannelName: redisChannelName);
services.AddWorkflowRegistration<OracleAqPerfExternalSignalWorkflow, OracleAqPerfStartRequest>();
services.AddWorkflowRegistration<OracleAqPerfSignalRoundTripWorkflow, OracleAqPerfStartRequest>();
services.AddWorkflowEngineCoreServices(configuration);
services.AddWorkflowOracleDataStore(configuration);
if (string.Equals(configuration.GetWorkflowSignalDriverProvider(), WorkflowSignalDriverNames.Redis, StringComparison.OrdinalIgnoreCase))
{
services.AddWorkflowRedisSignaling(configuration);
}
var provider = services.BuildServiceProvider();
ServiceProviderAccessor.Initialize(provider);
return provider;
}
public static ServiceProvider CreateBulstradProvider(
OracleAqQueueSet queueSet,
WorkflowTransportScripts transports,
string signalDriverProvider = WorkflowSignalDriverNames.Native,
string? redisConnectionString = null,
string? redisChannelName = null)
{
var services = CreateBaseServiceCollection(queueSet);
var configuration = CreateConfiguration(
queueSet,
includeAssignmentRoles: true,
signalDriverProvider: signalDriverProvider,
redisConnectionString: redisConnectionString,
redisChannelName: redisChannelName);
new BulstradWorkflowRegistrator().RegisterServices(services, configuration);
services.AddWorkflowEngineCoreServices(configuration);
services.AddWorkflowModule("transport.legacy-rabbit", "1.0.0");
services.AddWorkflowOracleDataStore(configuration);
if (string.Equals(configuration.GetWorkflowSignalDriverProvider(), WorkflowSignalDriverNames.Redis, StringComparison.OrdinalIgnoreCase))
{
services.AddWorkflowRedisSignaling(configuration);
}
services.Replace(ServiceDescriptor.Scoped<IWorkflowLegacyRabbitTransport>(_ => transports.LegacyRabbit));
services.Replace(ServiceDescriptor.Scoped<IWorkflowMicroserviceTransport>(_ => transports.Microservice));
services.Replace(ServiceDescriptor.Scoped<IWorkflowGraphqlTransport>(_ => transports.Graphql));
services.Replace(ServiceDescriptor.Scoped<IWorkflowHttpTransport>(_ => transports.Http));
var provider = services.BuildServiceProvider();
ServiceProviderAccessor.Initialize(provider);
return provider;
}
public static async Task<int> DrainSignalsUntilIdleAsync(
IServiceProvider provider,
TimeSpan timeout,
string consumerName = "workflow-service")
{
var telemetry = await DrainSignalsWithWorkersUntilIdleDetailedAsync(provider, timeout, workerCount: 1, consumerNamePrefix: consumerName);
return telemetry.ProcessedCount;
}
public static async Task<int> DrainSignalsWithWorkersUntilIdleAsync(
IServiceProvider provider,
TimeSpan timeout,
int workerCount,
string consumerNamePrefix = "workflow-service")
{
var telemetry = await DrainSignalsWithWorkersUntilIdleDetailedAsync(provider, timeout, workerCount, consumerNamePrefix);
return telemetry.ProcessedCount;
}
public static async Task<WorkflowSignalDrainTelemetry> DrainSignalsWithWorkersUntilIdleDetailedAsync(
IServiceProvider provider,
TimeSpan timeout,
int workerCount,
string consumerNamePrefix = "workflow-service")
{
ArgumentOutOfRangeException.ThrowIfLessThan(workerCount, 1);
using var scope = provider.CreateScope();
var worker = scope.ServiceProvider.GetRequiredService<WorkflowSignalPumpWorker>();
var timeoutAt = DateTime.UtcNow.Add(timeout);
var startedAtUtc = DateTime.UtcNow;
var processedCount = 0;
var consecutiveEmptyRounds = 0;
var totalRounds = 0;
DateTime? firstProcessedAtUtc = null;
DateTime? lastProcessedAtUtc = null;
while (DateTime.UtcNow < timeoutAt && consecutiveEmptyRounds < 3)
{
totalRounds++;
var workerNames = Enumerable
.Range(0, workerCount)
.Select(index => workerCount == 1
? consumerNamePrefix
: $"{consumerNamePrefix}-{index + 1}")
.ToArray();
var roundResults = await Task.WhenAll(workerNames.Select(workerName => worker.RunOnceAsync(workerName, CancellationToken.None)));
var roundProcessedCount = roundResults.Count(result => result);
if (roundProcessedCount > 0)
{
var processedAtUtc = DateTime.UtcNow;
processedCount += roundProcessedCount;
consecutiveEmptyRounds = 0;
firstProcessedAtUtc ??= processedAtUtc;
lastProcessedAtUtc = processedAtUtc;
continue;
}
consecutiveEmptyRounds++;
}
return new WorkflowSignalDrainTelemetry
{
ProcessedCount = processedCount,
TotalRounds = totalRounds,
IdleEmptyRounds = consecutiveEmptyRounds,
StartedAtUtc = startedAtUtc,
CompletedAtUtc = DateTime.UtcNow,
FirstProcessedAtUtc = firstProcessedAtUtc,
LastProcessedAtUtc = lastProcessedAtUtc,
};
}
public static async Task<T> WithRuntimeServiceAsync<T>(
IServiceProvider provider,
Func<WorkflowRuntimeService, Task<T>> action)
{
return await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(provider, action);
}
public static async Task WithRuntimeServiceAsync(
IServiceProvider provider,
Func<WorkflowRuntimeService, Task> action)
{
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(provider, action);
}
public static async Task<IReadOnlyList<TResult>> RunConcurrentAsync<TInput, TResult>(
IEnumerable<TInput> items,
int concurrency,
Func<TInput, Task<TResult>> action)
{
return await WorkflowEnginePerformanceSupport.RunConcurrentAsync(items, concurrency, action);
}
public static async Task<(T Result, OraclePerformanceDelta OracleMetrics)> MeasureWithOracleMetricsAsync<T>(
Func<Task<T>> action,
CancellationToken cancellationToken = default)
{
var before = await OraclePerformanceMetricsCollector.CaptureAsync(cancellationToken);
var result = await action();
var after = await OraclePerformanceMetricsCollector.CaptureAsync(cancellationToken);
return (result, OraclePerformanceDelta.Create(before, after));
}
public static async Task<OraclePerformanceDelta> MeasureWithOracleMetricsAsync(
Func<Task> action,
CancellationToken cancellationToken = default)
{
var before = await OraclePerformanceMetricsCollector.CaptureAsync(cancellationToken);
await action();
var after = await OraclePerformanceMetricsCollector.CaptureAsync(cancellationToken);
return OraclePerformanceDelta.Create(before, after);
}
public static Task<OracleAqTransportBurstResult> RunImmediateTransportBurstAsync(
IServiceProvider provider,
string queueName,
string deadLetterQueueName,
int messageCount,
TimeSpan timeout,
string correlationPrefix)
{
return RunTransportBurstAsync(
provider,
queueName,
deadLetterQueueName,
messageCount,
delaySeconds: 0,
timeout,
correlationPrefix);
}
public static Task<OracleAqTransportBurstResult> RunDelayedTransportBurstAsync(
IServiceProvider provider,
string queueName,
string deadLetterQueueName,
int messageCount,
int delaySeconds,
TimeSpan timeout,
string correlationPrefix)
{
return RunTransportBurstAsync(
provider,
queueName,
deadLetterQueueName,
messageCount,
delaySeconds,
timeout,
correlationPrefix);
}
private static ServiceCollection CreateBaseServiceCollection(OracleAqQueueSet queueSet)
{
var services = new ServiceCollection();
services.AddLogging();
return services;
}
private static IConfiguration CreateConfiguration(
OracleAqQueueSet queueSet,
bool includeAssignmentRoles = false,
string signalDriverProvider = WorkflowSignalDriverNames.Native,
string? redisConnectionString = null,
string? redisChannelName = null)
{
var values = new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = OracleAqIntegrationLifetime.Fixture.ConnectionString,
["WorkflowBackend:Provider"] = WorkflowBackendNames.Oracle,
["WorkflowSignalDriver:Provider"] = signalDriverProvider,
["WorkflowRetention:OpenStaleAfterDays"] = "30",
["WorkflowRetention:CompletedPurgeAfterDays"] = "180",
["WorkflowRuntime:DefaultProvider"] = WorkflowRuntimeProviderNames.Engine,
["WorkflowRuntime:EnabledProviders:0"] = WorkflowRuntimeProviderNames.Engine,
["WorkflowAq:QueueOwner"] = "SRD_WFKLW",
["WorkflowAq:SignalQueueName"] = queueSet.SignalQueueName,
["WorkflowAq:ScheduleQueueName"] = queueSet.SignalQueueName,
["WorkflowAq:DeadLetterQueueName"] = queueSet.DeadLetterQueueName,
["WorkflowAq:ConsumerName"] = "workflow-service",
["WorkflowAq:BlockingDequeueSeconds"] = "1",
["WorkflowAq:MaxDeliveryAttempts"] = "3",
};
if (string.Equals(signalDriverProvider, WorkflowSignalDriverNames.Redis, StringComparison.OrdinalIgnoreCase))
{
values["RedisConfig:ServerUrl"] = redisConnectionString
?? throw new InvalidOperationException("Redis connection string is required when Redis signal driver is selected.");
values[$"{RedisWorkflowSignalDriverOptions.SectionName}:ChannelName"] =
redisChannelName ?? $"stella:test:workflow:perf:oracle:{queueSet.SignalQueueName}";
values[$"{RedisWorkflowSignalDriverOptions.SectionName}:BlockingWaitSeconds"] = "1";
}
if (includeAssignmentRoles)
{
values["GenericAssignmentPermissions:AdminRoles:0"] = "DBA";
values["GenericAssignmentPermissions:AdminRoles:1"] = "APR_APPL";
}
return new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
}
private sealed record OracleAqPerfStartRequest
{
[WorkflowBusinessId]
[WorkflowBusinessReferencePart("policyId")]
public long SrPolicyId { get; init; }
}
private sealed class OracleAqPerfExternalSignalWorkflow : IDeclarativeWorkflow<OracleAqPerfStartRequest>
{
public const string WorkflowNameValue = "OracleAqPerfExternalSignalWorkflow";
public string WorkflowName => WorkflowNameValue;
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Oracle AQ Performance External Signal Workflow";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<OracleAqPerfStartRequest> Spec { get; } = WorkflowSpec.For<OracleAqPerfStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")),
WorkflowExpr.Prop("documentId", WorkflowExpr.Number(0L))))
.AddTask(
WorkflowHumanTask.For<OracleAqPerfStartRequest>(
"Oracle Perf Review",
"OraclePerfReview",
"business/policies",
["DBA"])
.WithPayload(
WorkflowExpr.Obj(
WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase")),
WorkflowExpr.Prop("documentId", WorkflowExpr.Path("state.documentId"))))
.OnComplete(flow => flow.Complete()))
.StartWith(flow => flow
.Set("phase", "waiting-external")
.WaitForSignal("Wait For Perf Upload", WorkflowExpr.String("documents-uploaded"), resultKey: "uploadSignal")
.Set("documentId", WorkflowExpr.Path("result.uploadSignal.documentId"))
.Set("phase", "after-external")
.ActivateTask("Oracle Perf Review"))
.Build();
}
private sealed class OracleAqPerfSignalRoundTripWorkflow : IDeclarativeWorkflow<OracleAqPerfStartRequest>
{
public const string WorkflowNameValue = "OracleAqPerfSignalRoundTripWorkflow";
public string WorkflowName => WorkflowNameValue;
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Oracle AQ Performance Signal Round Trip Workflow";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<OracleAqPerfStartRequest> Spec { get; } = WorkflowSpec.For<OracleAqPerfStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")),
WorkflowExpr.Prop("documentId", WorkflowExpr.Number(0L))))
.StartWith(flow => flow
.Set("phase", "waiting-external")
.WaitForSignal("Wait For Perf Upload Completion", WorkflowExpr.String("documents-uploaded"), resultKey: "uploadSignal")
.Set("documentId", WorkflowExpr.Path("result.uploadSignal.documentId"))
.Set("phase", "completed")
.Complete())
.Build();
}
public sealed record OracleAqTransportBurstResult
{
public required IReadOnlyList<TimeSpan> ReceiveLatencies { get; init; }
public required IReadOnlyCollection<string> ProcessedCorrelations { get; init; }
}
private static async Task<OracleAqTransportBurstResult> RunTransportBurstAsync(
IServiceProvider provider,
string queueName,
string deadLetterQueueName,
int messageCount,
int delaySeconds,
TimeSpan timeout,
string correlationPrefix)
{
using var scope = provider.CreateScope();
var transport = scope.ServiceProvider.GetRequiredService<IOracleAqTransport>();
var publishedAt = new Dictionary<string, DateTime>(StringComparer.Ordinal);
for (var index = 0; index < messageCount; index++)
{
var correlation = $"{correlationPrefix}-{index}-{Guid.NewGuid():N}";
publishedAt[correlation] = DateTime.UtcNow;
await transport.EnqueueAsync(new OracleAqEnqueueRequest
{
QueueName = queueName,
Payload = JsonSerializer.SerializeToUtf8Bytes(new { correlation }),
Correlation = correlation,
DelaySeconds = delaySeconds,
ExceptionQueueName = deadLetterQueueName,
});
}
var receiveLatencies = new List<TimeSpan>(messageCount);
var processedCorrelations = new HashSet<string>(StringComparer.Ordinal);
var timeoutAt = DateTime.UtcNow.Add(timeout);
while (processedCorrelations.Count < messageCount && DateTime.UtcNow < timeoutAt)
{
await using var lease = await transport.DequeueAsync(new OracleAqDequeueRequest
{
QueueName = queueName,
WaitSeconds = 1,
});
if (lease is null)
{
continue;
}
if (!string.IsNullOrWhiteSpace(lease.Message.Correlation)
&& publishedAt.TryGetValue(lease.Message.Correlation, out var publishedMoment))
{
processedCorrelations.Add(lease.Message.Correlation);
receiveLatencies.Add(DateTime.UtcNow - publishedMoment);
}
await lease.CommitAsync(CancellationToken.None);
}
return new OracleAqTransportBurstResult
{
ReceiveLatencies = receiveLatencies,
ProcessedCorrelations = processedCorrelations,
};
}
}

View File

@@ -0,0 +1,172 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.Oracle.Tests.Performance;
[TestFixture]
[Category("Integration")]
[NonParallelizable]
[Category(WorkflowPerformanceCategories.Throughput)]
public class OracleAqPerformanceThroughputTests
{
[Test]
public async Task OracleAqEnginePerfThroughput_WhenSignalRoundTripRunsWithParallelWorkers_ShouldWriteArtifacts()
{
const int workflowCount = 96;
const int operationConcurrency = 16;
const int workerCount = 8;
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
using var provider = OracleAqPerformanceTestSupport.CreateTransportProvider(queueSet);
var warmupResponse = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "OracleAqPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 991999L,
},
}));
await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = warmupResponse.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 861999L,
},
}));
await OracleAqPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(provider, TimeSpan.FromSeconds(20), workerCount: 2);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var startLatencies = new ConcurrentBag<TimeSpan>();
var signalPublishLatencies = new ConcurrentBag<TimeSpan>();
var signalToCompletionLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, oracleMetrics) = await OracleAqPerformanceTestSupport.MeasureWithOracleMetricsAsync(async () =>
{
var startResponses = await OracleAqPerformanceTestSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
operationConcurrency,
async index =>
{
var operationStartedAtUtc = DateTime.UtcNow;
var startMeasureStartedAtUtc = operationStartedAtUtc;
var response = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "OracleAqPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 991000L + index,
},
}));
startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc);
return (Index: index, Response: response, StartedAtUtc: operationStartedAtUtc);
});
var signalRaisedAtUtc = new ConcurrentDictionary<string, DateTime>(StringComparer.Ordinal);
await OracleAqPerformanceTestSupport.RunConcurrentAsync(
startResponses,
operationConcurrency,
async startResponse =>
{
var publishStartedAtUtc = DateTime.UtcNow;
signalRaisedAtUtc[startResponse.Response.WorkflowInstanceId] = publishStartedAtUtc;
await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 861000L + startResponse.Index,
},
}));
signalPublishLatencies.Add(DateTime.UtcNow - publishStartedAtUtc);
return true;
});
var totalProcessedSignals = await OracleAqPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(
provider,
TimeSpan.FromSeconds(90),
workerCount);
await OracleAqPerformanceTestSupport.RunConcurrentAsync(
startResponses,
operationConcurrency,
async startResponse =>
{
var instance = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
var completedAtUtc = DateTime.UtcNow;
endToEndLatencies.Add(completedAtUtc - startResponse.StartedAtUtc);
signalToCompletionLatencies.Add(completedAtUtc - signalRaisedAtUtc[startResponse.Response.WorkflowInstanceId]);
return true;
});
return totalProcessedSignals;
});
var completedAtUtc = DateTime.UtcNow;
processedSignals.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "oracle-aq-signal-roundtrip-throughput-parallel",
Tier = WorkflowPerformanceCategories.Throughput,
EnvironmentName = "oracle-aq-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = operationConcurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
PhaseLatencySummaries = new Dictionary<string, WorkflowPerformanceLatencySummary>
{
["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!,
["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!,
["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!,
},
BackendMetrics = oracleMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "OracleAqPerfSignalRoundTripWorkflow",
["queueName"] = queueSet.SignalQueueName,
["workerCount"] = workerCount.ToString(),
["measurementKind"] = "steady-throughput",
},
});
}
}

View File

@@ -0,0 +1,217 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.Engine.HostedServices;
using StellaOps.Workflow.Signaling.Redis;
using StellaOps.Workflow.Signaling.Redis.Tests;
using StellaOps.Workflow.Engine.Services;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.Oracle.Tests;
[TestFixture]
[Category("Integration")]
[NonParallelizable]
public class OracleAqRedisSignalDriverIntegrationTests
{
private RedisDockerFixture? redisFixture;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
redisFixture = new RedisDockerFixture();
await redisFixture.StartOrIgnoreAsync();
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
redisFixture?.Dispose();
}
[Test]
public async Task ExternalSignalWorkflow_WhenOracleUsesRedisSignalDriver_ShouldWakeWorkerAndResumeInstance()
{
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
using var provider = CreateProvider(
queueSet,
redisFixture!.ConnectionString,
$"stella:test:workflow:oracle:redis:{Guid.NewGuid():N}");
string workflowInstanceId;
using (var startScope = provider.CreateScope())
{
var runtimeService = startScope.ServiceProvider.GetRequiredService<WorkflowRuntimeService>();
var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = OracleRedisRecoveryWorkflow.WorkflowNameValue,
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 940001L,
},
});
workflowInstanceId = startResponse.WorkflowInstanceId;
}
Task<bool> receiveTask;
using (var workerScope = provider.CreateScope())
{
var worker = workerScope.ServiceProvider.GetRequiredService<WorkflowSignalPumpWorker>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
receiveTask = worker.RunOnceAsync("workflow-service", cts.Token);
await Task.Delay(250);
using var signalScope = provider.CreateScope();
var runtimeService = signalScope.ServiceProvider.GetRequiredService<WorkflowRuntimeService>();
var raiseResponse = await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = workflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 778001L,
},
});
raiseResponse.Queued.Should().BeTrue();
var processed = await receiveTask;
processed.Should().BeTrue();
}
using var verifyScope = provider.CreateScope();
var verifyRuntimeService = verifyScope.ServiceProvider.GetRequiredService<WorkflowRuntimeService>();
var openTasks = await verifyRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = workflowInstanceId,
Status = WorkflowTaskStatuses.Open,
});
var resumedInstance = await verifyRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowInstanceId,
});
var openTask = openTasks.Tasks.Should().ContainSingle().Subject;
openTask.TaskName.Should().Be("Oracle Redis Review");
ReadString(openTask.Payload["phase"]).Should().Be("after-external");
ReadLong(openTask.Payload["documentId"]).Should().Be(778001L);
resumedInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask");
resumedInstance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine);
}
private static ServiceProvider CreateProvider(
OracleAqQueueSet queueSet,
string redisConnectionString,
string redisChannelName)
{
var services = new ServiceCollection();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = OracleAqIntegrationLifetime.Fixture.ConnectionString,
["WorkflowBackend:Provider"] = WorkflowBackendNames.Oracle,
["WorkflowSignalDriver:Provider"] = WorkflowSignalDriverNames.Redis,
["WorkflowSignalDriver:Redis:ChannelName"] = redisChannelName,
["WorkflowSignalDriver:Redis:BlockingWaitSeconds"] = "5",
["RedisConfig:ServerUrl"] = redisConnectionString,
["WorkflowRetention:OpenStaleAfterDays"] = "30",
["WorkflowRetention:CompletedPurgeAfterDays"] = "180",
["WorkflowRuntime:DefaultProvider"] = WorkflowRuntimeProviderNames.Engine,
["WorkflowRuntime:EnabledProviders:0"] = WorkflowRuntimeProviderNames.Engine,
["WorkflowAq:QueueOwner"] = "SRD_WFKLW",
["WorkflowAq:SignalQueueName"] = queueSet.SignalQueueName,
["WorkflowAq:ScheduleQueueName"] = queueSet.SignalQueueName,
["WorkflowAq:DeadLetterQueueName"] = queueSet.DeadLetterQueueName,
["WorkflowAq:ConsumerName"] = "workflow-service",
["WorkflowAq:BlockingDequeueSeconds"] = "1",
["WorkflowAq:MaxDeliveryAttempts"] = "3",
})
.Build();
services.AddLogging();
services.AddWorkflowRegistration<OracleRedisRecoveryWorkflow, OracleRedisRecoveryStartRequest>();
services.AddWorkflowEngineCoreServices(configuration);
services.AddWorkflowOracleDataStore(configuration);
services.AddWorkflowRedisSignaling(configuration);
var provider = services.BuildServiceProvider();
// ServiceProviderAccessor.Initialize(provider); // TODO: Requires Serdica-specific types
return provider;
}
private static string ReadString(object? value)
{
return value switch
{
string text => text,
JsonElement jsonElement => jsonElement.Get<string>(),
_ => throw new AssertionException("Value is not a string."),
};
}
private static long ReadLong(object? value)
{
return value switch
{
long number => number,
int number => number,
JsonElement jsonElement when jsonElement.TryGetInt64(out var number) => number,
_ => throw new AssertionException("Value is not an integer."),
};
}
private sealed record OracleRedisRecoveryStartRequest
{
[WorkflowBusinessId]
[WorkflowBusinessReferencePart("policyId")]
public long SrPolicyId { get; init; }
}
private sealed class OracleRedisRecoveryWorkflow : IDeclarativeWorkflow<OracleRedisRecoveryStartRequest>
{
public const string WorkflowNameValue = "OracleRedisRecoveryWorkflow";
public string WorkflowName => WorkflowNameValue;
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Oracle Redis Recovery Workflow";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<OracleRedisRecoveryStartRequest> Spec { get; } = WorkflowSpec.For<OracleRedisRecoveryStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")),
WorkflowExpr.Prop("documentId", WorkflowExpr.Number(0L))))
.AddTask(
WorkflowHumanTask.For<OracleRedisRecoveryStartRequest>(
"Oracle Redis Review",
"OracleRedisReview",
"business/policies",
["DBA"])
.WithPayload(
WorkflowExpr.Obj(
WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase")),
WorkflowExpr.Prop("documentId", WorkflowExpr.Path("state.documentId"))))
.OnComplete(flow => flow.Complete()))
.StartWith(flow => flow
.Set("phase", "waiting-external")
.WaitForSignal("Wait For Redis Upload", WorkflowExpr.String("documents-uploaded"), resultKey: "uploadSignal")
.Set("documentId", WorkflowExpr.Path("result.uploadSignal.documentId"))
.Set("phase", "after-external")
.ActivateTask("Oracle Redis Review"))
.Build();
}
}

View File

@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Hosting;
using StellaOps.Workflow.Engine.Scheduling;
using StellaOps.Workflow.Engine.Signaling;
using StellaOps.Workflow.Signaling.OracleAq;
using WorkflowSignalEnvelopeSerializer = StellaOps.Workflow.Signaling.OracleAq.WorkflowSignalEnvelopeSerializer;
using WorkflowAqOptions = StellaOps.Workflow.Signaling.OracleAq.WorkflowAqOptions;
using FluentAssertions;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.Oracle.Tests;
[TestFixture]
public class OracleAqWorkflowScheduleBusTests
{
[Test]
public async Task ScheduleAsync_WhenDueInFuture_ShouldUsePositiveDelayAndSignalQueue()
{
var transport = new FakeOracleAqTransport();
var serializer = new WorkflowSignalEnvelopeSerializer();
var bus = new OracleAqWorkflowScheduleBus(
transport,
serializer,
Options.Create(new WorkflowAqOptions
{
SignalQueueName = "WF_SIGNAL_Q",
ScheduleQueueName = "WF_SCHEDULE_Q",
DeadLetterQueueName = "WF_DLQ_Q",
}));
var dueAtUtc = DateTime.UtcNow.AddSeconds(3);
await bus.ScheduleAsync(BuildEnvelope(), dueAtUtc, CancellationToken.None);
transport.EnqueueRequests.Should().ContainSingle();
transport.EnqueueRequests[0].QueueName.Should().Be("WF_SIGNAL_Q");
transport.EnqueueRequests[0].DelaySeconds.Should().BeGreaterThanOrEqualTo(2);
transport.EnqueueRequests[0].ExceptionQueueName.Should().Be("WF_DLQ_Q");
var roundTrip = serializer.Deserialize(transport.EnqueueRequests[0].Payload);
roundTrip.DueAtUtc.Should().BeCloseTo(dueAtUtc, TimeSpan.FromSeconds(1));
}
[Test]
public async Task ScheduleAsync_WhenDueInPast_ShouldClampDelayToZero()
{
var transport = new FakeOracleAqTransport();
var bus = new OracleAqWorkflowScheduleBus(
transport,
new WorkflowSignalEnvelopeSerializer(),
Options.Create(new WorkflowAqOptions
{
SignalQueueName = "WF_SIGNAL_Q",
ScheduleQueueName = "WF_SCHEDULE_Q",
DeadLetterQueueName = "WF_DLQ_Q",
}));
await bus.ScheduleAsync(BuildEnvelope(), DateTime.UtcNow.AddSeconds(-5), CancellationToken.None);
transport.EnqueueRequests.Should().ContainSingle();
transport.EnqueueRequests[0].DelaySeconds.Should().Be(0);
}
private static WorkflowSignalEnvelope BuildEnvelope()
{
return new WorkflowSignalEnvelope
{
SignalId = "sig-1",
WorkflowInstanceId = "wf-1",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.TimerDue,
ExpectedVersion = 2,
Payload = new Dictionary<string, JsonElement>
{
["timer"] = JsonSerializer.SerializeToElement("follow-up"),
},
};
}
private sealed class FakeOracleAqTransport : IOracleAqTransport
{
public List<OracleAqEnqueueRequest> EnqueueRequests { get; } = [];
public Task EnqueueAsync(OracleAqEnqueueRequest request, CancellationToken cancellationToken = default)
{
EnqueueRequests.Add(request);
return Task.CompletedTask;
}
public Task<IReadOnlyCollection<OracleAqDequeuedMessage>> BrowseAsync(OracleAqBrowseRequest request, CancellationToken cancellationToken = default)
{
throw new NotSupportedException();
}
public Task<IOracleAqMessageLease?> DequeueAsync(OracleAqDequeueRequest request, CancellationToken cancellationToken = default)
{
throw new NotSupportedException();
}
}
}

View File

@@ -0,0 +1,286 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Hosting;
using StellaOps.Workflow.Engine.Signaling;
using StellaOps.Workflow.Signaling.OracleAq;
using WorkflowSignalEnvelopeSerializer = StellaOps.Workflow.Signaling.OracleAq.WorkflowSignalEnvelopeSerializer;
using WorkflowAqOptions = StellaOps.Workflow.Signaling.OracleAq.WorkflowAqOptions;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.Oracle.Tests;
[TestFixture]
public class OracleAqWorkflowSignalBusTests
{
[Test]
public async Task PublishAsync_WhenEnvelopeProvided_ShouldEnqueueSerializedSignalMessage()
{
var transport = new FakeOracleAqTransport();
var serializer = new WorkflowSignalEnvelopeSerializer();
var bus = new OracleAqWorkflowSignalBus(
transport,
serializer,
Options.Create(new WorkflowAqOptions
{
SignalQueueName = "WF_SIGNAL_Q",
DeadLetterQueueName = "WF_DLQ_Q",
ConsumerName = "workflow-service",
BlockingDequeueSeconds = 15,
}),
NullLogger<OracleAqWorkflowSignalBus>.Instance);
var envelope = BuildEnvelope();
await bus.PublishAsync(envelope, CancellationToken.None);
transport.EnqueueRequests.Should().ContainSingle();
transport.EnqueueRequests[0].QueueName.Should().Be("WF_SIGNAL_Q");
transport.EnqueueRequests[0].Correlation.Should().Be("sig-1");
transport.EnqueueRequests[0].DelaySeconds.Should().Be(0);
transport.EnqueueRequests[0].ExceptionQueueName.Should().Be("WF_DLQ_Q");
var roundTrip = serializer.Deserialize(transport.EnqueueRequests[0].Payload);
roundTrip.SignalId.Should().Be(envelope.SignalId);
}
[Test]
public async Task ReceiveAsync_WhenNoMessageAvailable_ShouldReturnNull()
{
var transport = new FakeOracleAqTransport();
var bus = CreateBus(transport);
var lease = await bus.ReceiveAsync("workflow-service", CancellationToken.None);
lease.Should().BeNull();
transport.LastDequeueRequest.Should().NotBeNull();
transport.LastDequeueRequest!.WaitSeconds.Should().Be(15);
}
[Test]
public async Task TryClaimAsync_WhenNoMessageAvailable_ShouldUseNonBlockingDequeue()
{
var transport = new FakeOracleAqTransport();
var bus = CreateBus(transport);
var lease = await bus.TryClaimAsync("workflow-service", CancellationToken.None);
lease.Should().BeNull();
transport.LastDequeueRequest.Should().NotBeNull();
transport.LastDequeueRequest!.WaitSeconds.Should().Be(0);
}
[Test]
public async Task ReceiveAsync_WhenMessageAvailable_ShouldExposeLeaseAndCommitOnComplete()
{
var transport = new FakeOracleAqTransport();
var serializer = new WorkflowSignalEnvelopeSerializer();
transport.NextLease = new FakeOracleAqMessageLease(
new OracleAqDequeuedMessage
{
Payload = serializer.Serialize(BuildEnvelope()),
DequeueAttempts = 2,
});
var bus = CreateBus(transport);
await using var lease = await bus.ReceiveAsync("custom-consumer", CancellationToken.None);
lease.Should().NotBeNull();
lease!.Envelope.SignalId.Should().Be("sig-1");
lease.DeliveryCount.Should().Be(2);
await lease.CompleteAsync(CancellationToken.None);
transport.NextLease!.CommitCalls.Should().Be(1);
transport.LastDequeueRequest!.ConsumerName.Should().BeNull();
}
[Test]
public async Task ReceiveAsync_WhenCustomConsumerProvided_ShouldIgnoreConsumerNameForSingleConsumerQueue()
{
var transport = new FakeOracleAqTransport();
var serializer = new WorkflowSignalEnvelopeSerializer();
transport.NextLease = new FakeOracleAqMessageLease(
new OracleAqDequeuedMessage
{
Payload = serializer.Serialize(BuildEnvelope()),
});
var bus = CreateBus(transport);
await using var lease = await bus.ReceiveAsync("custom-consumer", CancellationToken.None);
lease.Should().NotBeNull();
transport.LastDequeueRequest!.ConsumerName.Should().BeNull();
}
[Test]
public async Task ReceiveAsync_WhenConsumerNameMissing_ShouldNotFallbackToConfiguredConsumerName()
{
var transport = new FakeOracleAqTransport();
var serializer = new WorkflowSignalEnvelopeSerializer();
transport.NextLease = new FakeOracleAqMessageLease(
new OracleAqDequeuedMessage
{
Payload = serializer.Serialize(BuildEnvelope()),
});
var bus = CreateBus(transport);
await using var lease = await bus.ReceiveAsync(string.Empty, CancellationToken.None);
lease.Should().NotBeNull();
transport.LastDequeueRequest!.ConsumerName.Should().BeNull();
}
[Test]
public async Task ReceiveAsync_WhenPayloadIsInvalid_ShouldDeadLetterLease()
{
var transport = new FakeOracleAqTransport();
transport.NextLease = new FakeOracleAqMessageLease(
new OracleAqDequeuedMessage
{
Payload = global::System.Text.Encoding.UTF8.GetBytes("{\"broken\":"),
});
var bus = CreateBus(transport);
var act = async () => await bus.ReceiveAsync("workflow-service", CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
transport.NextLease!.DeadLetterCalls.Should().Be(1);
transport.NextLease.DisposeCalls.Should().Be(1);
}
[Test]
public async Task LeaseAbandonAsync_ShouldRollbackUnderlyingTransportLease()
{
var transport = new FakeOracleAqTransport();
var serializer = new WorkflowSignalEnvelopeSerializer();
transport.NextLease = new FakeOracleAqMessageLease(
new OracleAqDequeuedMessage
{
Payload = serializer.Serialize(BuildEnvelope()),
});
var bus = CreateBus(transport);
await using var lease = await bus.ReceiveAsync("workflow-service", CancellationToken.None);
await lease!.AbandonAsync(CancellationToken.None);
transport.NextLease!.RollbackCalls.Should().Be(1);
}
[Test]
public async Task LeaseDeadLetterAsync_ShouldMoveMessageToConfiguredDeadLetterQueue()
{
var transport = new FakeOracleAqTransport();
var serializer = new WorkflowSignalEnvelopeSerializer();
transport.NextLease = new FakeOracleAqMessageLease(
new OracleAqDequeuedMessage
{
Payload = serializer.Serialize(BuildEnvelope()),
});
var bus = CreateBus(transport);
await using var lease = await bus.ReceiveAsync("workflow-service", CancellationToken.None);
await lease!.DeadLetterAsync(CancellationToken.None);
transport.NextLease!.DeadLetterCalls.Should().Be(1);
transport.NextLease.DeadLetterQueueNames.Should().ContainSingle().Which.Should().Be("WF_DLQ_Q");
}
private static OracleAqWorkflowSignalBus CreateBus(FakeOracleAqTransport transport)
{
return new OracleAqWorkflowSignalBus(
transport,
new WorkflowSignalEnvelopeSerializer(),
Options.Create(new WorkflowAqOptions
{
SignalQueueName = "WF_SIGNAL_Q",
DeadLetterQueueName = "WF_DLQ_Q",
ConsumerName = "workflow-service",
BlockingDequeueSeconds = 15,
}),
NullLogger<OracleAqWorkflowSignalBus>.Instance);
}
private static WorkflowSignalEnvelope BuildEnvelope()
{
return new WorkflowSignalEnvelope
{
SignalId = "sig-1",
WorkflowInstanceId = "wf-1",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.TaskCompleted,
ExpectedVersion = 2,
Payload = new Dictionary<string, JsonElement>
{
["answer"] = JsonSerializer.SerializeToElement("approve"),
},
};
}
private sealed class FakeOracleAqTransport : IOracleAqTransport
{
public List<OracleAqEnqueueRequest> EnqueueRequests { get; } = [];
public OracleAqDequeueRequest? LastDequeueRequest { get; private set; }
public FakeOracleAqMessageLease? NextLease { get; set; }
public Task EnqueueAsync(OracleAqEnqueueRequest request, CancellationToken cancellationToken = default)
{
EnqueueRequests.Add(request);
return Task.CompletedTask;
}
public Task<IReadOnlyCollection<OracleAqDequeuedMessage>> BrowseAsync(OracleAqBrowseRequest request, CancellationToken cancellationToken = default)
{
throw new NotSupportedException();
}
public Task<IOracleAqMessageLease?> DequeueAsync(OracleAqDequeueRequest request, CancellationToken cancellationToken = default)
{
LastDequeueRequest = request;
return Task.FromResult<IOracleAqMessageLease?>(NextLease);
}
}
private sealed class FakeOracleAqMessageLease(OracleAqDequeuedMessage message) : IOracleAqMessageLease
{
public OracleAqDequeuedMessage Message { get; } = message;
public int CommitCalls { get; private set; }
public int RollbackCalls { get; private set; }
public int DeadLetterCalls { get; private set; }
public int DisposeCalls { get; private set; }
public List<string> DeadLetterQueueNames { get; } = [];
public Task CommitAsync(CancellationToken cancellationToken = default)
{
CommitCalls++;
return Task.CompletedTask;
}
public Task RollbackAsync(CancellationToken cancellationToken = default)
{
RollbackCalls++;
return Task.CompletedTask;
}
public Task DeadLetterAsync(string queueName, CancellationToken cancellationToken = default)
{
DeadLetterCalls++;
DeadLetterQueueNames.Add(queueName);
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
DisposeCalls++;
return ValueTask.CompletedTask;
}
}
}

View File

@@ -0,0 +1,498 @@
using System;
using System.Data.Common;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Oracle.ManagedDataAccess.Client;
namespace StellaOps.Workflow.DataStore.Oracle.Tests;
internal sealed class OracleDockerFixture : IDisposable
{
private const string ImageName = "gvenzl/oracle-free:23-slim";
private const string SchemaName = "SRD_WFKLW";
private const string SchemaPassword = "srd_wfklw";
private const string SystemPassword = "oracle_test_pw";
private static readonly int[] TransientOracleErrorNumbers = [1012, 1033, 1089, 12170, 12514, 12516, 12528, 12537, 12541];
private readonly string containerName = $"stella-workflow-oracle-{Guid.NewGuid():N}";
private readonly int hostPort = GetFreeTcpPort();
private readonly SemaphoreSlim workflowStorageSync = new(1, 1);
private bool started;
private bool workflowStorageInitialized;
public string ConnectionString =>
$"User Id={SchemaName};Password={SchemaPassword};Data Source=127.0.0.1:{hostPort}/FREEPDB1;Pooling=true;Min Pool Size=1;Max Pool Size=24;Connection Timeout=60";
private string AdminConnectionString =>
$"User Id=system;Password={SystemPassword};Data Source=127.0.0.1:{hostPort}/FREEPDB1;Pooling=true;Min Pool Size=1;Max Pool Size=8;Connection Timeout=60";
internal string DiagnosticsConnectionString => AdminConnectionString;
public async Task StartOrIgnoreAsync(CancellationToken cancellationToken = default)
{
if (started)
{
return;
}
if (!await CanUseDockerAsync(cancellationToken))
{
Assert.Ignore("Docker is not available. Oracle AQ integration tests require a local Docker daemon.");
}
var runExitCode = await RunDockerCommandAsync(
$"run -d --name {containerName} -p {hostPort}:1521 -e ORACLE_PASSWORD={SystemPassword} -e APP_USER={SchemaName} -e APP_USER_PASSWORD={SchemaPassword} {ImageName}",
ignoreErrors: false,
cancellationToken);
if (runExitCode != 0)
{
Assert.Ignore("Unable to start Oracle Docker container for AQ integration tests.");
}
started = true;
OracleConnection.ClearAllPools();
try
{
await WaitUntilReadyAsync(cancellationToken);
await EnsurePrivilegesAsync(cancellationToken);
}
catch
{
Dispose();
throw;
}
}
public async Task<OracleAqQueueSet> CreateQueueSetAsync(
CancellationToken cancellationToken = default)
{
await EnsureWorkflowStorageSchemaAsync(cancellationToken);
await ResetWorkflowStorageAsync(cancellationToken);
var suffix = Guid.NewGuid().ToString("N")[..8].ToUpperInvariant();
var signalQueueName = $"WF_SIG_{suffix}";
var signalTableName = $"WF_SQT_{suffix}";
var deadLetterQueueName = $"WF_DLQ_{suffix}";
var deadLetterTableName = $"WF_DQT_{suffix}";
await ExecuteNonQueryAsync(
ConnectionString,
$"""
BEGIN
DBMS_AQADM.CREATE_QUEUE_TABLE(
queue_table => '{SchemaName}.{signalTableName}',
queue_payload_type => 'RAW');
DBMS_AQADM.CREATE_QUEUE(
queue_name => '{SchemaName}.{signalQueueName}',
queue_table => '{SchemaName}.{signalTableName}');
DBMS_AQADM.START_QUEUE(queue_name => '{SchemaName}.{signalQueueName}');
DBMS_AQADM.CREATE_QUEUE_TABLE(
queue_table => '{SchemaName}.{deadLetterTableName}',
queue_payload_type => 'RAW');
DBMS_AQADM.CREATE_QUEUE(
queue_name => '{SchemaName}.{deadLetterQueueName}',
queue_table => '{SchemaName}.{deadLetterTableName}');
DBMS_AQADM.START_QUEUE(queue_name => '{SchemaName}.{deadLetterQueueName}');
END;
""",
cancellationToken);
return new OracleAqQueueSet
{
SignalQueueName = signalQueueName,
SignalQueueTableName = signalTableName,
DeadLetterQueueName = deadLetterQueueName,
DeadLetterQueueTableName = deadLetterTableName,
};
}
public async Task RestartAsync(CancellationToken cancellationToken = default)
{
if (!started)
{
throw new InvalidOperationException("Oracle Docker fixture is not started.");
}
var restartExitCode = await RunDockerCommandAsync(
$"restart {containerName}",
ignoreErrors: false,
cancellationToken);
if (restartExitCode != 0)
{
Assert.Fail("Unable to restart Oracle Docker container for AQ integration tests.");
}
OracleConnection.ClearAllPools();
await WaitUntilReadyAsync(cancellationToken);
}
private async Task EnsureWorkflowStorageSchemaAsync(CancellationToken cancellationToken)
{
if (workflowStorageInitialized)
{
return;
}
await workflowStorageSync.WaitAsync(cancellationToken);
try
{
if (workflowStorageInitialized)
{
return;
}
await EnsureSequenceAsync(
"WF_INSTANCES_SEQ",
"CREATE SEQUENCE WF_INSTANCES_SEQ START WITH 1 INCREMENT BY 1 NOCACHE",
cancellationToken);
await EnsureSequenceAsync(
"WF_TASKS_SEQ",
"CREATE SEQUENCE WF_TASKS_SEQ START WITH 1 INCREMENT BY 1 NOCACHE",
cancellationToken);
await EnsureSequenceAsync(
"WF_TASK_EVENTS_SEQ",
"CREATE SEQUENCE WF_TASK_EVENTS_SEQ START WITH 1 INCREMENT BY 1 NOCACHE",
cancellationToken);
await EnsureTableAsync(
"WF_INSTANCES",
"""
CREATE TABLE WF_INSTANCES (
WF_INSTANCE_PK NUMBER(18, 0) NOT NULL,
WF_INSTANCE_ID VARCHAR2(128 CHAR) NOT NULL,
WF_NAME VARCHAR2(128 CHAR) NOT NULL,
WF_VERSION VARCHAR2(64 CHAR) NOT NULL,
BUSINESS_ID VARCHAR2(128 CHAR) NULL,
BUSINESS_REFERENCE_JSON CLOB NULL,
STATUS VARCHAR2(32 CHAR) NOT NULL,
STATE_JSON CLOB NOT NULL,
CREATED_ON_UTC TIMESTAMP(6) NOT NULL,
COMPLETED_ON_UTC TIMESTAMP(6) NULL,
STALE_AFTER_UTC TIMESTAMP(6) NULL,
PURGE_AFTER_UTC TIMESTAMP(6) NULL,
CONSTRAINT WF_INSTANCES_PK PRIMARY KEY (WF_INSTANCE_PK),
CONSTRAINT WF_INSTANCES_ID_UK UNIQUE (WF_INSTANCE_ID)
)
""",
cancellationToken);
await EnsureTableAsync(
"WF_TASKS",
"""
CREATE TABLE WF_TASKS (
WF_TASK_PK NUMBER(18, 0) NOT NULL,
WF_TASK_ID VARCHAR2(128 CHAR) NOT NULL,
WF_INSTANCE_ID VARCHAR2(128 CHAR) NOT NULL,
WF_NAME VARCHAR2(128 CHAR) NOT NULL,
WF_VERSION VARCHAR2(64 CHAR) NOT NULL,
TASK_NAME VARCHAR2(256 CHAR) NOT NULL,
TASK_TYPE VARCHAR2(128 CHAR) NOT NULL,
ROUTE VARCHAR2(256 CHAR) NOT NULL,
BUSINESS_ID VARCHAR2(128 CHAR) NULL,
BUSINESS_REFERENCE_JSON CLOB NULL,
ASSIGNEE VARCHAR2(128 CHAR) NULL,
STATUS VARCHAR2(32 CHAR) NOT NULL,
WORKFLOW_ROLES_JSON CLOB NOT NULL,
TASK_ROLES_JSON CLOB NOT NULL,
RUNTIME_ROLES_JSON CLOB NOT NULL,
EFFECTIVE_ROLES_JSON CLOB NOT NULL,
PAYLOAD_JSON CLOB NOT NULL,
CREATED_ON_UTC TIMESTAMP(6) NOT NULL,
COMPLETED_ON_UTC TIMESTAMP(6) NULL,
STALE_AFTER_UTC TIMESTAMP(6) NULL,
PURGE_AFTER_UTC TIMESTAMP(6) NULL,
CONSTRAINT WF_TASKS_PK PRIMARY KEY (WF_TASK_PK),
CONSTRAINT WF_TASKS_ID_UK UNIQUE (WF_TASK_ID),
CONSTRAINT WF_TASKS_INSTANCE_FK FOREIGN KEY (WF_INSTANCE_ID) REFERENCES WF_INSTANCES (WF_INSTANCE_ID)
)
""",
cancellationToken);
await EnsureTableAsync(
"WF_TASK_EVENTS",
"""
CREATE TABLE WF_TASK_EVENTS (
WF_TASK_EVENT_PK NUMBER(18, 0) NOT NULL,
WF_TASK_ID VARCHAR2(128 CHAR) NOT NULL,
EVENT_TYPE VARCHAR2(64 CHAR) NOT NULL,
ACTOR_ID VARCHAR2(128 CHAR) NULL,
PAYLOAD_JSON CLOB NOT NULL,
CREATED_ON_UTC TIMESTAMP(6) NOT NULL,
CONSTRAINT WF_TASK_EVENTS_PK PRIMARY KEY (WF_TASK_EVENT_PK),
CONSTRAINT WF_TASK_EVENTS_TASK_FK FOREIGN KEY (WF_TASK_ID) REFERENCES WF_TASKS (WF_TASK_ID)
)
""",
cancellationToken);
await EnsureTableAsync(
"WF_RUNTIME_STATES",
"""
CREATE TABLE WF_RUNTIME_STATES (
WF_INSTANCE_ID VARCHAR2(128 CHAR) NOT NULL,
WF_NAME VARCHAR2(128 CHAR) NOT NULL,
WF_VERSION VARCHAR2(64 CHAR) NOT NULL,
VERSION_NO NUMBER(18, 0) NOT NULL,
BUSINESS_ID VARCHAR2(128 CHAR) NULL,
BUSINESS_REFERENCE_JSON CLOB NULL,
RUNTIME_PROVIDER VARCHAR2(64 CHAR) NOT NULL,
RUNTIME_INSTANCE_ID VARCHAR2(128 CHAR) NOT NULL,
RUNTIME_STATUS VARCHAR2(32 CHAR) NOT NULL,
STATE_JSON CLOB NOT NULL,
CREATED_ON_UTC TIMESTAMP(6) NOT NULL,
COMPLETED_ON_UTC TIMESTAMP(6) NULL,
STALE_AFTER_UTC TIMESTAMP(6) NULL,
PURGE_AFTER_UTC TIMESTAMP(6) NULL,
LAST_UPDATED_ON_UTC TIMESTAMP(6) NOT NULL,
CONSTRAINT WF_RUNTIME_STATES_PK PRIMARY KEY (WF_INSTANCE_ID)
)
""",
cancellationToken);
await EnsureTableAsync(
"WF_HOST_LOCKS",
"""
CREATE TABLE WF_HOST_LOCKS (
LOCK_NAME VARCHAR2(128 CHAR) NOT NULL,
LOCK_OWNER VARCHAR2(256 CHAR) NOT NULL,
ACQUIRED_ON_UTC TIMESTAMP(6) NOT NULL,
EXPIRES_ON_UTC TIMESTAMP(6) NOT NULL,
CONSTRAINT WF_HOST_LOCKS_PK PRIMARY KEY (LOCK_NAME)
)
""",
cancellationToken);
workflowStorageInitialized = true;
}
finally
{
workflowStorageSync.Release();
}
}
private async Task ResetWorkflowStorageAsync(CancellationToken cancellationToken)
{
await ExecuteNonQueryAsync(
ConnectionString,
"""
BEGIN
DELETE FROM WF_TASK_EVENTS;
DELETE FROM WF_TASKS;
DELETE FROM WF_RUNTIME_STATES;
DELETE FROM WF_INSTANCES;
DELETE FROM WF_HOST_LOCKS;
COMMIT;
END;
""",
cancellationToken);
}
public void Dispose()
{
if (!started)
{
return;
}
try
{
RunDockerCommandAsync($"rm -f {containerName}", ignoreErrors: true, CancellationToken.None)
.GetAwaiter()
.GetResult();
}
catch
{
}
finally
{
OracleConnection.ClearAllPools();
workflowStorageInitialized = false;
started = false;
}
}
private async Task WaitUntilReadyAsync(CancellationToken cancellationToken)
{
var timeoutAt = DateTime.UtcNow.AddMinutes(8);
while (DateTime.UtcNow < timeoutAt)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await using var connection = new OracleConnection(ConnectionString);
await connection.OpenAsync(cancellationToken);
return;
}
catch
{
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
}
}
Assert.Ignore("Oracle Docker container did not become ready in time for AQ integration tests.");
}
private async Task EnsurePrivilegesAsync(CancellationToken cancellationToken)
{
await ExecuteNonQueryAsync(
AdminConnectionString,
$"""
BEGIN
EXECUTE IMMEDIATE 'GRANT AQ_ADMINISTRATOR_ROLE TO {SchemaName}';
EXECUTE IMMEDIATE 'GRANT AQ_USER_ROLE TO {SchemaName}';
EXECUTE IMMEDIATE 'GRANT EXECUTE ON DBMS_AQ TO {SchemaName}';
EXECUTE IMMEDIATE 'GRANT EXECUTE ON DBMS_AQADM TO {SchemaName}';
EXECUTE IMMEDIATE 'GRANT MANAGE ANY QUEUE TO {SchemaName}';
EXECUTE IMMEDIATE 'GRANT ENQUEUE ANY QUEUE TO {SchemaName}';
EXECUTE IMMEDIATE 'GRANT DEQUEUE ANY QUEUE TO {SchemaName}';
END;
""",
cancellationToken);
}
private async Task EnsureSequenceAsync(
string sequenceName,
string createStatement,
CancellationToken cancellationToken)
{
await ExecuteNonQueryAsync(
ConnectionString,
$"""
DECLARE
sequence_count NUMBER := 0;
BEGIN
SELECT COUNT(*)
INTO sequence_count
FROM USER_SEQUENCES
WHERE SEQUENCE_NAME = '{sequenceName}';
IF sequence_count = 0 THEN
EXECUTE IMMEDIATE q'[ {createStatement} ]';
END IF;
END;
""",
cancellationToken);
}
private async Task EnsureTableAsync(
string tableName,
string createStatement,
CancellationToken cancellationToken)
{
await ExecuteNonQueryAsync(
ConnectionString,
$"""
DECLARE
table_count NUMBER := 0;
BEGIN
SELECT COUNT(*)
INTO table_count
FROM USER_TABLES
WHERE TABLE_NAME = '{tableName}';
IF table_count = 0 THEN
EXECUTE IMMEDIATE q'[ {createStatement} ]';
END IF;
END;
""",
cancellationToken);
}
private static async Task ExecuteNonQueryAsync(
string connectionString,
string commandText,
CancellationToken cancellationToken)
{
const int maxAttempts = 6;
var delay = TimeSpan.FromSeconds(2);
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await using var connection = new OracleConnection(connectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = commandText;
await command.ExecuteNonQueryAsync(cancellationToken);
return;
}
catch (OracleException ex) when (attempt < maxAttempts && IsTransientOracleSetupError(ex))
{
await Task.Delay(delay, cancellationToken);
delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 10));
}
}
}
private static bool IsTransientOracleSetupError(OracleException exception)
{
return Array.IndexOf(TransientOracleErrorNumbers, exception.Number) >= 0;
}
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();
}
}
}
internal sealed record OracleAqQueueSet
{
public required string SignalQueueName { get; init; }
public required string SignalQueueTableName { get; init; }
public required string DeadLetterQueueName { get; init; }
public required string DeadLetterQueueTableName { get; init; }
}

View File

@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Oracle.ManagedDataAccess.Client;
namespace StellaOps.Workflow.DataStore.Oracle.Tests.Performance;
internal static class OraclePerformanceMetricsCollector
{
private static readonly int[] TransientOracleErrorNumbers = [1012, 1033, 1089, 12170, 12514, 12516, 12528, 12537, 12541];
private static readonly string[] SysStatNames =
[
"user commits",
"user rollbacks",
"execute count",
"parse count (total)",
"session logical reads",
"db block gets",
"consistent gets",
"physical reads",
"physical writes",
"redo size",
"bytes sent via SQL*Net to client",
"bytes received via SQL*Net from client",
];
private static readonly string[] TimeModelNames =
[
"DB time",
"DB CPU",
"sql execute elapsed time",
"PL/SQL execution elapsed time",
"connection management call elapsed time",
];
public static async Task<OraclePerformanceMetrics> CaptureAsync(CancellationToken cancellationToken = default)
{
const int maxAttempts = 5;
var connectionString = OracleAqIntegrationLifetime.Fixture.DiagnosticsConnectionString;
var delay = TimeSpan.FromSeconds(1);
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await using var connection = new OracleConnection(connectionString);
await connection.OpenAsync(cancellationToken);
var instanceInfo = await ReadInstanceInfoAsync(connection, cancellationToken);
var sysStats = await ReadNamedNumberMapAsync(
connection,
SysStatNames,
"V$SYSSTAT",
"NAME",
cancellationToken);
var timeModel = await ReadNamedNumberMapAsync(
connection,
TimeModelNames,
"V$SYS_TIME_MODEL",
"STAT_NAME",
cancellationToken);
var waitEvents = await ReadWaitEventsAsync(connection, cancellationToken);
return new OraclePerformanceMetrics
{
CapturedAtUtc = DateTime.UtcNow,
InstanceName = instanceInfo.InstanceName,
HostName = instanceInfo.HostName,
Version = instanceInfo.Version,
SysStats = sysStats,
TimeModel = timeModel,
WaitEvents = waitEvents,
};
}
catch (OracleException ex) when (attempt < maxAttempts && IsTransientOracleError(ex))
{
await Task.Delay(delay, cancellationToken);
delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 8));
}
}
throw new InvalidOperationException("Oracle performance metrics could not be captured after retries.");
}
private static bool IsTransientOracleError(OracleException exception)
{
return Array.IndexOf(TransientOracleErrorNumbers, exception.Number) >= 0;
}
private static async Task<(string InstanceName, string HostName, string Version)> ReadInstanceInfoAsync(
OracleConnection connection,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = "SELECT INSTANCE_NAME, HOST_NAME, VERSION FROM V$INSTANCE";
await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow, cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return ("unknown", "unknown", "unknown");
}
return (
reader.GetString(0),
reader.GetString(1),
reader.GetString(2));
}
private static async Task<Dictionary<string, long>> ReadNamedNumberMapAsync(
OracleConnection connection,
IReadOnlyCollection<string> names,
string viewName,
string nameColumn,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
var nameList = string.Join(", ", names.Select(name => $"'{name.Replace("'", "''", StringComparison.Ordinal)}'"));
command.CommandText = $"SELECT {nameColumn}, VALUE FROM {viewName} WHERE {nameColumn} IN ({nameList})";
var results = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var name = reader.GetString(0);
var value = Convert.ToInt64(reader.GetDecimal(1), CultureInfo.InvariantCulture);
results[name] = value;
}
foreach (var name in names)
{
results.TryAdd(name, 0L);
}
return results;
}
private static async Task<IReadOnlyList<OracleWaitEventMetric>> ReadWaitEventsAsync(
OracleConnection connection,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText =
"""
SELECT EVENT, TOTAL_WAITS, TIME_WAITED_MICRO
FROM V$SYSTEM_EVENT
WHERE WAIT_CLASS <> 'Idle'
""";
var results = new List<OracleWaitEventMetric>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(new OracleWaitEventMetric
{
EventName = reader.GetString(0),
TotalWaits = Convert.ToInt64(reader.GetDecimal(1), CultureInfo.InvariantCulture),
TimeWaitedMicroseconds = Convert.ToInt64(reader.GetDecimal(2), CultureInfo.InvariantCulture),
});
}
return results;
}
}
internal sealed record OraclePerformanceMetrics
{
public required DateTime CapturedAtUtc { get; init; }
public required string InstanceName { get; init; }
public required string HostName { get; init; }
public required string Version { get; init; }
public required IReadOnlyDictionary<string, long> SysStats { get; init; }
public required IReadOnlyDictionary<string, long> TimeModel { get; init; }
public required IReadOnlyList<OracleWaitEventMetric> WaitEvents { get; init; }
}
internal sealed record OracleWaitEventMetric
{
public required string EventName { get; init; }
public required long TotalWaits { get; init; }
public required long TimeWaitedMicroseconds { get; init; }
}
internal sealed record OraclePerformanceDelta
{
public required string InstanceName { get; init; }
public required string HostName { get; init; }
public required string Version { get; init; }
public required IReadOnlyDictionary<string, long> SysStatDeltas { get; init; }
public required IReadOnlyDictionary<string, long> TimeModelDeltas { get; init; }
public required IReadOnlyList<OracleWaitEventMetric> TopWaitDeltas { get; init; }
public static OraclePerformanceDelta Create(
OraclePerformanceMetrics before,
OraclePerformanceMetrics after,
int topWaitCount = 8)
{
var sysStatDeltas = after.SysStats
.ToDictionary(
pair => pair.Key,
pair => pair.Value - before.SysStats.GetValueOrDefault(pair.Key),
StringComparer.OrdinalIgnoreCase);
var timeModelDeltas = after.TimeModel
.ToDictionary(
pair => pair.Key,
pair => pair.Value - before.TimeModel.GetValueOrDefault(pair.Key),
StringComparer.OrdinalIgnoreCase);
var beforeWaits = before.WaitEvents.ToDictionary(x => x.EventName, StringComparer.OrdinalIgnoreCase);
var topWaits = after.WaitEvents
.Select(wait =>
{
beforeWaits.TryGetValue(wait.EventName, out var previous);
return new OracleWaitEventMetric
{
EventName = wait.EventName,
TotalWaits = wait.TotalWaits - (previous?.TotalWaits ?? 0L),
TimeWaitedMicroseconds = wait.TimeWaitedMicroseconds - (previous?.TimeWaitedMicroseconds ?? 0L),
};
})
.Where(wait => wait.TotalWaits > 0 || wait.TimeWaitedMicroseconds > 0)
.OrderByDescending(wait => wait.TimeWaitedMicroseconds)
.ThenByDescending(wait => wait.TotalWaits)
.Take(topWaitCount)
.ToArray();
return new OraclePerformanceDelta
{
InstanceName = after.InstanceName,
HostName = after.HostName,
Version = after.Version,
SysStatDeltas = sysStatDeltas,
TimeModelDeltas = timeModelDeltas,
TopWaitDeltas = topWaits,
};
}
}

View File

@@ -0,0 +1,316 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using StellaOps.Workflow.Signaling.Redis.Tests;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.Oracle.Tests.Performance;
[TestFixture]
[Category("Integration")]
[NonParallelizable]
public class OracleRedisSignalDriverPerformanceTests
{
private RedisDockerFixture? redisFixture;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
redisFixture = new RedisDockerFixture();
await redisFixture.StartOrIgnoreAsync();
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
redisFixture?.Dispose();
}
[Test]
[Category(WorkflowPerformanceCategories.Latency)]
public async Task OracleAqEnginePerfLatency_WhenRedisWakeDriverRunsSerially_ShouldWriteArtifacts()
{
const int workflowCount = 16;
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
using var provider = OracleAqPerformanceTestSupport.CreateTransportProvider(
queueSet,
signalDriverProvider: WorkflowSignalDriverNames.Redis,
redisConnectionString: redisFixture!.ConnectionString,
redisChannelName: $"stella:test:workflow:perf:oracle:latency:{Guid.NewGuid():N}");
await using var hostedServices = await WorkflowEnginePerformanceSupport.StartHostedServicesAsync(provider);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var startLatencies = new ConcurrentBag<TimeSpan>();
var signalPublishLatencies = new ConcurrentBag<TimeSpan>();
var signalToCompletionLatencies = new ConcurrentBag<TimeSpan>();
var signalToFirstCompletionLatencies = new ConcurrentBag<TimeSpan>();
var drainToIdleOverhangLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, oracleMetrics) = await OracleAqPerformanceTestSupport.MeasureWithOracleMetricsAsync(async () =>
{
var totalProcessedSignals = 0;
for (var index = 0; index < workflowCount; index++)
{
var operationStartedAtUtc = DateTime.UtcNow;
var startMeasureStartedAtUtc = DateTime.UtcNow;
var startResponse = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "OracleAqPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 994000L + index,
},
}));
startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc);
var signalRaisedAtUtc = DateTime.UtcNow;
var signalPublishStartedAtUtc = signalRaisedAtUtc;
await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 864000L + index,
},
}));
signalPublishLatencies.Add(DateTime.UtcNow - signalPublishStartedAtUtc);
var drain = await OracleAqPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleDetailedAsync(
provider,
TimeSpan.FromSeconds(30),
workerCount: 1);
totalProcessedSignals += drain.ProcessedCount;
var instance = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
drain.FirstProcessedAtUtc.Should().NotBeNull();
drain.LastProcessedAtUtc.Should().NotBeNull();
signalToFirstCompletionLatencies.Add(drain.FirstProcessedAtUtc!.Value - signalRaisedAtUtc);
drainToIdleOverhangLatencies.Add(drain.CompletedAtUtc - drain.LastProcessedAtUtc!.Value);
signalToCompletionLatencies.Add(drain.CompletedAtUtc - signalRaisedAtUtc);
endToEndLatencies.Add(drain.CompletedAtUtc - operationStartedAtUtc);
}
return totalProcessedSignals;
});
var completedAtUtc = DateTime.UtcNow;
processedSignals.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "oracle-redis-signal-roundtrip-latency-serial",
Tier = WorkflowPerformanceCategories.Latency,
EnvironmentName = "oracle-aq-docker+redis-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
PhaseLatencySummaries = new Dictionary<string, WorkflowPerformanceLatencySummary>
{
["drainToIdleOverhang"] = WorkflowPerformanceLatencySummary.FromSamples(drainToIdleOverhangLatencies)!,
["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!,
["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!,
["signalToFirstCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToFirstCompletionLatencies)!,
["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!,
},
BackendMetrics = oracleMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "OracleAqPerfSignalRoundTripWorkflow",
["queueName"] = queueSet.SignalQueueName,
["measurementKind"] = "serial-latency",
["signalDriver"] = WorkflowSignalDriverNames.Redis,
},
});
}
[Test]
[Category(WorkflowPerformanceCategories.Throughput)]
public async Task OracleAqEnginePerfThroughput_WhenRedisWakeDriverRunsWithParallelWorkers_ShouldWriteArtifacts()
{
const int workflowCount = 96;
const int operationConcurrency = 16;
const int workerCount = 8;
var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync();
using var provider = OracleAqPerformanceTestSupport.CreateTransportProvider(
queueSet,
signalDriverProvider: WorkflowSignalDriverNames.Redis,
redisConnectionString: redisFixture!.ConnectionString,
redisChannelName: $"stella:test:workflow:perf:oracle:throughput:{Guid.NewGuid():N}");
await using var hostedServices = await WorkflowEnginePerformanceSupport.StartHostedServicesAsync(provider);
var warmupResponse = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "OracleAqPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 993999L,
},
}));
await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = warmupResponse.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 863999L,
},
}));
await OracleAqPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(provider, TimeSpan.FromSeconds(20), workerCount: 2);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var startLatencies = new ConcurrentBag<TimeSpan>();
var signalPublishLatencies = new ConcurrentBag<TimeSpan>();
var signalToCompletionLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, oracleMetrics) = await OracleAqPerformanceTestSupport.MeasureWithOracleMetricsAsync(async () =>
{
var startResponses = await OracleAqPerformanceTestSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
operationConcurrency,
async index =>
{
var operationStartedAtUtc = DateTime.UtcNow;
var startMeasureStartedAtUtc = operationStartedAtUtc;
var response = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "OracleAqPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 993000L + index,
},
}));
startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc);
return (Index: index, Response: response, StartedAtUtc: operationStartedAtUtc);
});
var signalRaisedAtUtc = new ConcurrentDictionary<string, DateTime>(StringComparer.Ordinal);
await OracleAqPerformanceTestSupport.RunConcurrentAsync(
startResponses,
operationConcurrency,
async startResponse =>
{
var publishStartedAtUtc = DateTime.UtcNow;
signalRaisedAtUtc[startResponse.Response.WorkflowInstanceId] = publishStartedAtUtc;
await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 863000L + startResponse.Index,
},
}));
signalPublishLatencies.Add(DateTime.UtcNow - publishStartedAtUtc);
return true;
});
var totalProcessedSignals = await OracleAqPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(
provider,
TimeSpan.FromSeconds(90),
workerCount);
await OracleAqPerformanceTestSupport.RunConcurrentAsync(
startResponses,
operationConcurrency,
async startResponse =>
{
var instance = await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
var completedAtUtc = DateTime.UtcNow;
endToEndLatencies.Add(completedAtUtc - startResponse.StartedAtUtc);
signalToCompletionLatencies.Add(completedAtUtc - signalRaisedAtUtc[startResponse.Response.WorkflowInstanceId]);
return true;
});
return totalProcessedSignals;
});
var completedAtUtc = DateTime.UtcNow;
processedSignals.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "oracle-redis-signal-roundtrip-throughput-parallel",
Tier = WorkflowPerformanceCategories.Throughput,
EnvironmentName = "oracle-aq-docker+redis-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = operationConcurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
PhaseLatencySummaries = new Dictionary<string, WorkflowPerformanceLatencySummary>
{
["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!,
["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!,
["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!,
},
BackendMetrics = oracleMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "OracleAqPerfSignalRoundTripWorkflow",
["queueName"] = queueSet.SignalQueueName,
["workerCount"] = workerCount.ToString(),
["measurementKind"] = "steady-throughput",
["signalDriver"] = WorkflowSignalDriverNames.Redis,
},
});
}
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Workflow.DataStore.Oracle.Tests.Performance;
internal static class OracleWorkflowPerformanceMetricsExtensions
{
public static WorkflowPerformanceBackendMetrics ToBackendMetrics(this OraclePerformanceDelta delta)
{
ArgumentNullException.ThrowIfNull(delta);
return new WorkflowPerformanceBackendMetrics
{
BackendName = "Oracle",
InstanceName = delta.InstanceName,
HostName = delta.HostName,
Version = delta.Version,
CounterDeltas = new Dictionary<string, long>(delta.SysStatDeltas, StringComparer.OrdinalIgnoreCase),
DurationDeltas = new Dictionary<string, long>(delta.TimeModelDeltas, StringComparer.OrdinalIgnoreCase),
TopWaitDeltas = delta.TopWaitDeltas
.Select(wait => new WorkflowPerformanceWaitMetric
{
Name = wait.EventName,
TotalCount = wait.TotalWaits,
DurationMicroseconds = wait.TimeWaitedMicroseconds,
})
.ToArray(),
};
}
}

View File

@@ -0,0 +1,129 @@
using System;
using System.Threading.Tasks;
using StellaOps.Workflow.DataStore.Oracle;
using StellaOps.Workflow.Abstractions;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.Oracle.Tests;
[TestFixture]
public class OracleWorkflowRuntimeStateStoreTests
{
[Test]
public async Task UpsertAsync_WhenVersionAdvancesSequentially_ShouldPersistLatestState()
{
using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var dbContext = CreateDbContext(connection);
var store = new OracleWorkflowRuntimeStateStore(dbContext);
await store.UpsertAsync(CreateState(version: 1, stateJson: """{"version":1}"""));
await store.UpsertAsync(CreateState(version: 2, stateJson: """{"version":2}"""));
var state = await store.GetAsync("wf-1");
state.Should().NotBeNull();
state!.Version.Should().Be(2);
state.StateJson.Should().Be("""{"version":2}""");
}
[Test]
public async Task UpsertAsync_WhenVersionIsStale_ShouldThrowConcurrencyException()
{
using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var dbContext = CreateDbContext(connection);
var store = new OracleWorkflowRuntimeStateStore(dbContext);
await store.UpsertAsync(CreateState(version: 1, stateJson: """{"version":1}"""));
await store.UpsertAsync(CreateState(version: 2, stateJson: """{"version":2}"""));
var act = async () => await store.UpsertAsync(CreateState(version: 2, stateJson: """{"version":2,"retry":true}"""));
await act.Should().ThrowAsync<WorkflowRuntimeStateConcurrencyException>()
.Where(x => x.WorkflowInstanceId == "wf-1" && x.ExpectedVersion == 2 && x.ActualVersion == 2);
}
[Test]
public async Task UpsertAsync_WhenNonVersionedStateOverwrites_ShouldAllowLegacyProviders()
{
using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var dbContext = CreateDbContext(connection);
var store = new OracleWorkflowRuntimeStateStore(dbContext);
await store.UpsertAsync(CreateState(
version: 0,
runtimeProvider: WorkflowRuntimeProviderNames.Engine,
stateJson: """{"bookmarkCount":1}"""));
await store.UpsertAsync(CreateState(
version: 0,
runtimeProvider: WorkflowRuntimeProviderNames.Engine,
stateJson: """{"bookmarkCount":2}"""));
var state = await store.GetAsync("wf-1");
state.Should().NotBeNull();
state!.Version.Should().Be(0);
state.StateJson.Should().Be("""{"bookmarkCount":2}""");
}
private static WorkflowDbContext CreateDbContext(SqliteConnection connection)
{
var options = new DbContextOptionsBuilder<WorkflowDbContext>()
.UseSqlite(connection)
.Options;
var dbContext = new WorkflowDbContext(options);
InitializeRuntimeStateSchema(dbContext);
return dbContext;
}
private static void InitializeRuntimeStateSchema(WorkflowDbContext dbContext)
{
dbContext.Database.ExecuteSqlRaw(
"""
CREATE TABLE IF NOT EXISTS WF_RUNTIME_STATES (
WF_INSTANCE_ID TEXT NOT NULL PRIMARY KEY,
WF_NAME TEXT NOT NULL,
WF_VERSION TEXT NOT NULL,
VERSION_NO INTEGER NOT NULL,
BUSINESS_ID TEXT NULL,
BUSINESS_REFERENCE_JSON TEXT NULL,
RUNTIME_PROVIDER TEXT NOT NULL,
RUNTIME_INSTANCE_ID TEXT NOT NULL,
RUNTIME_STATUS TEXT NOT NULL,
STATE_JSON TEXT NOT NULL,
CREATED_ON_UTC TEXT NOT NULL,
COMPLETED_ON_UTC TEXT NULL,
STALE_AFTER_UTC TEXT NULL,
PURGE_AFTER_UTC TEXT NULL,
LAST_UPDATED_ON_UTC TEXT NOT NULL
);
""");
}
private static WorkflowRuntimeStateRecord CreateState(
long version,
string stateJson,
string runtimeProvider = WorkflowRuntimeProviderNames.Engine)
{
return new WorkflowRuntimeStateRecord
{
WorkflowInstanceId = "wf-1",
WorkflowName = "TestWorkflow",
WorkflowVersion = "1.0.0",
Version = version,
RuntimeProvider = runtimeProvider,
RuntimeInstanceId = "wf-1",
RuntimeStatus = "Open",
StateJson = stateJson,
CreatedOnUtc = DateTime.UtcNow,
LastUpdatedOnUtc = DateTime.UtcNow,
};
}
}

View File

@@ -0,0 +1,55 @@
<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="Moq" Version="4.20.72" />
<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="Microsoft.Data.Sqlite" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="23.26.100" />
</ItemGroup>
<!-- TODO: These files reference Serdica/Bulstrad-specific workflow types (Engine.Helpers, Engine.Workflows.Bulstrad)
that are not in this repository. Re-enable when those types are ported to Stella. -->
<ItemGroup>
<Compile Remove="OracleAqBulstradWorkflowIntegrationTests.cs" />
<Compile Remove="OracleAqPerformanceTestSupport.cs" />
<Compile Remove="OracleAqPerformanceCapacityTests.cs" />
<Compile Remove="OracleAqPerformanceLatencyTests.cs" />
<Compile Remove="OracleAqPerformanceNightlyTests.cs" />
<Compile Remove="OracleAqPerformanceSmokeTests.cs" />
<Compile Remove="OracleAqPerformanceSoakTests.cs" />
<Compile Remove="OracleAqPerformanceThroughputTests.cs" />
<Compile Remove="OraclePerformanceMetricsCollector.cs" />
<Compile Remove="OracleRedisSignalDriverPerformanceTests.cs" />
<Compile Remove="OracleWorkflowPerformanceMetricsExtensions.cs" />
</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.DataStore.Oracle\StellaOps.Workflow.DataStore.Oracle.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Signaling.OracleAq\StellaOps.Workflow.Signaling.OracleAq.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Signaling.Redis\StellaOps.Workflow.Signaling.Redis.csproj" />
<ProjectReference Include="..\StellaOps.Workflow.IntegrationTests.Shared\StellaOps.Workflow.IntegrationTests.Shared.csproj" />
<ProjectReference Include="..\StellaOps.Workflow.Signaling.Redis.Tests\StellaOps.Workflow.Signaling.Redis.Tests.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,187 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.PostgreSQL;
using FluentAssertions;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests.Performance;
[TestFixture]
[NonParallelizable]
[Category("Performance")]
[Category(WorkflowPerformanceCategories.Capacity)]
public class PostgresPerformanceCapacityTests
{
private PostgresDockerFixture? fixture;
private PostgresWorkflowBackendOptions? options;
private PostgresWorkflowSqlBuilder? sqlBuilder;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new PostgresDockerFixture();
await fixture.StartOrIgnoreAsync();
options = PostgresPerformanceTestSupport.CreateOptions("wf_perf_capacity");
sqlBuilder = new PostgresWorkflowSqlBuilder(Options.Create(options));
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetSchemaAsync(options!.SchemaName, sqlBuilder!.BuildStorageBootstrapSql());
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task PostgresEnginePerfCapacity_WhenSyntheticSignalRoundTripRunsAcrossConcurrencyLadder_ShouldWriteArtifacts()
{
var concurrencyLadder = new[] { 1, 4, 8, 16 };
var configuration = PostgresPerformanceTestSupport.CreateConfiguration(options!, fixture!.ConnectionString);
using var provider = PostgresPerformanceTestSupport.CreateTransportProvider(configuration);
var warmupStart = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "PostgresPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 993999L,
},
}));
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = warmupStart.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 869998L,
},
}));
await PostgresPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(provider, TimeSpan.FromSeconds(20), workerCount: 2);
foreach (var concurrency in concurrencyLadder)
{
var workflowCount = concurrency * 16;
var workerCount = Math.Min(concurrency, 8);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var (capacityResult, postgresMetrics) = await PostgresPerformanceTestSupport.MeasureWithPostgresMetricsAsync(
fixture!.ConnectionString,
async () =>
{
var startResponses = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
concurrency,
async index =>
{
var started = DateTime.UtcNow;
var response = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "PostgresPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 994000L + (concurrency * 1000L) + index,
},
}));
return (Index: index, Response: response, StartedAtUtc: started);
});
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
startResponses,
concurrency,
async startResponse =>
{
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 870000L + (concurrency * 1000L) + startResponse.Index,
},
}));
return true;
});
var processedSignals = await PostgresPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(
provider,
TimeSpan.FromSeconds(60),
workerCount);
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
startResponses,
workerCount,
async startResponse =>
{
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
endToEndLatencies.Add(DateTime.UtcNow - startResponse.StartedAtUtc);
return true;
});
return processedSignals;
});
var completedAtUtc = DateTime.UtcNow;
capacityResult.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = $"postgres-signal-roundtrip-capacity-c{concurrency}",
Tier = WorkflowPerformanceCategories.Capacity,
EnvironmentName = "postgres-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = capacityResult,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
BackendMetrics = postgresMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "PostgresPerfSignalRoundTripWorkflow",
["ladder"] = string.Join(",", concurrencyLadder),
["workerCount"] = workerCount.ToString(),
},
});
}
}
}

View File

@@ -0,0 +1,166 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.PostgreSQL;
using FluentAssertions;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests.Performance;
[TestFixture]
[NonParallelizable]
[Category("Performance")]
[Category(WorkflowPerformanceCategories.Latency)]
public class PostgresPerformanceLatencyTests
{
private PostgresDockerFixture? fixture;
private PostgresWorkflowBackendOptions? options;
private PostgresWorkflowSqlBuilder? sqlBuilder;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new PostgresDockerFixture();
await fixture.StartOrIgnoreAsync();
options = PostgresPerformanceTestSupport.CreateOptions("wf_perf_latency");
sqlBuilder = new PostgresWorkflowSqlBuilder(Options.Create(options));
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetSchemaAsync(options!.SchemaName, sqlBuilder!.BuildStorageBootstrapSql());
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task PostgresEnginePerfLatency_WhenSignalRoundTripRunsSerially_ShouldWriteArtifacts()
{
const int workflowCount = 16;
var configuration = PostgresPerformanceTestSupport.CreateConfiguration(options!, fixture!.ConnectionString);
using var provider = PostgresPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var startLatencies = new ConcurrentBag<TimeSpan>();
var signalPublishLatencies = new ConcurrentBag<TimeSpan>();
var signalToCompletionLatencies = new ConcurrentBag<TimeSpan>();
var signalToFirstCompletionLatencies = new ConcurrentBag<TimeSpan>();
var drainToIdleOverhangLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, postgresMetrics) = await PostgresPerformanceTestSupport.MeasureWithPostgresMetricsAsync(
fixture.ConnectionString,
async () =>
{
var totalProcessedSignals = 0;
for (var index = 0; index < workflowCount; index++)
{
var operationStartedAtUtc = DateTime.UtcNow;
var startMeasureStartedAtUtc = DateTime.UtcNow;
var startResponse = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "PostgresPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 992000L + index,
},
}));
startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc);
var signalPublishStartedAtUtc = DateTime.UtcNow;
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 862000L + index,
},
}));
signalPublishLatencies.Add(DateTime.UtcNow - signalPublishStartedAtUtc);
var drain = await PostgresPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleDetailedAsync(
provider,
TimeSpan.FromSeconds(20),
workerCount: 1);
totalProcessedSignals += drain.ProcessedCount;
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
drain.FirstProcessedAtUtc.Should().NotBeNull();
drain.LastProcessedAtUtc.Should().NotBeNull();
endToEndLatencies.Add(drain.CompletedAtUtc - operationStartedAtUtc);
signalToFirstCompletionLatencies.Add(drain.FirstProcessedAtUtc!.Value - signalPublishStartedAtUtc);
drainToIdleOverhangLatencies.Add(drain.CompletedAtUtc - drain.LastProcessedAtUtc!.Value);
signalToCompletionLatencies.Add(drain.CompletedAtUtc - signalPublishStartedAtUtc);
}
return totalProcessedSignals;
});
var completedAtUtc = DateTime.UtcNow;
processedSignals.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "postgres-signal-roundtrip-latency-serial",
Tier = WorkflowPerformanceCategories.Latency,
EnvironmentName = "postgres-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
PhaseLatencySummaries = new Dictionary<string, WorkflowPerformanceLatencySummary>
{
["drainToIdleOverhang"] = WorkflowPerformanceLatencySummary.FromSamples(drainToIdleOverhangLatencies)!,
["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!,
["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!,
["signalToFirstCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToFirstCompletionLatencies)!,
["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!,
},
BackendMetrics = postgresMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "PostgresPerfSignalRoundTripWorkflow",
["measurementKind"] = "serial-latency",
},
});
}
}

View File

@@ -0,0 +1,210 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using Npgsql;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests.Performance;
internal static class PostgresPerformanceMetricsCollector
{
public static async Task<PostgresPerformanceMetrics> CaptureAsync(
string connectionString,
CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
var instanceInfo = await ReadInstanceInfoAsync(connection, cancellationToken);
var databaseStats = await ReadDatabaseStatsAsync(connection, cancellationToken);
var waitCounts = await ReadWaitCountsAsync(connection, cancellationToken);
return new PostgresPerformanceMetrics
{
CapturedAtUtc = DateTime.UtcNow,
DatabaseName = instanceInfo.DatabaseName,
ServerVersion = instanceInfo.ServerVersion,
ServerAddress = instanceInfo.ServerAddress,
CounterStats = databaseStats.CounterStats,
DurationStats = databaseStats.DurationStats,
WaitCounts = waitCounts,
ActiveSessions = databaseStats.ActiveSessions,
NotificationQueueUsage = databaseStats.NotificationQueueUsage,
};
}
private static async Task<(string DatabaseName, string ServerVersion, string ServerAddress)> ReadInstanceInfoAsync(
NpgsqlConnection connection,
CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
"""
select current_database(),
version(),
coalesce(inet_server_addr()::text, 'local')
""",
connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
await reader.ReadAsync(cancellationToken);
return (reader.GetString(0), reader.GetString(1), reader.GetString(2));
}
private static async Task<(Dictionary<string, long> CounterStats, Dictionary<string, long> DurationStats, int ActiveSessions, double NotificationQueueUsage)> ReadDatabaseStatsAsync(
NpgsqlConnection connection,
CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
"""
select xact_commit,
xact_rollback,
blks_read,
blks_hit,
tup_returned,
tup_fetched,
tup_inserted,
tup_updated,
tup_deleted,
temp_bytes,
deadlocks,
coalesce(round(session_time)::bigint, 0),
coalesce(round(active_time)::bigint, 0),
coalesce(round(idle_in_transaction_time)::bigint, 0),
(select count(*) from pg_stat_activity where datname = current_database()),
pg_notification_queue_usage()
from pg_stat_database
where datname = current_database()
""",
connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
await reader.ReadAsync(cancellationToken);
var counters = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase)
{
["xact_commit"] = reader.GetInt64(0),
["xact_rollback"] = reader.GetInt64(1),
["blks_read"] = reader.GetInt64(2),
["blks_hit"] = reader.GetInt64(3),
["tup_returned"] = reader.GetInt64(4),
["tup_fetched"] = reader.GetInt64(5),
["tup_inserted"] = reader.GetInt64(6),
["tup_updated"] = reader.GetInt64(7),
["tup_deleted"] = reader.GetInt64(8),
["temp_bytes"] = reader.GetInt64(9),
["deadlocks"] = reader.GetInt64(10),
};
var durations = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase)
{
["session_time_ms"] = reader.GetInt64(11),
["active_time_ms"] = reader.GetInt64(12),
["idle_in_transaction_time_ms"] = reader.GetInt64(13),
};
return (
counters,
durations,
reader.GetInt32(14),
reader.GetDouble(15));
}
private static async Task<IReadOnlyDictionary<string, long>> ReadWaitCountsAsync(
NpgsqlConnection connection,
CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
"""
select case
when wait_event_type is null then 'Running'
when wait_event is null then wait_event_type
else wait_event_type || ':' || wait_event
end as wait_name,
count(*)::bigint
from pg_stat_activity
where datname = current_database()
and pid <> pg_backend_pid()
group by 1
""",
connection);
var waits = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
waits[reader.GetString(0)] = reader.GetInt64(1);
}
return waits;
}
}
internal sealed record PostgresPerformanceMetrics
{
public required DateTime CapturedAtUtc { get; init; }
public required string DatabaseName { get; init; }
public required string ServerVersion { get; init; }
public required string ServerAddress { get; init; }
public required IReadOnlyDictionary<string, long> CounterStats { get; init; }
public required IReadOnlyDictionary<string, long> DurationStats { get; init; }
public required IReadOnlyDictionary<string, long> WaitCounts { get; init; }
public required int ActiveSessions { get; init; }
public required double NotificationQueueUsage { get; init; }
}
internal sealed record PostgresPerformanceDelta
{
public required string DatabaseName { get; init; }
public required string ServerVersion { get; init; }
public required string ServerAddress { get; init; }
public required IReadOnlyDictionary<string, long> CounterDeltas { get; init; }
public required IReadOnlyDictionary<string, long> DurationDeltas { get; init; }
public required IReadOnlyList<WorkflowPerformanceWaitMetric> TopWaits { get; init; }
public required int ActiveSessions { get; init; }
public required double NotificationQueueUsage { get; init; }
public static PostgresPerformanceDelta Create(
PostgresPerformanceMetrics before,
PostgresPerformanceMetrics after,
int topWaitCount = 8)
{
var counterDeltas = after.CounterStats
.ToDictionary(
pair => pair.Key,
pair => pair.Value - before.CounterStats.GetValueOrDefault(pair.Key),
StringComparer.OrdinalIgnoreCase);
var durationDeltas = after.DurationStats
.ToDictionary(
pair => pair.Key,
pair => pair.Value - before.DurationStats.GetValueOrDefault(pair.Key),
StringComparer.OrdinalIgnoreCase);
var topWaits = after.WaitCounts
.OrderByDescending(pair => pair.Value)
.ThenBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)
.Take(topWaitCount)
.Select(pair => new WorkflowPerformanceWaitMetric
{
Name = pair.Key,
TotalCount = pair.Value,
DurationMicroseconds = 0,
})
.ToArray();
return new PostgresPerformanceDelta
{
DatabaseName = after.DatabaseName,
ServerVersion = after.ServerVersion,
ServerAddress = after.ServerAddress,
CounterDeltas = counterDeltas,
DurationDeltas = durationDeltas,
TopWaits = topWaits,
ActiveSessions = after.ActiveSessions,
NotificationQueueUsage = after.NotificationQueueUsage,
};
}
}

View File

@@ -0,0 +1,373 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.PostgreSQL;
using FluentAssertions;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests.Performance;
[TestFixture]
[NonParallelizable]
[Category("Performance")]
[Category(WorkflowPerformanceCategories.Nightly)]
public class PostgresPerformanceNightlyTests
{
private PostgresDockerFixture? fixture;
private PostgresWorkflowBackendOptions? options;
private PostgresWorkflowSqlBuilder? sqlBuilder;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new PostgresDockerFixture();
await fixture.StartOrIgnoreAsync();
options = PostgresPerformanceTestSupport.CreateOptions("wf_perf_nightly");
sqlBuilder = new PostgresWorkflowSqlBuilder(Options.Create(options));
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetSchemaAsync(options!.SchemaName, sqlBuilder!.BuildStorageBootstrapSql());
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task PostgresTransportPerfNightly_WhenImmediateBurstQueuedAtScale_ShouldDrainAndWriteArtifacts()
{
const int messageCount = 120;
var configuration = PostgresPerformanceTestSupport.CreateConfiguration(options!, fixture!.ConnectionString);
using var provider = PostgresPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var (transportResult, postgresMetrics) = await PostgresPerformanceTestSupport.MeasureWithPostgresMetricsAsync(
fixture.ConnectionString,
() => PostgresPerformanceTestSupport.RunImmediateTransportBurstAsync(
provider,
messageCount,
timeout: TimeSpan.FromSeconds(30),
correlationPrefix: "pg-nightly-immediate"));
var completedAtUtc = DateTime.UtcNow;
transportResult.ProcessedCorrelations.Should().HaveCount(messageCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "postgres-immediate-burst-nightly",
Tier = WorkflowPerformanceCategories.Nightly,
EnvironmentName = "postgres-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = messageCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
SignalsPublished = messageCount,
SignalsProcessed = transportResult.ProcessedCorrelations.Count,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(transportResult.ReceiveLatencies),
BackendMetrics = postgresMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["schemaName"] = options!.SchemaName,
["messageCount"] = messageCount.ToString(),
},
});
}
[Test]
public async Task PostgresTransportPerfNightly_WhenDelayedBurstQueuedAtScale_ShouldDrainAndWriteArtifacts()
{
const int messageCount = 48;
const int delaySeconds = 2;
var configuration = PostgresPerformanceTestSupport.CreateConfiguration(options!, fixture!.ConnectionString);
using var provider = PostgresPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var (transportResult, postgresMetrics) = await PostgresPerformanceTestSupport.MeasureWithPostgresMetricsAsync(
fixture.ConnectionString,
() => PostgresPerformanceTestSupport.RunDelayedTransportBurstAsync(
provider,
messageCount,
delaySeconds,
timeout: TimeSpan.FromSeconds(45),
correlationPrefix: "pg-nightly-delayed"));
var completedAtUtc = DateTime.UtcNow;
transportResult.ProcessedCorrelations.Should().HaveCount(messageCount);
transportResult.ReceiveLatencies.Should().OnlyContain(latency => latency > TimeSpan.FromSeconds(1));
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "postgres-delayed-burst-nightly",
Tier = WorkflowPerformanceCategories.Nightly,
EnvironmentName = "postgres-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = messageCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
SignalsPublished = messageCount,
SignalsProcessed = transportResult.ProcessedCorrelations.Count,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(transportResult.ReceiveLatencies),
BackendMetrics = postgresMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["schemaName"] = options!.SchemaName,
["messageCount"] = messageCount.ToString(),
["delaySeconds"] = delaySeconds.ToString(),
},
});
}
[Test]
public async Task PostgresEnginePerfNightly_WhenSyntheticExternalSignalBacklogResumedAtScale_ShouldDrainAndWriteArtifacts()
{
const int workflowCount = 36;
const int concurrency = 8;
var configuration = PostgresPerformanceTestSupport.CreateConfiguration(options!, fixture!.ConnectionString);
using var provider = PostgresPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var (engineResult, postgresMetrics) = await PostgresPerformanceTestSupport.MeasureWithPostgresMetricsAsync(
fixture.ConnectionString,
async () =>
{
var startResponses = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
concurrency,
async index =>
{
var response = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "PostgresPerfExternalSignalWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 999000L + index,
},
}));
return (Index: index, Response: response, StartedAtUtc: DateTime.UtcNow);
});
var raisedSignalsAt = new ConcurrentDictionary<string, DateTime>(StringComparer.Ordinal);
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
startResponses,
concurrency,
async startResponse =>
{
raisedSignalsAt[startResponse.Response.WorkflowInstanceId] = DateTime.UtcNow;
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 990000L + startResponse.Index,
},
}));
return true;
});
var processedSignals = await PostgresPerformanceTestSupport.DrainSignalsUntilIdleAsync(provider, TimeSpan.FromSeconds(45));
foreach (var startResponse in startResponses)
{
var openTasks = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
Status = WorkflowTaskStatuses.Open,
}));
openTasks.Tasks.Should().ContainSingle();
endToEndLatencies.Add(DateTime.UtcNow - raisedSignalsAt[startResponse.Response.WorkflowInstanceId]);
}
return processedSignals;
});
var completedAtUtc = DateTime.UtcNow;
engineResult.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "postgres-synthetic-external-resume-nightly",
Tier = WorkflowPerformanceCategories.Nightly,
EnvironmentName = "postgres-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
TasksActivated = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = engineResult,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
BackendMetrics = postgresMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "PostgresPerfExternalSignalWorkflow",
},
});
}
[Test]
public async Task PostgresBulstradPerfNightly_WhenQuotationConfirmConvertToPolicyBurstCompleted_ShouldWriteArtifacts()
{
const int workflowCount = 12;
const int concurrency = 4;
var configuration = PostgresPerformanceTestSupport.CreateConfiguration(options!, fixture!.ConnectionString, includeAssignmentRoles: true);
var transports = PostgresPerformanceTestSupport.CreateQuotationConfirmConvertToPolicyTransports();
using var provider = PostgresPerformanceTestSupport.CreateBulstradProvider(configuration, transports);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, postgresMetrics) = await PostgresPerformanceTestSupport.MeasureWithPostgresMetricsAsync(
fixture.ConnectionString,
async () =>
{
var startResponses = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
concurrency,
async index =>
{
var started = DateTime.UtcNow;
var response = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "QuotationConfirm",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 996500L + index,
["srAnnexId"] = 886500L + index,
["srCustId"] = 776500L + index,
},
}));
return (Index: index, Response: response, StartedAtUtc: started);
});
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
startResponses,
concurrency,
async startResponse =>
{
var quotationTask = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
Status = WorkflowTaskStatuses.Open,
}));
var task = quotationTask.Tasks.Should().ContainSingle().Subject;
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
{
WorkflowTaskId = task.WorkflowTaskId,
ActorId = "perf-user",
ActorRoles = ["DBA"],
Payload = new Dictionary<string, object?>
{
["answer"] = "confirm",
},
}));
return true;
});
var processed = await PostgresPerformanceTestSupport.DrainSignalsUntilIdleAsync(provider, TimeSpan.FromSeconds(45));
foreach (var startResponse in startResponses)
{
var quotationInstance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
}));
var pdfInstances = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest
{
WorkflowName = "PdfGenerator",
BusinessReferenceKey = (996500L + startResponse.Index).ToString(),
}));
quotationInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
quotationInstance.WorkflowState["nextStep"]!.ToString().Should().Be("ConvertToPolicy");
pdfInstances.Instances.Should().ContainSingle();
endToEndLatencies.Add(DateTime.UtcNow - startResponse.StartedAtUtc);
}
return processed;
});
var completedAtUtc = DateTime.UtcNow;
transports.LegacyRabbit.Invocations.Count.Should().Be(workflowCount * 4);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "postgres-bulstrad-quotation-confirm-convert-to-policy-nightly",
Tier = WorkflowPerformanceCategories.Nightly,
EnvironmentName = "postgres-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
TasksActivated = workflowCount,
TasksCompleted = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
BackendMetrics = postgresMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "QuotationConfirm",
["legacyRabbitInvocationCount"] = transports.LegacyRabbit.Invocations.Count.ToString(),
["expectedInvocationCount"] = (workflowCount * 4).ToString(),
},
});
}
}

View File

@@ -0,0 +1,237 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.PostgreSQL;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests.Performance;
[TestFixture]
[NonParallelizable]
[Category("Performance")]
[Category(WorkflowPerformanceCategories.Smoke)]
public class PostgresPerformanceSmokeTests
{
private PostgresDockerFixture? fixture;
private PostgresWorkflowBackendOptions? options;
private PostgresWorkflowSqlBuilder? sqlBuilder;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new PostgresDockerFixture();
await fixture.StartOrIgnoreAsync();
options = PostgresPerformanceTestSupport.CreateOptions("wf_perf_smoke");
sqlBuilder = new PostgresWorkflowSqlBuilder(Options.Create(options));
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetSchemaAsync(options!.SchemaName, sqlBuilder!.BuildStorageBootstrapSql());
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task PostgresTransportPerfSmoke_WhenImmediateBurstQueued_ShouldDrainAndWriteArtifacts()
{
const int messageCount = 24;
var configuration = PostgresPerformanceTestSupport.CreateConfiguration(options!, fixture!.ConnectionString);
using var provider = PostgresPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var (transportResult, postgresMetrics) = await PostgresPerformanceTestSupport.MeasureWithPostgresMetricsAsync(
fixture.ConnectionString,
() => PostgresPerformanceTestSupport.RunImmediateTransportBurstAsync(
provider,
messageCount,
timeout: TimeSpan.FromSeconds(20),
correlationPrefix: "pg-perf-immediate"));
var completedAtUtc = DateTime.UtcNow;
transportResult.ProcessedCorrelations.Should().HaveCount(messageCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "postgres-immediate-burst-smoke",
Tier = WorkflowPerformanceCategories.Smoke,
EnvironmentName = "postgres-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = messageCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
SignalsPublished = messageCount,
SignalsProcessed = messageCount,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(transportResult.ReceiveLatencies),
BackendMetrics = postgresMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["schemaName"] = options!.SchemaName,
["signalTable"] = options.SignalQueueTableName,
["messageCount"] = messageCount.ToString(),
},
});
}
[Test]
public async Task PostgresTransportPerfSmoke_WhenDelayedBurstQueued_ShouldDrainAndWriteArtifacts()
{
const int messageCount = 12;
const int delaySeconds = 2;
var configuration = PostgresPerformanceTestSupport.CreateConfiguration(options!, fixture!.ConnectionString);
using var provider = PostgresPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var (transportResult, postgresMetrics) = await PostgresPerformanceTestSupport.MeasureWithPostgresMetricsAsync(
fixture.ConnectionString,
() => PostgresPerformanceTestSupport.RunDelayedTransportBurstAsync(
provider,
messageCount,
delaySeconds,
timeout: TimeSpan.FromSeconds(30),
correlationPrefix: "pg-perf-delayed"));
var completedAtUtc = DateTime.UtcNow;
transportResult.ProcessedCorrelations.Should().HaveCount(messageCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "postgres-delayed-burst-smoke",
Tier = WorkflowPerformanceCategories.Smoke,
EnvironmentName = "postgres-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = messageCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
SignalsPublished = messageCount,
SignalsProcessed = messageCount,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(transportResult.ReceiveLatencies),
BackendMetrics = postgresMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["schemaName"] = options!.SchemaName,
["messageCount"] = messageCount.ToString(),
["delaySeconds"] = delaySeconds.ToString(),
},
});
}
[Test]
public async Task PostgresBulstradPerfSmoke_WhenQuoteOrAplCancelBurstStarted_ShouldCompleteAndWriteArtifacts()
{
const int workflowCount = 10;
const int concurrency = 4;
var configuration = PostgresPerformanceTestSupport.CreateConfiguration(options!, fixture!.ConnectionString, includeAssignmentRoles: true);
var transports = PostgresPerformanceTestSupport.CreateQuoteOrAplCancelSuccessTransports();
using var provider = PostgresPerformanceTestSupport.CreateBulstradProvider(configuration, transports);
var startedAtUtc = DateTime.UtcNow;
var startLatencies = new ConcurrentBag<TimeSpan>();
var (bulstradResult, postgresMetrics) = await PostgresPerformanceTestSupport.MeasureWithPostgresMetricsAsync(
fixture.ConnectionString,
async () =>
{
var workflowIds = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
concurrency,
async index =>
{
var stopwatch = Stopwatch.StartNew();
var response = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "QuoteOrAplCancel",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 995000L + index,
},
}));
stopwatch.Stop();
startLatencies.Add(stopwatch.Elapsed);
return response.WorkflowInstanceId;
});
var completedInstances = 0;
foreach (var workflowId in workflowIds)
{
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
completedInstances++;
}
var openTasks = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowName = "QuoteOrAplCancel",
Status = WorkflowTaskStatuses.Open,
}));
return (CompletedInstances: completedInstances, OpenTaskCount: openTasks.Tasks.Count);
});
var completedAtUtc = DateTime.UtcNow;
bulstradResult.CompletedInstances.Should().Be(workflowCount);
bulstradResult.OpenTaskCount.Should().Be(0);
transports.LegacyRabbit.Invocations.Count.Should().Be(workflowCount * 2);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "postgres-bulstrad-quote-or-apl-cancel-smoke",
Tier = WorkflowPerformanceCategories.Smoke,
EnvironmentName = "postgres-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(startLatencies),
BackendMetrics = postgresMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "QuoteOrAplCancel",
["legacyRabbitInvocationCount"] = transports.LegacyRabbit.Invocations.Count.ToString(),
},
});
}
}

View File

@@ -0,0 +1,173 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.PostgreSQL;
using FluentAssertions;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests.Performance;
[TestFixture]
[NonParallelizable]
[Category("Performance")]
[Category(WorkflowPerformanceCategories.Soak)]
public class PostgresPerformanceSoakTests
{
private PostgresDockerFixture? fixture;
private PostgresWorkflowBackendOptions? options;
private PostgresWorkflowSqlBuilder? sqlBuilder;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new PostgresDockerFixture();
await fixture.StartOrIgnoreAsync();
options = PostgresPerformanceTestSupport.CreateOptions("wf_perf_soak");
sqlBuilder = new PostgresWorkflowSqlBuilder(Options.Create(options));
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetSchemaAsync(options!.SchemaName, sqlBuilder!.BuildStorageBootstrapSql());
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task PostgresEnginePerfSoak_WhenSyntheticSignalRoundTripRunsInWaves_ShouldStayStableAndWriteArtifacts()
{
const int waveCount = 6;
const int workflowsPerWave = 18;
const int concurrency = 8;
const int workerCount = 8;
var configuration = PostgresPerformanceTestSupport.CreateConfiguration(options!, fixture!.ConnectionString);
using var provider = PostgresPerformanceTestSupport.CreateTransportProvider(configuration);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var (soakResult, postgresMetrics) = await PostgresPerformanceTestSupport.MeasureWithPostgresMetricsAsync(
fixture.ConnectionString,
async () =>
{
var totalProcessedSignals = 0;
var totalCompletedInstances = 0;
for (var waveIndex = 0; waveIndex < waveCount; waveIndex++)
{
var waveStarts = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowsPerWave),
concurrency,
async index =>
{
var started = DateTime.UtcNow;
var response = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "PostgresPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 995000L + (waveIndex * 1000L) + index,
},
}));
return (Index: index, Response: response, StartedAtUtc: started);
});
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
waveStarts,
concurrency,
async waveStart =>
{
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = waveStart.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 880000L + (waveIndex * 1000L) + waveStart.Index,
},
}));
return true;
});
totalProcessedSignals += await PostgresPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(
provider,
TimeSpan.FromSeconds(45),
workerCount);
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
waveStarts,
workerCount,
async waveStart =>
{
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = waveStart.Response.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
endToEndLatencies.Add(DateTime.UtcNow - waveStart.StartedAtUtc);
return true;
});
totalCompletedInstances += waveStarts.Count;
}
return (ProcessedSignals: totalProcessedSignals, CompletedInstances: totalCompletedInstances);
});
var completedAtUtc = DateTime.UtcNow;
var operationCount = waveCount * workflowsPerWave;
soakResult.CompletedInstances.Should().Be(operationCount);
soakResult.ProcessedSignals.Should().Be(operationCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "postgres-signal-roundtrip-soak",
Tier = WorkflowPerformanceCategories.Soak,
EnvironmentName = "postgres-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = operationCount,
Concurrency = concurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = operationCount,
SignalsPublished = operationCount,
SignalsProcessed = soakResult.ProcessedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
BackendMetrics = postgresMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "PostgresPerfSignalRoundTripWorkflow",
["waveCount"] = waveCount.ToString(),
["workflowsPerWave"] = workflowsPerWave.ToString(),
["workerCount"] = workerCount.ToString(),
},
});
}
}

View File

@@ -0,0 +1,412 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Helpers;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Signaling.Redis;
using StellaOps.Workflow.Engine.HostedServices;
using StellaOps.Workflow.DataStore.PostgreSQL;
using StellaOps.Workflow.Engine.Services;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using BulstradWorkflowRegistrator = StellaOps.Workflow.Engine.Workflows.Bulstrad.ServiceRegistrator;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests.Performance;
internal static class PostgresPerformanceTestSupport
{
public static PostgresWorkflowBackendOptions CreateOptions(string schemaName)
{
return new PostgresWorkflowBackendOptions
{
ConnectionStringName = "WorkflowPostgres",
SchemaName = schemaName,
BlockingWaitSeconds = 1,
};
}
public static IConfiguration CreateConfiguration(
PostgresWorkflowBackendOptions options,
string connectionString,
bool includeAssignmentRoles = false,
string signalDriverProvider = WorkflowSignalDriverNames.Native,
string? redisConnectionString = null,
string? redisChannelName = null)
{
var values = new Dictionary<string, string?>
{
["WorkflowBackend:Provider"] = WorkflowBackendNames.Postgres,
["WorkflowSignalDriver:Provider"] = signalDriverProvider,
[$"{PostgresWorkflowBackendOptions.SectionName}:ConnectionStringName"] = options.ConnectionStringName,
[$"{PostgresWorkflowBackendOptions.SectionName}:SchemaName"] = options.SchemaName,
[$"{PostgresWorkflowBackendOptions.SectionName}:BlockingWaitSeconds"] = options.BlockingWaitSeconds.ToString(),
[$"{PostgresWorkflowBackendOptions.SectionName}:NotifyChannel"] = options.NotifyChannel,
[$"ConnectionStrings:{options.ConnectionStringName}"] = connectionString,
["WorkflowRuntime:DefaultProvider"] = WorkflowRuntimeProviderNames.Engine,
["WorkflowRuntime:EnabledProviders:0"] = WorkflowRuntimeProviderNames.Engine,
["WorkflowAq:ConsumerName"] = "workflow-service",
["WorkflowAq:MaxDeliveryAttempts"] = "3",
["WorkflowRetention:OpenStaleAfterDays"] = "30",
["WorkflowRetention:CompletedPurgeAfterDays"] = "180",
};
if (string.Equals(signalDriverProvider, WorkflowSignalDriverNames.Redis, StringComparison.OrdinalIgnoreCase))
{
values["RedisConfig:ServerUrl"] = redisConnectionString
?? throw new InvalidOperationException("Redis connection string is required when Redis signal driver is selected.");
values[$"{RedisWorkflowSignalDriverOptions.SectionName}:ChannelName"] =
redisChannelName ?? $"stella:test:workflow:perf:postgres:{options.SchemaName}";
values[$"{RedisWorkflowSignalDriverOptions.SectionName}:BlockingWaitSeconds"] = "1";
}
if (includeAssignmentRoles)
{
values["GenericAssignmentPermissions:AdminRoles:0"] = "DBA";
values["GenericAssignmentPermissions:AdminRoles:1"] = "APR_APPL";
}
return new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
}
public static ServiceProvider CreateTransportProvider(IConfiguration configuration)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddWorkflowCoreServices(configuration);
services.AddWorkflowPostgresDataStore(configuration);
if (string.Equals(configuration.GetWorkflowSignalDriverProvider(), WorkflowSignalDriverNames.Redis, StringComparison.OrdinalIgnoreCase))
{
services.AddWorkflowRedisSignaling(configuration);
}
services.AddWorkflowRegistration<PostgresPerfExternalSignalWorkflow, PostgresPerfStartRequest>();
services.AddWorkflowRegistration<PostgresPerfSignalRoundTripWorkflow, PostgresPerfStartRequest>();
var provider = services.BuildServiceProvider();
ServiceProviderAccessor.Initialize(provider);
return provider;
}
public static ServiceProvider CreateBulstradProvider(
IConfiguration configuration,
WorkflowTransportScripts transports)
{
var services = new ServiceCollection();
services.AddLogging();
new BulstradWorkflowRegistrator().RegisterServices(services, configuration);
services.AddWorkflowCoreServices(configuration);
services.AddWorkflowModule("transport.legacy-rabbit", "1.0.0");
services.AddWorkflowPostgresDataStore(configuration);
if (string.Equals(configuration.GetWorkflowSignalDriverProvider(), WorkflowSignalDriverNames.Redis, StringComparison.OrdinalIgnoreCase))
{
services.AddWorkflowRedisSignaling(configuration);
}
services.Replace(ServiceDescriptor.Scoped<IWorkflowLegacyRabbitTransport>(_ => transports.LegacyRabbit));
services.Replace(ServiceDescriptor.Scoped<IWorkflowMicroserviceTransport>(_ => transports.Microservice));
services.Replace(ServiceDescriptor.Scoped<IWorkflowGraphqlTransport>(_ => transports.Graphql));
services.Replace(ServiceDescriptor.Scoped<IWorkflowHttpTransport>(_ => transports.Http));
var provider = services.BuildServiceProvider();
ServiceProviderAccessor.Initialize(provider);
return provider;
}
public static async Task<int> DrainSignalsUntilIdleAsync(
IServiceProvider provider,
TimeSpan timeout,
string consumerName = "workflow-service")
{
var telemetry = await DrainSignalsWithWorkersUntilIdleDetailedAsync(provider, timeout, workerCount: 1, consumerNamePrefix: consumerName);
return telemetry.ProcessedCount;
}
public static async Task<int> DrainSignalsWithWorkersUntilIdleAsync(
IServiceProvider provider,
TimeSpan timeout,
int workerCount,
string consumerNamePrefix = "workflow-service")
{
var telemetry = await DrainSignalsWithWorkersUntilIdleDetailedAsync(provider, timeout, workerCount, consumerNamePrefix);
return telemetry.ProcessedCount;
}
public static async Task<WorkflowSignalDrainTelemetry> DrainSignalsWithWorkersUntilIdleDetailedAsync(
IServiceProvider provider,
TimeSpan timeout,
int workerCount,
string consumerNamePrefix = "workflow-service")
{
ArgumentOutOfRangeException.ThrowIfLessThan(workerCount, 1);
using var scope = provider.CreateScope();
var worker = scope.ServiceProvider.GetRequiredService<WorkflowSignalPumpWorker>();
var timeoutAt = DateTime.UtcNow.Add(timeout);
var startedAtUtc = DateTime.UtcNow;
var processedCount = 0;
var consecutiveEmptyRounds = 0;
var totalRounds = 0;
DateTime? firstProcessedAtUtc = null;
DateTime? lastProcessedAtUtc = null;
while (DateTime.UtcNow < timeoutAt && consecutiveEmptyRounds < 3)
{
totalRounds++;
var workerNames = Enumerable
.Range(0, workerCount)
.Select(index => workerCount == 1
? consumerNamePrefix
: $"{consumerNamePrefix}-{index + 1}")
.ToArray();
var roundResults = await Task.WhenAll(workerNames.Select(workerName => worker.RunOnceAsync(workerName, CancellationToken.None)));
var roundProcessedCount = roundResults.Count(result => result);
if (roundProcessedCount > 0)
{
var processedAtUtc = DateTime.UtcNow;
processedCount += roundProcessedCount;
consecutiveEmptyRounds = 0;
firstProcessedAtUtc ??= processedAtUtc;
lastProcessedAtUtc = processedAtUtc;
continue;
}
consecutiveEmptyRounds++;
}
return new WorkflowSignalDrainTelemetry
{
ProcessedCount = processedCount,
TotalRounds = totalRounds,
IdleEmptyRounds = consecutiveEmptyRounds,
StartedAtUtc = startedAtUtc,
CompletedAtUtc = DateTime.UtcNow,
FirstProcessedAtUtc = firstProcessedAtUtc,
LastProcessedAtUtc = lastProcessedAtUtc,
};
}
public static async Task<(T Result, PostgresPerformanceDelta Metrics)> MeasureWithPostgresMetricsAsync<T>(
string connectionString,
Func<Task<T>> action,
CancellationToken cancellationToken = default)
{
var before = await PostgresPerformanceMetricsCollector.CaptureAsync(connectionString, cancellationToken);
var result = await action();
var after = await PostgresPerformanceMetricsCollector.CaptureAsync(connectionString, cancellationToken);
return (result, PostgresPerformanceDelta.Create(before, after));
}
public static async Task<PostgresTransportBurstResult> RunImmediateTransportBurstAsync(
IServiceProvider provider,
int messageCount,
TimeSpan timeout,
string correlationPrefix)
{
using var scope = provider.CreateScope();
var signalBus = scope.ServiceProvider.GetRequiredService<IWorkflowSignalBus>();
return await RunTransportBurstAsync(signalBus, scheduleBus: null, messageCount, delaySeconds: 0, timeout, correlationPrefix);
}
public static async Task<PostgresTransportBurstResult> RunDelayedTransportBurstAsync(
IServiceProvider provider,
int messageCount,
int delaySeconds,
TimeSpan timeout,
string correlationPrefix)
{
using var scope = provider.CreateScope();
var signalBus = scope.ServiceProvider.GetRequiredService<IWorkflowSignalBus>();
var scheduleBus = scope.ServiceProvider.GetRequiredService<IWorkflowScheduleBus>();
return await RunTransportBurstAsync(signalBus, scheduleBus, messageCount, delaySeconds, timeout, correlationPrefix);
}
private static async Task<PostgresTransportBurstResult> RunTransportBurstAsync(
IWorkflowSignalBus signalBus,
IWorkflowScheduleBus? scheduleBus,
int messageCount,
int delaySeconds,
TimeSpan timeout,
string correlationPrefix)
{
var publishedAt = new Dictionary<string, DateTime>(StringComparer.Ordinal);
for (var index = 0; index < messageCount; index++)
{
var correlation = $"{correlationPrefix}-{index}-{Guid.NewGuid():N}";
publishedAt[correlation] = DateTime.UtcNow;
var envelope = CreateTransportEnvelope(correlation, delaySeconds);
if (delaySeconds <= 0)
{
await signalBus.PublishAsync(envelope);
continue;
}
await scheduleBus!.ScheduleAsync(envelope, envelope.DueAtUtc!.Value);
}
var receiveLatencies = new List<TimeSpan>(messageCount);
var processedCorrelations = new HashSet<string>(StringComparer.Ordinal);
var timeoutAt = DateTime.UtcNow.Add(timeout);
while (processedCorrelations.Count < messageCount && DateTime.UtcNow < timeoutAt)
{
await using var lease = await signalBus.ReceiveAsync("postgres-perf-transport", CancellationToken.None);
if (lease is null)
{
continue;
}
if (publishedAt.TryGetValue(lease.Envelope.SignalId, out var publishedMoment))
{
processedCorrelations.Add(lease.Envelope.SignalId);
receiveLatencies.Add(DateTime.UtcNow - publishedMoment);
}
await lease.CompleteAsync(CancellationToken.None);
}
return new PostgresTransportBurstResult
{
ReceiveLatencies = receiveLatencies,
ProcessedCorrelations = processedCorrelations,
};
}
private static WorkflowSignalEnvelope CreateTransportEnvelope(string correlation, int delaySeconds)
{
return new WorkflowSignalEnvelope
{
SignalId = correlation,
WorkflowInstanceId = $"wf-{correlation}",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.ExternalSignal,
ExpectedVersion = 1,
WaitingToken = "perf-transport",
DueAtUtc = delaySeconds > 0 ? DateTime.UtcNow.AddSeconds(delaySeconds) : null,
Payload = new Dictionary<string, JsonElement>
{
["name"] = JsonSerializer.SerializeToElement("documents-uploaded"),
},
};
}
public static WorkflowTransportScripts CreateQuoteOrAplCancelSuccessTransports()
{
var transports = new WorkflowTransportScripts();
transports.LegacyRabbit
.Respond("bst_blanknumbersrelease", new { released = true })
.Respond("pas_annexprocessing_cancelaplorqt", new { cancelled = true });
return transports;
}
public static WorkflowTransportScripts CreateQuotationConfirmConvertToPolicyTransports()
{
var transports = new WorkflowTransportScripts();
transports.LegacyRabbit
.Respond("pas_polannexes_get", new
{
shortDescription = "Quote for policy conversion",
})
.Respond("pas_polreg_checkuwrules", new
{
nextStep = "ConvertToPolicy",
})
.Respond("pas_polreg_convertqttopoldefault", new
{
converted = true,
})
.Respond("bst_integration_printpolicydocuments", new
{
printed = true,
});
return transports;
}
private sealed record PostgresPerfStartRequest
{
[WorkflowBusinessId]
[WorkflowBusinessReferencePart("policyId")]
public long SrPolicyId { get; init; }
}
private sealed class PostgresPerfExternalSignalWorkflow : IDeclarativeWorkflow<PostgresPerfStartRequest>
{
public const string WorkflowNameValue = "PostgresPerfExternalSignalWorkflow";
public string WorkflowName => WorkflowNameValue;
public string WorkflowVersion => "1.0.0";
public string DisplayName => "PostgreSQL Performance External Signal Workflow";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<PostgresPerfStartRequest> Spec { get; } = WorkflowSpec.For<PostgresPerfStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")),
WorkflowExpr.Prop("documentId", WorkflowExpr.Number(0L))))
.AddTask(
WorkflowHumanTask.For<PostgresPerfStartRequest>(
"Postgres Perf Review",
"PostgresPerfReview",
"business/policies",
["DBA"])
.WithPayload(
WorkflowExpr.Obj(
WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase")),
WorkflowExpr.Prop("documentId", WorkflowExpr.Path("state.documentId"))))
.OnComplete(flow => flow.Complete()))
.StartWith(flow => flow
.Set("phase", "waiting-external")
.WaitForSignal("Wait For Perf Upload", WorkflowExpr.String("documents-uploaded"), resultKey: "uploadSignal")
.Set("documentId", WorkflowExpr.Path("result.uploadSignal.documentId"))
.Set("phase", "after-external")
.ActivateTask("Postgres Perf Review"))
.Build();
}
private sealed class PostgresPerfSignalRoundTripWorkflow : IDeclarativeWorkflow<PostgresPerfStartRequest>
{
public const string WorkflowNameValue = "PostgresPerfSignalRoundTripWorkflow";
public string WorkflowName => WorkflowNameValue;
public string WorkflowVersion => "1.0.0";
public string DisplayName => "PostgreSQL Performance Signal Round Trip Workflow";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<PostgresPerfStartRequest> Spec { get; } = WorkflowSpec.For<PostgresPerfStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")),
WorkflowExpr.Prop("documentId", WorkflowExpr.Number(0L))))
.StartWith(flow => flow
.Set("phase", "waiting-external")
.WaitForSignal("Wait For Perf Upload Completion", WorkflowExpr.String("documents-uploaded"), resultKey: "uploadSignal")
.Set("documentId", WorkflowExpr.Path("result.uploadSignal.documentId"))
.Set("phase", "completed")
.Complete())
.Build();
}
public sealed record PostgresTransportBurstResult
{
public required IReadOnlyList<TimeSpan> ReceiveLatencies { get; init; }
public required IReadOnlyCollection<string> ProcessedCorrelations { get; init; }
}
}

View File

@@ -0,0 +1,201 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.PostgreSQL;
using FluentAssertions;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests.Performance;
[TestFixture]
[NonParallelizable]
[Category("Performance")]
[Category(WorkflowPerformanceCategories.Throughput)]
public class PostgresPerformanceThroughputTests
{
private PostgresDockerFixture? fixture;
private PostgresWorkflowBackendOptions? options;
private PostgresWorkflowSqlBuilder? sqlBuilder;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new PostgresDockerFixture();
await fixture.StartOrIgnoreAsync();
options = PostgresPerformanceTestSupport.CreateOptions("wf_perf_throughput");
sqlBuilder = new PostgresWorkflowSqlBuilder(Options.Create(options));
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetSchemaAsync(options!.SchemaName, sqlBuilder!.BuildStorageBootstrapSql());
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task PostgresEnginePerfThroughput_WhenSignalRoundTripRunsWithParallelWorkers_ShouldWriteArtifacts()
{
const int workflowCount = 96;
const int operationConcurrency = 16;
const int workerCount = 8;
var configuration = PostgresPerformanceTestSupport.CreateConfiguration(options!, fixture!.ConnectionString);
using var provider = PostgresPerformanceTestSupport.CreateTransportProvider(configuration);
var warmupResponse = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "PostgresPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 991999L,
},
}));
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = warmupResponse.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 861999L,
},
}));
await PostgresPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(provider, TimeSpan.FromSeconds(20), workerCount: 2);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var startLatencies = new ConcurrentBag<TimeSpan>();
var signalPublishLatencies = new ConcurrentBag<TimeSpan>();
var signalToCompletionLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, postgresMetrics) = await PostgresPerformanceTestSupport.MeasureWithPostgresMetricsAsync(
fixture.ConnectionString,
async () =>
{
var startResponses = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
operationConcurrency,
async index =>
{
var operationStartedAtUtc = DateTime.UtcNow;
var startMeasureStartedAtUtc = operationStartedAtUtc;
var response = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "PostgresPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 991000L + index,
},
}));
startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc);
return (Index: index, Response: response, StartedAtUtc: operationStartedAtUtc);
});
var signalRaisedAtUtc = new ConcurrentDictionary<string, DateTime>(StringComparer.Ordinal);
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
startResponses,
operationConcurrency,
async startResponse =>
{
var publishStartedAtUtc = DateTime.UtcNow;
signalRaisedAtUtc[startResponse.Response.WorkflowInstanceId] = publishStartedAtUtc;
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 861000L + startResponse.Index,
},
}));
signalPublishLatencies.Add(DateTime.UtcNow - publishStartedAtUtc);
return true;
});
var totalProcessedSignals = await PostgresPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(
provider,
TimeSpan.FromSeconds(60),
workerCount);
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
startResponses,
operationConcurrency,
async startResponse =>
{
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
var completedAtUtc = DateTime.UtcNow;
endToEndLatencies.Add(completedAtUtc - startResponse.StartedAtUtc);
signalToCompletionLatencies.Add(completedAtUtc - signalRaisedAtUtc[startResponse.Response.WorkflowInstanceId]);
return true;
});
return totalProcessedSignals;
});
var completedAtUtc = DateTime.UtcNow;
processedSignals.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "postgres-signal-roundtrip-throughput-parallel",
Tier = WorkflowPerformanceCategories.Throughput,
EnvironmentName = "postgres-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = operationConcurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
PhaseLatencySummaries = new Dictionary<string, WorkflowPerformanceLatencySummary>
{
["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!,
["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!,
["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!,
},
BackendMetrics = postgresMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "PostgresPerfSignalRoundTripWorkflow",
["workerCount"] = workerCount.ToString(),
["measurementKind"] = "steady-throughput",
},
});
}
}

View File

@@ -0,0 +1,339 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
using StellaOps.Workflow.Signaling.Redis.Tests;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.PostgreSQL;
using FluentAssertions;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests.Performance;
[TestFixture]
[NonParallelizable]
[Category("Performance")]
public class PostgresRedisSignalDriverPerformanceTests
{
private PostgresDockerFixture? postgresFixture;
private RedisDockerFixture? redisFixture;
private PostgresWorkflowBackendOptions? options;
private PostgresWorkflowSqlBuilder? sqlBuilder;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
postgresFixture = new PostgresDockerFixture();
await postgresFixture.StartOrIgnoreAsync();
redisFixture = new RedisDockerFixture();
await redisFixture.StartOrIgnoreAsync();
options = PostgresPerformanceTestSupport.CreateOptions("wf_perf_redis_driver");
sqlBuilder = new PostgresWorkflowSqlBuilder(Options.Create(options));
}
[SetUp]
public async Task SetUpAsync()
{
await postgresFixture!.ResetSchemaAsync(options!.SchemaName, sqlBuilder!.BuildStorageBootstrapSql());
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
redisFixture?.Dispose();
postgresFixture?.Dispose();
}
[Test]
[Category(WorkflowPerformanceCategories.Latency)]
public async Task PostgresEnginePerfLatency_WhenRedisWakeDriverRunsSerially_ShouldWriteArtifacts()
{
const int workflowCount = 16;
var configuration = PostgresPerformanceTestSupport.CreateConfiguration(
options!,
postgresFixture!.ConnectionString,
signalDriverProvider: WorkflowSignalDriverNames.Redis,
redisConnectionString: redisFixture!.ConnectionString,
redisChannelName: $"stella:test:workflow:perf:postgres:latency:{Guid.NewGuid():N}");
using var provider = PostgresPerformanceTestSupport.CreateTransportProvider(configuration);
await using var hostedServices = await WorkflowEnginePerformanceSupport.StartHostedServicesAsync(provider);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var startLatencies = new ConcurrentBag<TimeSpan>();
var signalPublishLatencies = new ConcurrentBag<TimeSpan>();
var signalToCompletionLatencies = new ConcurrentBag<TimeSpan>();
var signalToFirstCompletionLatencies = new ConcurrentBag<TimeSpan>();
var drainToIdleOverhangLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, postgresMetrics) = await PostgresPerformanceTestSupport.MeasureWithPostgresMetricsAsync(
postgresFixture.ConnectionString,
async () =>
{
var totalProcessedSignals = 0;
for (var index = 0; index < workflowCount; index++)
{
var operationStartedAtUtc = DateTime.UtcNow;
var startMeasureStartedAtUtc = DateTime.UtcNow;
var startResponse = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "PostgresPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 993000L + index,
},
}));
startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc);
var signalPublishStartedAtUtc = DateTime.UtcNow;
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 863000L + index,
},
}));
signalPublishLatencies.Add(DateTime.UtcNow - signalPublishStartedAtUtc);
var drain = await PostgresPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleDetailedAsync(
provider,
TimeSpan.FromSeconds(20),
workerCount: 1);
totalProcessedSignals += drain.ProcessedCount;
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
drain.FirstProcessedAtUtc.Should().NotBeNull();
drain.LastProcessedAtUtc.Should().NotBeNull();
endToEndLatencies.Add(drain.CompletedAtUtc - operationStartedAtUtc);
signalToFirstCompletionLatencies.Add(drain.FirstProcessedAtUtc!.Value - signalPublishStartedAtUtc);
drainToIdleOverhangLatencies.Add(drain.CompletedAtUtc - drain.LastProcessedAtUtc!.Value);
signalToCompletionLatencies.Add(drain.CompletedAtUtc - signalPublishStartedAtUtc);
}
return totalProcessedSignals;
});
var completedAtUtc = DateTime.UtcNow;
processedSignals.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "postgres-redis-signal-roundtrip-latency-serial",
Tier = WorkflowPerformanceCategories.Latency,
EnvironmentName = "postgres-docker+redis-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = 1,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
PhaseLatencySummaries = new Dictionary<string, WorkflowPerformanceLatencySummary>
{
["drainToIdleOverhang"] = WorkflowPerformanceLatencySummary.FromSamples(drainToIdleOverhangLatencies)!,
["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!,
["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!,
["signalToFirstCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToFirstCompletionLatencies)!,
["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!,
},
BackendMetrics = postgresMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "PostgresPerfSignalRoundTripWorkflow",
["measurementKind"] = "serial-latency",
["signalDriver"] = WorkflowSignalDriverNames.Redis,
},
});
}
[Test]
[Category(WorkflowPerformanceCategories.Throughput)]
public async Task PostgresEnginePerfThroughput_WhenRedisWakeDriverRunsWithParallelWorkers_ShouldWriteArtifacts()
{
const int workflowCount = 96;
const int operationConcurrency = 16;
const int workerCount = 8;
var configuration = PostgresPerformanceTestSupport.CreateConfiguration(
options!,
postgresFixture!.ConnectionString,
signalDriverProvider: WorkflowSignalDriverNames.Redis,
redisConnectionString: redisFixture!.ConnectionString,
redisChannelName: $"stella:test:workflow:perf:postgres:throughput:{Guid.NewGuid():N}");
using var provider = PostgresPerformanceTestSupport.CreateTransportProvider(configuration);
await using var hostedServices = await WorkflowEnginePerformanceSupport.StartHostedServicesAsync(provider);
var warmupResponse = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "PostgresPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 992999L,
},
}));
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = warmupResponse.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 862999L,
},
}));
await PostgresPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(provider, TimeSpan.FromSeconds(20), workerCount: 2);
var startedAtUtc = DateTime.UtcNow;
var endToEndLatencies = new ConcurrentBag<TimeSpan>();
var startLatencies = new ConcurrentBag<TimeSpan>();
var signalPublishLatencies = new ConcurrentBag<TimeSpan>();
var signalToCompletionLatencies = new ConcurrentBag<TimeSpan>();
var (processedSignals, postgresMetrics) = await PostgresPerformanceTestSupport.MeasureWithPostgresMetricsAsync(
postgresFixture.ConnectionString,
async () =>
{
var startResponses = await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
Enumerable.Range(0, workflowCount),
operationConcurrency,
async index =>
{
var operationStartedAtUtc = DateTime.UtcNow;
var startMeasureStartedAtUtc = operationStartedAtUtc;
var response = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "PostgresPerfSignalRoundTripWorkflow",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 992000L + index,
},
}));
startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc);
return (Index: index, Response: response, StartedAtUtc: operationStartedAtUtc);
});
var signalRaisedAtUtc = new ConcurrentDictionary<string, DateTime>(StringComparer.Ordinal);
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
startResponses,
operationConcurrency,
async startResponse =>
{
var publishStartedAtUtc = DateTime.UtcNow;
signalRaisedAtUtc[startResponse.Response.WorkflowInstanceId] = publishStartedAtUtc;
await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 862000L + startResponse.Index,
},
}));
signalPublishLatencies.Add(DateTime.UtcNow - publishStartedAtUtc);
return true;
});
var totalProcessedSignals = await PostgresPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(
provider,
TimeSpan.FromSeconds(60),
workerCount);
await WorkflowEnginePerformanceSupport.RunConcurrentAsync(
startResponses,
operationConcurrency,
async startResponse =>
{
var instance = await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(
provider,
runtimeService => runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.Response.WorkflowInstanceId,
}));
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
var completedAtUtc = DateTime.UtcNow;
endToEndLatencies.Add(completedAtUtc - startResponse.StartedAtUtc);
signalToCompletionLatencies.Add(completedAtUtc - signalRaisedAtUtc[startResponse.Response.WorkflowInstanceId]);
return true;
});
return totalProcessedSignals;
});
var completedAtUtc = DateTime.UtcNow;
processedSignals.Should().Be(workflowCount);
WorkflowPerformanceArtifactWriter.Write(new WorkflowPerformanceRunResult
{
ScenarioName = "postgres-redis-signal-roundtrip-throughput-parallel",
Tier = WorkflowPerformanceCategories.Throughput,
EnvironmentName = "postgres-docker+redis-docker",
StartedAtUtc = startedAtUtc,
CompletedAtUtc = completedAtUtc,
OperationCount = workflowCount,
Concurrency = operationConcurrency,
Counters = new WorkflowPerformanceCounters
{
WorkflowsStarted = workflowCount,
SignalsPublished = workflowCount,
SignalsProcessed = processedSignals,
},
LatencySummary = WorkflowPerformanceLatencySummary.FromSamples(endToEndLatencies),
PhaseLatencySummaries = new Dictionary<string, WorkflowPerformanceLatencySummary>
{
["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!,
["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!,
["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!,
},
BackendMetrics = postgresMetrics.ToBackendMetrics(),
ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["workflowName"] = "PostgresPerfSignalRoundTripWorkflow",
["workerCount"] = workerCount.ToString(),
["measurementKind"] = "steady-throughput",
["signalDriver"] = WorkflowSignalDriverNames.Redis,
},
});
}
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using StellaOps.Workflow.IntegrationTests.Shared.Performance;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests.Performance;
internal static class PostgresWorkflowPerformanceMetricsExtensions
{
public static WorkflowPerformanceBackendMetrics ToBackendMetrics(this PostgresPerformanceDelta delta)
{
ArgumentNullException.ThrowIfNull(delta);
return new WorkflowPerformanceBackendMetrics
{
BackendName = "PostgreSQL",
InstanceName = delta.DatabaseName,
HostName = delta.ServerAddress,
Version = delta.ServerVersion,
CounterDeltas = new Dictionary<string, long>(delta.CounterDeltas, StringComparer.OrdinalIgnoreCase),
DurationDeltas = new Dictionary<string, long>(delta.DurationDeltas, StringComparer.OrdinalIgnoreCase),
Metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["activeSessions"] = delta.ActiveSessions.ToString(CultureInfo.InvariantCulture),
["notificationQueueUsage"] = delta.NotificationQueueUsage.ToString("F6", CultureInfo.InvariantCulture),
},
TopWaitDeltas = delta.TopWaits,
};
}
}

View File

@@ -0,0 +1,254 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.PostgreSQL.Tests.Performance;
using StellaOps.Workflow.DataStore.PostgreSQL;
using StellaOps.Workflow.Engine.Services;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests;
[TestFixture]
[NonParallelizable]
public class PostgresBulstradWorkflowIntegrationTests
{
private PostgresDockerFixture? fixture;
private PostgresWorkflowBackendOptions? options;
private PostgresWorkflowSqlBuilder? sqlBuilder;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new PostgresDockerFixture();
await fixture.StartOrIgnoreAsync();
options = new PostgresWorkflowBackendOptions
{
ConnectionStringName = "WorkflowPostgres",
SchemaName = "wf_bulstrad_test",
BlockingWaitSeconds = 1,
};
sqlBuilder = new PostgresWorkflowSqlBuilder(Options.Create(options));
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetSchemaAsync(options!.SchemaName, sqlBuilder!.BuildStorageBootstrapSql());
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task QuoteOrAplCancel_WhenServicesSucceedAcrossRestartedProviders_ShouldCompleteWithoutTasks()
{
var transports = PostgresPerformanceTestSupport.CreateQuoteOrAplCancelSuccessTransports();
string workflowInstanceId;
using (var provider = CreateProvider(transports))
{
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "QuoteOrAplCancel",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 896601L,
},
});
workflowInstanceId = startResponse.WorkflowInstanceId;
}
using var resumedProvider = CreateProvider(transports);
var resumedRuntimeService = resumedProvider.GetRequiredService<WorkflowRuntimeService>();
var instance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowInstanceId,
});
var tasks = await resumedRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = workflowInstanceId,
});
tasks.Tasks.Should().BeEmpty();
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine);
ReadBool(instance.WorkflowState, "releaseDocNumbersFailed").Should().BeFalse();
ReadBool(instance.WorkflowState, "cancelApplicationFailed").Should().BeFalse();
transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}")
.Should().Equal(
"Envelope:bst_blanknumbersrelease",
"Envelope:pas_annexprocessing_cancelaplorqt");
}
[Test]
public async Task InsisIntegrationNew_WhenTransferRequiresRetryAcrossRestartedProviders_ShouldCreateRetryTask()
{
var transports = CreateInsisIntegrationNewRetryTransports();
string workflowInstanceId;
using (var provider = CreateProvider(transports))
{
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "InsisIntegrationNew",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 895501L,
["srAnnexId"] = 885501L,
["srCustId"] = 775501L,
},
});
workflowInstanceId = startResponse.WorkflowInstanceId;
}
using var resumedProvider = CreateProvider(transports);
var resumedRuntimeService = resumedProvider.GetRequiredService<WorkflowRuntimeService>();
var instance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowInstanceId,
});
var tasks = await resumedRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = workflowInstanceId,
Status = WorkflowTaskStatuses.Open,
});
instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Open);
instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine);
instance.WorkflowState["nextStep"]!.ToString().Should().Be("RETRY");
instance.WorkflowState["taskDescription"]!.ToString().Should().Be("INSIS retry required");
tasks.Tasks.Should().ContainSingle();
tasks.Tasks.Single().TaskName.Should().Be("Retry");
tasks.Tasks.Single().TaskType.Should().Be("PolicyIntegrationPartialFailure");
tasks.Tasks.Single().TaskRoles.Should().Contain("APR_ANNEX");
tasks.Tasks.Single().Payload["taskDescription"]!.ToString().Should().Be("INSIS retry required");
transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}")
.Should().Equal(
"Envelope:bst_integration_processsendpolicyrequest",
"Envelope:pas_polannexes_get");
}
[Test]
public async Task QuotationConfirm_WhenConvertedToPolicyAcrossRestartedProviders_ShouldContinueWithPdfGenerator()
{
var transports = PostgresPerformanceTestSupport.CreateQuotationConfirmConvertToPolicyTransports();
string workflowInstanceId;
using (var provider = CreateProvider(transports))
{
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "QuotationConfirm",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 896612L,
["srAnnexId"] = 886612L,
["srCustId"] = 776612L,
},
});
workflowInstanceId = startResponse.WorkflowInstanceId;
}
using (var provider = CreateProvider(transports))
{
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var quotationTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = workflowInstanceId,
Status = WorkflowTaskStatuses.Open,
})).Tasks.Should().ContainSingle().Subject;
await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
{
WorkflowTaskId = quotationTask.WorkflowTaskId,
ActorId = "postgres-user",
ActorRoles = ["DBA"],
Payload = new Dictionary<string, object?>
{
["answer"] = "confirm",
},
});
}
using var resumedProvider = CreateProvider(transports);
var resumedRuntimeService = resumedProvider.GetRequiredService<WorkflowRuntimeService>();
var processedSignals = await PostgresPerformanceTestSupport.DrainSignalsUntilIdleAsync(resumedProvider, TimeSpan.FromSeconds(45));
var quotationInstance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowInstanceId,
});
var pdfInstances = await resumedRuntimeService.GetInstancesAsync(new WorkflowInstancesGetRequest
{
WorkflowName = "PdfGenerator",
BusinessReferenceKey = "896612",
});
var pdfInstance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = pdfInstances.Instances.Should().ContainSingle().Subject.WorkflowInstanceId,
});
processedSignals.Should().BeGreaterThan(0);
quotationInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
quotationInstance.WorkflowState["nextStep"]!.ToString().Should().Be("ConvertToPolicy");
pdfInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
pdfInstance.WorkflowState["stage"]!.ToString().Should().Be("POL");
ReadBool(pdfInstance.WorkflowState, "printFailed").Should().BeFalse();
transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}")
.Should().Equal(
"Envelope:pas_polannexes_get",
"Envelope:pas_polreg_checkuwrules",
"Envelope:pas_polreg_convertqttopoldefault",
"Envelope:bst_integration_printpolicydocuments");
}
private ServiceProvider CreateProvider(WorkflowTransportScripts transports)
{
var configuration = PostgresPerformanceTestSupport.CreateConfiguration(options!, fixture!.ConnectionString, includeAssignmentRoles: true);
return PostgresPerformanceTestSupport.CreateBulstradProvider(configuration, transports);
}
private static WorkflowTransportScripts CreateInsisIntegrationNewRetryTransports()
{
var transports = new WorkflowTransportScripts();
transports.LegacyRabbit
.Respond("bst_integration_processsendpolicyrequest", new
{
policySubstatus = "PENDING_RETRY",
})
.Respond("pas_polannexes_get", new
{
shortDescription = "INSIS retry required",
});
return transports;
}
private static bool ReadBool(IDictionary<string, object?> state, string key)
{
return state[key] switch
{
bool boolean => boolean,
_ => bool.Parse(state[key]!.ToString()!),
};
}
}

View File

@@ -0,0 +1,173 @@
using System;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Npgsql;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests;
internal sealed class PostgresDockerFixture : IDisposable
{
private const string ImageName = "postgres:16-alpine";
private const string DatabaseName = "workflow";
private const string UserName = "postgres";
private const string Password = "postgres_test_pw";
private readonly string containerName = $"stella-workflow-postgres-{Guid.NewGuid():N}";
private readonly int hostPort = GetFreeTcpPort();
private bool started;
public string ConnectionString =>
$"Host=127.0.0.1;Port={hostPort};Database={DatabaseName};Username={UserName};Password={Password};Pooling=true;Minimum Pool Size=1;Maximum Pool Size=24";
public async Task StartOrIgnoreAsync(CancellationToken cancellationToken = default)
{
if (started)
{
return;
}
if (!await CanUseDockerAsync(cancellationToken))
{
Assert.Ignore("Docker is not available. PostgreSQL workflow integration tests require a local Docker daemon.");
}
var runExitCode = await RunDockerCommandAsync(
$"run -d --name {containerName} -p {hostPort}:5432 -e POSTGRES_PASSWORD={Password} -e POSTGRES_DB={DatabaseName} {ImageName}",
ignoreErrors: false,
cancellationToken);
if (runExitCode != 0)
{
Assert.Ignore("Unable to start PostgreSQL Docker container for workflow integration tests.");
}
started = true;
try
{
await WaitUntilReadyAsync(cancellationToken);
}
catch
{
Dispose();
throw;
}
}
public async Task ResetSchemaAsync(string schemaName, string bootstrapSql, CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync(cancellationToken);
await using (var drop = new NpgsqlCommand($"drop schema if exists \"{schemaName}\" cascade;", connection))
{
await drop.ExecuteNonQueryAsync(cancellationToken);
}
await using var command = new NpgsqlCommand(bootstrapSql, connection);
await command.ExecuteNonQueryAsync(cancellationToken);
}
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 = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = new NpgsqlCommand("select 1;", connection);
await command.ExecuteScalarAsync(cancellationToken);
return;
}
catch
{
await Task.Delay(500, cancellationToken);
}
}
Dispose();
Assert.Ignore("PostgreSQL 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,208 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.PostgreSQL;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests;
[TestFixture]
[Category("Integration")]
public class PostgresWorkflowProjectionIntegrationTests
{
private PostgresDockerFixture? fixture;
private PostgresWorkflowBackendOptions? options;
private IConfiguration? configuration;
private PostgresWorkflowSqlBuilder? sqlBuilder;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new PostgresDockerFixture();
await fixture.StartOrIgnoreAsync();
options = new PostgresWorkflowBackendOptions
{
ConnectionStringName = "WorkflowPostgres",
SchemaName = "wf_projection_test",
};
configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"ConnectionStrings:{options.ConnectionStringName}"] = fixture.ConnectionString,
["WorkflowRetention:OpenStaleAfterDays"] = "30",
["WorkflowRetention:CompletedPurgeAfterDays"] = "180",
})
.Build();
sqlBuilder = new PostgresWorkflowSqlBuilder(Options.Create(options));
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetSchemaAsync(options!.SchemaName, sqlBuilder!.BuildStorageBootstrapSql());
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task ProjectionStore_ShouldCreateAssignCompleteAndInspectWorkflow()
{
var store = CreateStore();
var definition = new WorkflowDefinitionDescriptor
{
WorkflowName = "ApproveApplication",
WorkflowVersion = "1.0.0",
DisplayName = "Approve Application",
WorkflowRoles = ["uw"],
};
var businessReference = new WorkflowBusinessReference
{
Key = "policy",
Parts = new Dictionary<string, object?>
{
["policyId"] = 42L,
},
};
var startPlan = new WorkflowStartExecutionPlan
{
InstanceStatus = "Open",
WorkflowState = new Dictionary<string, JsonElement>
{
["phase"] = JsonSerializer.SerializeToElement("start"),
},
Tasks =
[
new WorkflowExecutionTaskPlan
{
TaskName = "Review",
TaskType = "Human",
Route = "review",
TaskRoles = ["uw.review"],
Payload = new Dictionary<string, JsonElement>
{
["requestId"] = JsonSerializer.SerializeToElement("REQ-1"),
},
}
],
};
var started = await store.CreateWorkflowAsync(definition, businessReference, startPlan);
var task = (await store.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = started.WorkflowInstanceId,
})).Single();
task.TaskName.Should().Be("Review");
var assigned = await store.AssignTaskAsync(task.WorkflowTaskId, "actor-1", "actor-1");
assigned.Assignee.Should().Be("actor-1");
assigned.Status.Should().Be("Assigned");
await store.ApplyTaskCompletionAsync(
task.WorkflowTaskId,
"actor-1",
new Dictionary<string, object?> { ["approved"] = true },
new WorkflowTaskCompletionPlan
{
InstanceStatus = "Completed",
WorkflowState = new Dictionary<string, JsonElement>
{
["phase"] = JsonSerializer.SerializeToElement("done"),
},
},
businessReference);
var details = await store.GetInstanceDetailsAsync(started.WorkflowInstanceId);
details.Should().NotBeNull();
details!.Instance.Status.Should().Be("Completed");
ReadText(details.WorkflowState["phase"]).Should().Be("done");
details.Tasks.Should().ContainSingle();
details.Tasks.Single().Status.Should().Be("Completed");
details.TaskEvents.Select(x => x.EventType).Should().Contain(["Created", "Assigned", "Completed"]);
}
[Test]
public async Task ProjectionStore_ShouldExposeSyntheticChildProjectionInstanceDetails()
{
var store = CreateStore();
var definition = new WorkflowDefinitionDescriptor
{
WorkflowName = "ParentWorkflow",
WorkflowVersion = "1.0.0",
DisplayName = "Parent Workflow",
WorkflowRoles = ["uw"],
};
var projectionId = "proj-child-1";
var started = await store.CreateWorkflowAsync(
definition,
businessReference: null,
new WorkflowStartExecutionPlan
{
Tasks =
[
new WorkflowExecutionTaskPlan
{
WorkflowName = "ReviewPolicyOpenForChange",
WorkflowVersion = "1.0.0",
TaskName = "Review Child",
TaskType = "Human",
Route = "child-review",
Payload = new Dictionary<string, JsonElement>
{
[WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey] =
JsonSerializer.SerializeToElement(projectionId),
},
}
],
});
var childDetails = await store.GetInstanceDetailsAsync(projectionId);
childDetails.Should().NotBeNull();
childDetails!.Instance.WorkflowInstanceId.Should().Be(projectionId);
childDetails.Tasks.Should().ContainSingle();
childDetails.Tasks.Single().TaskName.Should().Be("Review Child");
childDetails.TaskEvents.Should().ContainSingle();
childDetails.TaskEvents.Single().EventType.Should().Be("Created");
var rootDetails = await store.GetInstanceDetailsAsync(started.WorkflowInstanceId);
rootDetails.Should().NotBeNull();
rootDetails!.Tasks.Should().ContainSingle();
}
private PostgresWorkflowProjectionStore CreateStore()
{
return new PostgresWorkflowProjectionStore(
new PostgresWorkflowDatabase(
configuration!,
Options.Create(options!),
new PostgresWorkflowMutationSessionAccessor(),
new StellaOps.Workflow.Engine.Services.WorkflowMutationScopeAccessor()),
configuration!);
}
private static string? ReadText(object? value)
{
return value switch
{
string text => text,
JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.String => jsonElement.GetString(),
JsonElement jsonElement => jsonElement.ToString(),
_ => value?.ToString(),
};
}
}

View File

@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.PostgreSQL;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests;
[TestFixture]
[Category("Integration")]
public class PostgresWorkflowSignalIntegrationTests
{
private PostgresDockerFixture? fixture;
private PostgresWorkflowBackendOptions? options;
private IConfiguration? configuration;
private PostgresWorkflowSqlBuilder? sqlBuilder;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new PostgresDockerFixture();
await fixture.StartOrIgnoreAsync();
options = new PostgresWorkflowBackendOptions
{
ConnectionStringName = "WorkflowPostgres",
SchemaName = "wf_signal_test",
BlockingWaitSeconds = 1,
};
configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"ConnectionStrings:{options.ConnectionStringName}"] = fixture.ConnectionString,
})
.Build();
sqlBuilder = new PostgresWorkflowSqlBuilder(Options.Create(options));
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetSchemaAsync(options!.SchemaName, sqlBuilder!.BuildStorageBootstrapSql());
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task SignalBus_ShouldPublishReceiveDeadLetterAndReplaySignals()
{
var database = CreateDatabase();
var signalStore = new PostgresWorkflowSignalStore(database, sqlBuilder!);
var signalDriver = new PostgresWorkflowSignalBus(signalStore);
var deadLetters = new PostgresWorkflowSignalDeadLetterStore(signalStore);
var envelope = CreateEnvelope("signal-postgres-1");
await signalStore.PublishAsync(envelope);
await signalDriver.NotifySignalAvailableAsync(CreateWakeNotification(envelope));
await using var lease = await signalDriver.ReceiveAsync("consumer-a");
lease.Should().NotBeNull();
lease!.Envelope.WorkflowInstanceId.Should().Be(envelope.WorkflowInstanceId);
lease.DeliveryCount.Should().Be(1);
await lease.DeadLetterAsync();
var messages = await deadLetters.GetMessagesAsync(new WorkflowSignalDeadLettersGetRequest
{
SignalId = envelope.SignalId,
});
messages.Messages.Should().ContainSingle();
messages.Messages.Single().WorkflowInstanceId.Should().Be(envelope.WorkflowInstanceId);
var replay = await deadLetters.ReplayAsync(new WorkflowSignalDeadLetterReplayRequest
{
SignalId = envelope.SignalId,
});
replay.Replayed.Should().BeTrue();
await using var replayedLease = await signalDriver.ReceiveAsync("consumer-b");
replayedLease.Should().NotBeNull();
replayedLease!.Envelope.SignalId.Should().Be(envelope.SignalId);
await replayedLease.CompleteAsync();
}
[Test]
public async Task ScheduleBus_ShouldReleaseSignalWhenDueAtIsReached()
{
var database = CreateDatabase();
var signalStore = new PostgresWorkflowSignalStore(database, sqlBuilder!);
var signalDriver = new PostgresWorkflowSignalBus(signalStore);
var scheduleBus = new PostgresWorkflowScheduleBus(signalStore);
var envelope = CreateEnvelope("signal-postgres-2") with
{
DueAtUtc = DateTime.UtcNow.AddMilliseconds(250),
};
await scheduleBus.ScheduleAsync(envelope, envelope.DueAtUtc!.Value);
IWorkflowSignalLease? lease = null;
var deadlineUtc = DateTime.UtcNow.AddSeconds(5);
while (lease is null && DateTime.UtcNow < deadlineUtc)
{
lease = await signalDriver.ReceiveAsync("consumer-schedule");
}
await using var asyncLease = lease;
asyncLease.Should().NotBeNull();
asyncLease!.Envelope.SignalId.Should().Be(envelope.SignalId);
await asyncLease.CompleteAsync();
}
[Test]
public async Task SignalBus_WhenNoSignalAvailable_ShouldReturnNullAfterBlockingWindow()
{
var database = CreateDatabase();
var signalStore = new PostgresWorkflowSignalStore(database, sqlBuilder!);
var signalDriver = new PostgresWorkflowSignalBus(signalStore);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var startedAtUtc = DateTime.UtcNow;
await using var lease = await signalDriver.ReceiveAsync("consumer-empty", cts.Token);
lease.Should().BeNull();
(DateTime.UtcNow - startedAtUtc).Should().BeLessThan(TimeSpan.FromSeconds(3));
}
private PostgresWorkflowDatabase CreateDatabase()
{
return new PostgresWorkflowDatabase(
configuration!,
Options.Create(options!),
new PostgresWorkflowMutationSessionAccessor(),
new StellaOps.Workflow.Engine.Services.WorkflowMutationScopeAccessor());
}
private static WorkflowSignalEnvelope CreateEnvelope(string signalId)
{
return new WorkflowSignalEnvelope
{
SignalId = signalId,
WorkflowInstanceId = $"wf-{signalId}",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.ExternalSignal,
ExpectedVersion = 1,
WaitingToken = "wait-1",
Payload = new Dictionary<string, JsonElement>
{
["name"] = JsonSerializer.SerializeToElement("documents-uploaded"),
},
};
}
private static WorkflowSignalWakeNotification CreateWakeNotification(WorkflowSignalEnvelope envelope)
{
return new WorkflowSignalWakeNotification
{
SignalId = envelope.SignalId,
WorkflowInstanceId = envelope.WorkflowInstanceId,
RuntimeProvider = envelope.RuntimeProvider,
SignalType = envelope.SignalType,
DueAtUtc = envelope.DueAtUtc,
};
}
}

View File

@@ -0,0 +1,237 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.PostgreSQL;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests;
[TestFixture]
[Category("Integration")]
public class PostgresWorkflowStoreIntegrationTests
{
private PostgresDockerFixture? fixture;
private PostgresWorkflowBackendOptions? options;
private IConfiguration? configuration;
private PostgresWorkflowSqlBuilder? sqlBuilder;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new PostgresDockerFixture();
await fixture.StartOrIgnoreAsync();
options = new PostgresWorkflowBackendOptions
{
ConnectionStringName = "WorkflowPostgres",
SchemaName = "wf_test",
};
configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"ConnectionStrings:{options.ConnectionStringName}"] = fixture.ConnectionString,
})
.Build();
sqlBuilder = new PostgresWorkflowSqlBuilder(Options.Create(options));
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetSchemaAsync(options!.SchemaName, sqlBuilder!.BuildStorageBootstrapSql());
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task RuntimeStateStore_ShouldPersistAndVersionRuntimeStates()
{
var database = CreateDatabase();
var store = new PostgresWorkflowRuntimeStateStore(database);
var initialState = new WorkflowRuntimeStateRecord
{
WorkflowInstanceId = "wf-postgres-1",
WorkflowName = "ApproveApplication",
WorkflowVersion = "1.0.0",
Version = 1,
BusinessReference = new WorkflowBusinessReference
{
Key = "policy",
Parts = new Dictionary<string, object?>
{
["policyId"] = 123L,
},
},
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
RuntimeInstanceId = "runtime-1",
RuntimeStatus = "WaitingForTask",
StateJson = """{"phase":"start"}""",
CreatedOnUtc = DateTime.UtcNow,
LastUpdatedOnUtc = DateTime.UtcNow,
};
await store.UpsertAsync(initialState);
var updatedState = initialState with
{
Version = 2,
RuntimeStatus = "Completed",
StateJson = """{"phase":"done"}""",
CompletedOnUtc = DateTime.UtcNow,
LastUpdatedOnUtc = DateTime.UtcNow,
};
await store.UpsertAsync(updatedState);
var reloaded = await store.GetAsync(initialState.WorkflowInstanceId);
reloaded.Should().NotBeNull();
reloaded!.Version.Should().Be(2);
reloaded.RuntimeStatus.Should().Be("Completed");
reloaded.StateJson.Should().Contain("done");
ReadLong(reloaded.BusinessReference!.Parts["policyId"]).Should().Be(123L);
}
[Test]
public async Task RuntimeStateStore_WhenVersionConflicts_ShouldThrowConcurrencyException()
{
var database = CreateDatabase();
var store = new PostgresWorkflowRuntimeStateStore(database);
var state = new WorkflowRuntimeStateRecord
{
WorkflowInstanceId = "wf-postgres-2",
WorkflowName = "ApproveApplication",
WorkflowVersion = "1.0.0",
Version = 1,
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
RuntimeInstanceId = "runtime-2",
RuntimeStatus = "Open",
StateJson = "{}",
CreatedOnUtc = DateTime.UtcNow,
LastUpdatedOnUtc = DateTime.UtcNow,
};
await store.UpsertAsync(state);
var act = () => store.UpsertAsync(state with
{
Version = 3,
LastUpdatedOnUtc = DateTime.UtcNow,
});
await act.Should().ThrowAsync<WorkflowRuntimeStateConcurrencyException>();
}
[Test]
public async Task HostedJobLockService_ShouldAcquireAndReleaseLocks()
{
var database = CreateDatabase();
var service = new PostgresWorkflowHostedJobLockService(database);
var acquiredOnUtc = DateTime.UtcNow;
var firstAcquire = await service.TryAcquireAsync("retention", "node-a", acquiredOnUtc, TimeSpan.FromMinutes(5));
var secondAcquire = await service.TryAcquireAsync("retention", "node-b", acquiredOnUtc, TimeSpan.FromMinutes(5));
firstAcquire.Should().BeTrue();
secondAcquire.Should().BeFalse();
await service.ReleaseAsync("retention", "node-a");
var thirdAcquire = await service.TryAcquireAsync("retention", "node-b", acquiredOnUtc.AddMinutes(1), TimeSpan.FromMinutes(5));
thirdAcquire.Should().BeTrue();
}
[Test]
public async Task MutationCoordinator_WhenScopeIsNotCommitted_ShouldRollbackRuntimeStateChanges()
{
var database = CreateDatabase();
var coordinator = new PostgresWorkflowMutationCoordinator(database);
var store = new PostgresWorkflowRuntimeStateStore(database);
var state = new WorkflowRuntimeStateRecord
{
WorkflowInstanceId = "wf-postgres-tx-1",
WorkflowName = "ApproveApplication",
WorkflowVersion = "1.0.0",
Version = 1,
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
RuntimeInstanceId = "runtime-tx-1",
RuntimeStatus = "Open",
StateJson = "{}",
CreatedOnUtc = DateTime.UtcNow,
LastUpdatedOnUtc = DateTime.UtcNow,
};
await using (await coordinator.BeginAsync())
{
await store.UpsertAsync(state);
}
var reloaded = await store.GetAsync(state.WorkflowInstanceId);
reloaded.Should().BeNull();
}
[Test]
public async Task MutationCoordinator_WhenCommitted_ShouldPersistRuntimeStateChanges()
{
var database = CreateDatabase();
var coordinator = new PostgresWorkflowMutationCoordinator(database);
var store = new PostgresWorkflowRuntimeStateStore(database);
var state = new WorkflowRuntimeStateRecord
{
WorkflowInstanceId = "wf-postgres-tx-2",
WorkflowName = "ApproveApplication",
WorkflowVersion = "1.0.0",
Version = 1,
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
RuntimeInstanceId = "runtime-tx-2",
RuntimeStatus = "Open",
StateJson = "{}",
CreatedOnUtc = DateTime.UtcNow,
LastUpdatedOnUtc = DateTime.UtcNow,
};
await using (var scope = await coordinator.BeginAsync())
{
await store.UpsertAsync(state);
await scope.CommitAsync();
}
var reloaded = await store.GetAsync(state.WorkflowInstanceId);
reloaded.Should().NotBeNull();
reloaded!.WorkflowInstanceId.Should().Be(state.WorkflowInstanceId);
}
private static long ReadLong(object? value)
{
return value switch
{
long longValue => longValue,
int intValue => intValue,
JsonElement jsonElement => jsonElement.GetInt64(),
_ => Convert.ToInt64(value),
};
}
private PostgresWorkflowDatabase CreateDatabase()
{
return new PostgresWorkflowDatabase(
configuration!,
Options.Create(options!),
new PostgresWorkflowMutationSessionAccessor(),
new StellaOps.Workflow.Engine.Services.WorkflowMutationScopeAccessor());
}
}

View File

@@ -0,0 +1,91 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.DataStore.PostgreSQL;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests;
[TestFixture]
[Category("Integration")]
public class PostgresWorkflowWakeOutboxIntegrationTests
{
private PostgresDockerFixture? fixture;
private PostgresWorkflowBackendOptions? options;
private IConfiguration? configuration;
private PostgresWorkflowSqlBuilder? sqlBuilder;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
fixture = new PostgresDockerFixture();
await fixture.StartOrIgnoreAsync();
options = new PostgresWorkflowBackendOptions
{
ConnectionStringName = "WorkflowPostgres",
SchemaName = "wf_wake_outbox_test",
BlockingWaitSeconds = 1,
};
configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"ConnectionStrings:{options.ConnectionStringName}"] = fixture.ConnectionString,
})
.Build();
sqlBuilder = new PostgresWorkflowSqlBuilder(Options.Create(options));
}
[SetUp]
public async Task SetUpAsync()
{
await fixture!.ResetSchemaAsync(options!.SchemaName, sqlBuilder!.BuildStorageBootstrapSql());
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
fixture?.Dispose();
}
[Test]
public async Task WakeOutbox_ShouldEnqueueReceiveAndCompleteNotification()
{
var database = new PostgresWorkflowDatabase(
configuration!,
Options.Create(options!),
new PostgresWorkflowMutationSessionAccessor(),
new StellaOps.Workflow.Engine.Services.WorkflowMutationScopeAccessor());
var outbox = new PostgresWorkflowWakeOutbox(database, sqlBuilder!);
var notification = CreateNotification("pg-outbox-1");
await outbox.EnqueueAsync(notification);
await using var lease = await outbox.ReceiveAsync("publisher-a");
lease.Should().NotBeNull();
lease!.Notification.SignalId.Should().Be(notification.SignalId);
await lease.CompleteAsync();
await using var nextLease = await outbox.ReceiveAsync("publisher-a");
nextLease.Should().BeNull();
}
private static WorkflowSignalWakeNotification CreateNotification(string signalId)
{
return new WorkflowSignalWakeNotification
{
SignalId = signalId,
WorkflowInstanceId = $"wf-{signalId}",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.ExternalSignal,
};
}
}

View File

@@ -0,0 +1,51 @@
<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="Moq" Version="4.20.72" />
<PackageReference Include="Npgsql" Version="10.0.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" />
</ItemGroup>
<!-- TODO: These files reference Serdica/Bulstrad-specific workflow types (Engine.Helpers, Engine.Workflows.Bulstrad,
WorkflowTransportScripts) that are not in this repository. Re-enable when those types are ported to Stella. -->
<ItemGroup>
<Compile Remove="PostgresBulstradWorkflowIntegrationTests.cs" />
<Compile Remove="Performance\PostgresPerformanceTestSupport.cs" />
<Compile Remove="Performance\PostgresPerformanceCapacityTests.cs" />
<Compile Remove="Performance\PostgresPerformanceLatencyTests.cs" />
<Compile Remove="Performance\PostgresPerformanceMetricsCollector.cs" />
<Compile Remove="Performance\PostgresPerformanceNightlyTests.cs" />
<Compile Remove="Performance\PostgresPerformanceSmokeTests.cs" />
<Compile Remove="Performance\PostgresPerformanceSoakTests.cs" />
<Compile Remove="Performance\PostgresPerformanceThroughputTests.cs" />
<Compile Remove="Performance\PostgresRedisSignalDriverPerformanceTests.cs" />
<Compile Remove="Performance\PostgresWorkflowPerformanceMetricsExtensions.cs" />
</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.DataStore.PostgreSQL\StellaOps.Workflow.DataStore.PostgreSQL.csproj" />
<ProjectReference Include="..\StellaOps.Workflow.IntegrationTests.Shared\StellaOps.Workflow.IntegrationTests.Shared.csproj" />
<ProjectReference Include="..\StellaOps.Workflow.Signaling.Redis.Tests\StellaOps.Workflow.Signaling.Redis.Tests.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,450 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Engine.Execution;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class CanonicalWorkflowExecutionHandlerTests
{
private const string ForkFramesStateKey = "__serdica.forkFrames";
private const string ForkCoordinatorIdPayloadKey = "__serdica.forkCoordinatorId";
[Test]
public async Task StartAsync_WhenRabbitTransportSucceeds_ShouldStoreResultAndComplete()
{
var rabbitTransport = new RecordingRabbitTransport
{
Response = new WorkflowRabbitResponse
{
Succeeded = true,
Payload = new
{
phase = "published",
},
},
};
var handler = CreateHandler(
BuildRuntimeDefinition(new WorkflowCanonicalDefinition
{
WorkflowName = "RabbitSuccessWorkflow",
WorkflowVersion = "1.0.0",
DisplayName = "Rabbit Success Workflow",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = WorkflowExpr.Obj(
WorkflowExpr.Prop("phase", WorkflowExpr.String("start"))),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowTransportCallStepDeclaration
{
StepName = "Publish",
ResultKey = "publish",
Invocation = new WorkflowTransportInvocationDeclaration
{
Address = new WorkflowRabbitAddressDeclaration
{
Exchange = "workflow",
RoutingKey = "policy.publish",
},
PayloadExpression = WorkflowExpr.Obj(
WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase"))),
},
},
new WorkflowSetStateStepDeclaration
{
StateKey = "phase",
ValueExpression = WorkflowExpr.Path("result.publish.phase"),
},
new WorkflowCompleteStepDeclaration(),
],
},
},
}),
rabbitTransport);
var plan = await handler.StartAsync(new WorkflowStartExecutionContext
{
Registration = BuildRegistration(),
Definition = BuildDescriptor("RabbitSuccessWorkflow"),
StartRequest = new object(),
Payload = new Dictionary<string, JsonElement>(),
});
plan.InstanceStatus.Should().Be("Completed");
rabbitTransport.Requests.Should().ContainSingle();
rabbitTransport.Requests[0].Exchange.Should().Be("workflow");
rabbitTransport.Requests[0].RoutingKey.Should().Be("policy.publish");
plan.WorkflowState["phase"].GetString().Should().Be("published");
}
[Test]
public async Task StartAsync_WhenRabbitTransportFails_ShouldExecuteFailureBranch()
{
var handler = CreateHandler(
BuildRuntimeDefinition(new WorkflowCanonicalDefinition
{
WorkflowName = "RabbitFailureWorkflow",
WorkflowVersion = "1.0.0",
DisplayName = "Rabbit Failure Workflow",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = WorkflowExpr.Obj(
WorkflowExpr.Prop("phase", WorkflowExpr.String("start"))),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowTransportCallStepDeclaration
{
StepName = "Publish",
Invocation = new WorkflowTransportInvocationDeclaration
{
Address = new WorkflowRabbitAddressDeclaration
{
Exchange = "workflow",
RoutingKey = "policy.publish",
},
},
WhenFailure = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowSetStateStepDeclaration
{
StateKey = "phase",
ValueExpression = WorkflowExpr.String("failed"),
},
new WorkflowCompleteStepDeclaration(),
],
},
},
],
},
},
}),
new RecordingRabbitTransport
{
Response = new WorkflowRabbitResponse
{
Succeeded = false,
Error = "publish failed",
},
});
var plan = await handler.StartAsync(new WorkflowStartExecutionContext
{
Registration = BuildRegistration(),
Definition = BuildDescriptor("RabbitFailureWorkflow"),
StartRequest = new object(),
Payload = new Dictionary<string, JsonElement>(),
});
plan.InstanceStatus.Should().Be("Completed");
plan.WorkflowState["phase"].GetString().Should().Be("failed");
}
[Test]
public async Task StartAsync_WhenForkBranchesCompleteInline_ShouldMergeStateAndContinue()
{
var handler = CreateHandler(
BuildRuntimeDefinition(new WorkflowCanonicalDefinition
{
WorkflowName = "ForkWorkflow",
WorkflowVersion = "1.0.0",
DisplayName = "Fork Workflow",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = WorkflowExpr.Obj(
WorkflowExpr.Prop("shared", WorkflowExpr.String("seed"))),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowForkStepDeclaration
{
StepName = "Parallel Validation",
Branches =
[
new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowSetStateStepDeclaration
{
StateKey = "leftDone",
ValueExpression = WorkflowExpr.Bool(true),
},
new WorkflowSetStateStepDeclaration
{
StateKey = "shared",
ValueExpression = WorkflowExpr.String("left"),
},
],
},
new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowSetStateStepDeclaration
{
StateKey = "rightDone",
ValueExpression = WorkflowExpr.Bool(true),
},
],
},
],
},
new WorkflowSetStateStepDeclaration
{
StateKey = "phase",
ValueExpression = WorkflowExpr.String("joined"),
},
new WorkflowCompleteStepDeclaration(),
],
},
},
}),
new RecordingRabbitTransport());
var plan = await handler.StartAsync(new WorkflowStartExecutionContext
{
Registration = BuildRegistration(),
Definition = BuildDescriptor("ForkWorkflow"),
StartRequest = new object(),
Payload = new Dictionary<string, JsonElement>(),
});
plan.InstanceStatus.Should().Be("Completed");
plan.WorkflowState["leftDone"].GetBoolean().Should().BeTrue();
plan.WorkflowState["rightDone"].GetBoolean().Should().BeTrue();
plan.WorkflowState["shared"].GetString().Should().Be("left");
plan.WorkflowState["phase"].GetString().Should().Be("joined");
}
[Test]
public async Task StartAsync_WhenForkBranchWaits_ShouldReturnPendingSignalAndPersistForkCoordinator()
{
var handler = CreateHandler(
BuildRuntimeDefinition(new WorkflowCanonicalDefinition
{
WorkflowName = "ForkWaitWorkflow",
WorkflowVersion = "1.0.0",
DisplayName = "Fork Wait Workflow",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = WorkflowExpr.Obj(),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowForkStepDeclaration
{
StepName = "Parallel Wait",
Branches =
[
new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowTimerStepDeclaration
{
StepName = "Wait",
DelayExpression = WorkflowExpr.String("00:00:01"),
},
],
},
],
},
new WorkflowCompleteStepDeclaration(),
],
},
},
}),
new RecordingRabbitTransport());
var plan = await handler.StartAsync(new WorkflowStartExecutionContext
{
Registration = BuildRegistration(),
Definition = BuildDescriptor("ForkWaitWorkflow"),
StartRequest = new object(),
Payload = new Dictionary<string, JsonElement>(),
});
plan.InstanceStatus.Should().Be("Open");
plan.Tasks.Should().BeEmpty();
plan.PendingSignals.Should().ContainSingle();
plan.PendingSignals.Single().SignalType.Should().Be(WorkflowSignalTypes.TimerDue);
plan.PendingSignals.Single().ResumeState.Should().ContainKey(ForkCoordinatorIdPayloadKey);
plan.WorkflowState.Should().ContainKey(ForkFramesStateKey);
}
private static CanonicalWorkflowExecutionHandler CreateHandler(
WorkflowRuntimeDefinition runtimeDefinition,
RecordingRabbitTransport rabbitTransport)
{
return new CanonicalWorkflowExecutionHandler(
runtimeDefinition,
new NullMicroserviceTransport(),
rabbitTransport,
new NullLegacyRabbitTransport(),
new NullGraphqlTransport(),
new NullHttpTransport(),
new NullWorkflowFunctionRuntime(),
new StubWorkflowRegistrationCatalog(),
new StubWorkflowRuntimeDefinitionStore(),
new StubWorkflowRuntimeExecutionHandlerFactory());
}
private static WorkflowRuntimeDefinition BuildRuntimeDefinition(WorkflowCanonicalDefinition definition)
{
var descriptor = BuildDescriptor(definition.WorkflowName, definition.WorkflowVersion);
return new WorkflowRuntimeDefinition
{
Registration = BuildRegistration(descriptor),
Descriptor = descriptor,
ExecutionKind = WorkflowRuntimeExecutionKind.Declarative,
CanonicalDefinition = definition,
};
}
private static WorkflowDefinitionDescriptor BuildDescriptor(
string workflowName,
string workflowVersion = "1.0.0")
{
return new WorkflowDefinitionDescriptor
{
WorkflowName = workflowName,
WorkflowVersion = workflowVersion,
DisplayName = workflowName,
WorkflowRoles = [],
Tasks = [],
};
}
private static WorkflowRegistration BuildRegistration(WorkflowDefinitionDescriptor? descriptor = null)
{
var resolvedDescriptor = descriptor ?? BuildDescriptor("TestWorkflow");
return new WorkflowRegistration
{
WorkflowType = typeof(object),
StartRequestType = typeof(Dictionary<string, object?>),
Definition = resolvedDescriptor,
BindStartRequest = payload => new Dictionary<string, object?>(payload, StringComparer.OrdinalIgnoreCase),
ExtractBusinessReference = _ => null,
};
}
private sealed class RecordingRabbitTransport : IWorkflowRabbitTransport
{
public List<WorkflowRabbitRequest> Requests { get; } = [];
public WorkflowRabbitResponse Response { get; init; } = new()
{
Succeeded = true,
};
public Task<WorkflowRabbitResponse> ExecuteAsync(
WorkflowRabbitRequest request,
CancellationToken cancellationToken = default)
{
Requests.Add(request);
return Task.FromResult(Response);
}
}
private sealed class NullMicroserviceTransport : IWorkflowMicroserviceTransport
{
public Task<WorkflowMicroserviceResponse> ExecuteAsync(
WorkflowMicroserviceRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(new WorkflowMicroserviceResponse
{
Succeeded = false,
Error = "Not used in this test.",
});
}
private sealed class NullLegacyRabbitTransport : IWorkflowLegacyRabbitTransport
{
public Task<WorkflowMicroserviceResponse> ExecuteAsync(
WorkflowLegacyRabbitRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(new WorkflowMicroserviceResponse
{
Succeeded = false,
Error = "Not used in this test.",
});
}
private sealed class NullGraphqlTransport : IWorkflowGraphqlTransport
{
public Task<WorkflowGraphqlResponse> ExecuteAsync(
WorkflowGraphqlRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(new WorkflowGraphqlResponse
{
Succeeded = false,
Error = "Not used in this test.",
});
}
private sealed class NullHttpTransport : IWorkflowHttpTransport
{
public Task<WorkflowHttpResponse> ExecuteAsync(
WorkflowHttpRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(new WorkflowHttpResponse
{
Succeeded = false,
Error = "Not used in this test.",
});
}
private sealed class NullWorkflowFunctionRuntime : IWorkflowFunctionRuntime
{
public bool TryEvaluate(
string functionName,
IReadOnlyCollection<object?> arguments,
WorkflowCanonicalEvaluationContext context,
out object? result)
{
result = null;
return false;
}
}
private sealed class StubWorkflowRegistrationCatalog : IWorkflowRegistrationCatalog
{
public IReadOnlyCollection<WorkflowRegistration> GetRegistrations() => [];
public WorkflowRegistration? GetRegistration(string workflowName, string? workflowVersion = null) => null;
}
private sealed class StubWorkflowRuntimeDefinitionStore : IWorkflowRuntimeDefinitionStore
{
public IReadOnlyCollection<WorkflowRuntimeDefinition> GetDefinitions() => [];
public WorkflowRuntimeDefinition? GetDefinition(string workflowName, string? workflowVersion = null) => null;
public WorkflowRuntimeDefinition GetRequiredDefinition(string workflowName, string? workflowVersion = null)
=> throw new InvalidOperationException("No nested workflow definitions are expected in this test.");
}
private sealed class StubWorkflowRuntimeExecutionHandlerFactory : IWorkflowRuntimeExecutionHandlerFactory
{
public IWorkflowExecutionHandler? TryCreateHandler(WorkflowRuntimeDefinition definition) => null;
}
}

View File

@@ -0,0 +1,216 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Engine.Execution;
using StellaOps.Workflow.Engine.Hosting;
using FluentAssertions;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class ConfiguredWorkflowRuntimeOrchestratorTests
{
[Test]
public async Task StartAsync_WhenDefaultProviderConfigured_ShouldDispatchToMatchingProvider()
{
var inProcessProvider = new FakeWorkflowRuntimeProvider(WorkflowRuntimeProviderNames.InProcess);
var engineProvider = new FakeWorkflowRuntimeProvider(WorkflowRuntimeProviderNames.Engine);
var orchestrator = new ConfiguredWorkflowRuntimeOrchestrator(
[inProcessProvider, engineProvider],
Options.Create(new WorkflowRuntimeOptions
{
DefaultProvider = WorkflowRuntimeProviderNames.Engine,
EnabledProviders =
[
WorkflowRuntimeProviderNames.InProcess,
WorkflowRuntimeProviderNames.Engine,
],
}));
var result = await orchestrator.StartAsync(
BuildRegistration(),
BuildDefinition(),
null,
new StartWorkflowRequest
{
WorkflowName = "ApproveApplication",
},
new object(),
CancellationToken.None);
result.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine);
engineProvider.StartCalls.Should().Be(1);
inProcessProvider.StartCalls.Should().Be(0);
}
[Test]
public async Task CompleteAsync_WhenConfiguredProviderIsDisabled_ShouldThrow()
{
var provider = new FakeWorkflowRuntimeProvider(WorkflowRuntimeProviderNames.Engine);
var orchestrator = new ConfiguredWorkflowRuntimeOrchestrator(
[provider],
Options.Create(new WorkflowRuntimeOptions
{
DefaultProvider = WorkflowRuntimeProviderNames.Engine,
EnabledProviders = [WorkflowRuntimeProviderNames.InProcess],
}));
var act = async () => await orchestrator.CompleteAsync(
BuildRegistration(),
BuildDefinition(),
new WorkflowTaskExecutionContext
{
Registration = BuildRegistration(),
Definition = BuildDefinition(),
WorkflowInstanceId = "wf-1",
CurrentTask = new WorkflowTaskSummary
{
WorkflowTaskId = "task-1",
WorkflowInstanceId = "wf-1",
WorkflowName = "ApproveApplication",
WorkflowVersion = "1.0.0",
TaskName = "Approve Application",
TaskType = "ApproveQTApproveApplication",
Route = "business/policies",
},
},
CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*not enabled*");
}
[Test]
public async Task ResumeAsync_WhenRuntimeStateSpecifiesProvider_ShouldDispatchToPersistedProvider()
{
var inProcessProvider = new FakeWorkflowRuntimeProvider(WorkflowRuntimeProviderNames.InProcess);
var engineProvider = new FakeWorkflowRuntimeProvider(WorkflowRuntimeProviderNames.Engine);
var orchestrator = new ConfiguredWorkflowRuntimeOrchestrator(
[inProcessProvider, engineProvider],
Options.Create(new WorkflowRuntimeOptions
{
DefaultProvider = WorkflowRuntimeProviderNames.InProcess,
EnabledProviders =
[
WorkflowRuntimeProviderNames.InProcess,
WorkflowRuntimeProviderNames.Engine,
],
}));
var result = await orchestrator.ResumeAsync(
BuildRegistration(),
BuildDefinition(),
new WorkflowSignalExecutionContext
{
Registration = BuildRegistration(),
Definition = BuildDefinition(),
RuntimeState = new WorkflowRuntimeStateRecord
{
WorkflowInstanceId = "wf-1",
WorkflowName = "ApproveApplication",
WorkflowVersion = "1.0.0",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
RuntimeInstanceId = "engine-1",
},
Signal = new WorkflowSignalEnvelope
{
SignalId = "sig-1",
WorkflowInstanceId = "wf-1",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.TimerDue,
ExpectedVersion = 1,
},
},
CancellationToken.None);
result.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine);
engineProvider.ResumeCalls.Should().Be(1);
inProcessProvider.ResumeCalls.Should().Be(0);
}
private static WorkflowRegistration BuildRegistration()
{
return new WorkflowRegistration
{
WorkflowType = typeof(object),
StartRequestType = typeof(Dictionary<string, object?>),
Definition = BuildDefinition(),
BindStartRequest = payload => payload,
ExtractBusinessReference = _ => null,
};
}
private static WorkflowDefinitionDescriptor BuildDefinition()
{
return new WorkflowDefinitionDescriptor
{
WorkflowName = "ApproveApplication",
WorkflowVersion = "1.0.0",
DisplayName = "Approve Application",
};
}
private sealed class FakeWorkflowRuntimeProvider(string providerName) : IWorkflowRuntimeProvider
{
public string ProviderName { get; } = providerName;
public int StartCalls { get; private set; }
public int ResumeCalls { get; private set; }
public Task<WorkflowRuntimeExecutionResult> StartAsync(
WorkflowRegistration registration,
WorkflowDefinitionDescriptor definition,
WorkflowBusinessReference? businessReference,
StartWorkflowRequest request,
object startRequest,
CancellationToken cancellationToken = default)
{
StartCalls++;
return Task.FromResult(new WorkflowRuntimeExecutionResult
{
RuntimeProvider = ProviderName,
RuntimeInstanceId = "runtime-1",
RuntimeStatus = "Open",
InstanceStatus = "Open",
});
}
public Task<WorkflowRuntimeExecutionResult> CompleteAsync(
WorkflowRegistration registration,
WorkflowDefinitionDescriptor definition,
WorkflowTaskExecutionContext context,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new WorkflowRuntimeExecutionResult
{
RuntimeProvider = ProviderName,
RuntimeInstanceId = context.WorkflowInstanceId,
RuntimeStatus = "Open",
InstanceStatus = "Open",
});
}
public Task<WorkflowRuntimeExecutionResult> ResumeAsync(
WorkflowRegistration registration,
WorkflowDefinitionDescriptor definition,
WorkflowSignalExecutionContext context,
CancellationToken cancellationToken = default)
{
ResumeCalls++;
return Task.FromResult(new WorkflowRuntimeExecutionResult
{
RuntimeProvider = ProviderName,
RuntimeInstanceId = context.RuntimeState.RuntimeInstanceId,
RuntimeStatus = "Open",
InstanceStatus = "Open",
});
}
}
}

View File

@@ -0,0 +1,70 @@
using System;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Services;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class InMemoryWorkflowRuntimeStateStoreTests
{
[Test]
public async Task UpsertAsync_WhenVersionAdvancesSequentially_ShouldPersistLatestState()
{
var store = new InMemoryWorkflowRuntimeStateStore();
await store.UpsertAsync(CreateState(version: 1, stateJson: """{"version":1}"""));
await store.UpsertAsync(CreateState(version: 2, stateJson: """{"version":2}"""));
var state = await store.GetAsync("wf-1");
state.Should().NotBeNull();
state!.Version.Should().Be(2);
state.StateJson.Should().Be("""{"version":2}""");
}
[Test]
public async Task UpsertAsync_WhenInsertVersionSkipsAhead_ShouldThrowConcurrencyException()
{
var store = new InMemoryWorkflowRuntimeStateStore();
var act = async () => await store.UpsertAsync(CreateState(version: 2, stateJson: """{"version":2}"""));
await act.Should().ThrowAsync<WorkflowRuntimeStateConcurrencyException>()
.Where(x => x.WorkflowInstanceId == "wf-1" && x.ExpectedVersion == 2 && x.ActualVersion == 0);
}
[Test]
public async Task UpsertAsync_WhenVersionIsStale_ShouldThrowConcurrencyException()
{
var store = new InMemoryWorkflowRuntimeStateStore();
await store.UpsertAsync(CreateState(version: 1, stateJson: """{"version":1}"""));
await store.UpsertAsync(CreateState(version: 2, stateJson: """{"version":2}"""));
var act = async () => await store.UpsertAsync(CreateState(version: 2, stateJson: """{"version":2,"retry":true}"""));
await act.Should().ThrowAsync<WorkflowRuntimeStateConcurrencyException>()
.Where(x => x.WorkflowInstanceId == "wf-1" && x.ExpectedVersion == 2 && x.ActualVersion == 2);
}
private static WorkflowRuntimeStateRecord CreateState(long version, string stateJson)
{
return new WorkflowRuntimeStateRecord
{
WorkflowInstanceId = "wf-1",
WorkflowName = "TestWorkflow",
WorkflowVersion = "1.0.0",
Version = version,
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
RuntimeInstanceId = "wf-1",
RuntimeStatus = "Open",
StateJson = stateJson,
CreatedOnUtc = DateTime.UtcNow,
LastUpdatedOnUtc = DateTime.UtcNow,
};
}
}

View File

@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Engine.Tests;
internal sealed class RecordingWorkflowHttpTransport : IWorkflowHttpTransport
{
private readonly Dictionary<string, Exception> exceptions = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, WorkflowHttpResponse> responses = new(StringComparer.OrdinalIgnoreCase);
public List<WorkflowHttpRequest> Invocations { get; } = [];
public RecordingWorkflowHttpTransport Respond(
string target,
string path,
object? payload,
string method = "POST",
int statusCode = 200)
{
responses[BuildKey(target, method, path)] = new WorkflowHttpResponse
{
Succeeded = true,
StatusCode = statusCode,
JsonPayload = payload is null ? null : JsonSerializer.Serialize(payload),
};
return this;
}
public RecordingWorkflowHttpTransport Fail(
string target,
string path,
string error,
string method = "POST",
int statusCode = 500,
object? payload = null)
{
responses[BuildKey(target, method, path)] = new WorkflowHttpResponse
{
Succeeded = false,
StatusCode = statusCode,
Error = error,
JsonPayload = payload is null ? null : JsonSerializer.Serialize(payload),
};
return this;
}
public RecordingWorkflowHttpTransport Timeout(
string target,
string path,
string method = "POST")
{
exceptions[BuildKey(target, method, path)] =
new TimeoutException($"Timeout for HTTP request '{method} {target}:{path}'.");
return this;
}
public Task<WorkflowHttpResponse> ExecuteAsync(
WorkflowHttpRequest request,
CancellationToken cancellationToken = default)
{
Invocations.Add(request);
var key = BuildKey(request.Target, request.Method, request.Path);
if (exceptions.TryGetValue(key, out var exception))
{
return Task.FromException<WorkflowHttpResponse>(exception);
}
return Task.FromResult(
responses.TryGetValue(key, out var response)
? response
: new WorkflowHttpResponse
{
Succeeded = false,
Error = $"No fake HTTP response configured for {request.Method}:{request.Target}:{request.Path}.",
});
}
private static string BuildKey(string target, string method, string path)
{
return $"{method.Trim().ToUpperInvariant()}:{target}:{path}";
}
}

View File

@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Engine.Tests;
internal sealed class RecordingWorkflowLegacyRabbitTransport : IWorkflowLegacyRabbitTransport
{
private readonly Dictionary<string, Exception> exceptions = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Queue<WorkflowMicroserviceResponse>> responses = new(StringComparer.OrdinalIgnoreCase);
public List<WorkflowLegacyRabbitRequest> Invocations { get; } = [];
public RecordingWorkflowLegacyRabbitTransport Respond(
string command,
object? payload,
WorkflowLegacyRabbitMode mode = WorkflowLegacyRabbitMode.Envelope)
{
var key = BuildKey(command, mode);
if (!responses.TryGetValue(key, out var queue))
{
queue = new Queue<WorkflowMicroserviceResponse>();
responses[key] = queue;
}
queue.Enqueue(new WorkflowMicroserviceResponse
{
Succeeded = true,
Payload = payload,
});
return this;
}
public RecordingWorkflowLegacyRabbitTransport Fail(
string command,
string error,
WorkflowLegacyRabbitMode mode = WorkflowLegacyRabbitMode.Envelope)
{
var key = BuildKey(command, mode);
if (!responses.TryGetValue(key, out var queue))
{
queue = new Queue<WorkflowMicroserviceResponse>();
responses[key] = queue;
}
queue.Enqueue(new WorkflowMicroserviceResponse
{
Succeeded = false,
Error = error,
});
return this;
}
public RecordingWorkflowLegacyRabbitTransport Timeout(
string command,
WorkflowLegacyRabbitMode mode = WorkflowLegacyRabbitMode.Envelope)
{
exceptions[BuildKey(command, mode)] = new TimeoutException($"Timeout for legacy Rabbit command '{command}'.");
return this;
}
public Task<WorkflowMicroserviceResponse> ExecuteAsync(
WorkflowLegacyRabbitRequest request,
CancellationToken cancellationToken = default)
{
Invocations.Add(request);
var key = BuildKey(request.Command, request.Mode);
if (exceptions.TryGetValue(key, out var exception))
{
return Task.FromException<WorkflowMicroserviceResponse>(exception);
}
return Task.FromResult(
responses.TryGetValue(key, out var responseQueue) && responseQueue.Count > 0
? DequeueResponse(responseQueue)
: new WorkflowMicroserviceResponse
{
Succeeded = false,
Error = $"No fake legacy Rabbit response configured for {request.Mode}:{request.Command}.",
});
}
private static string BuildKey(string command, WorkflowLegacyRabbitMode mode)
{
return $"{mode}:{command}";
}
private static WorkflowMicroserviceResponse DequeueResponse(Queue<WorkflowMicroserviceResponse> queue)
{
if (queue.Count <= 1)
{
return queue.Peek();
}
return queue.Dequeue();
}
}

View File

@@ -0,0 +1,54 @@
<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>
<InternalsVisibleTo Include="StellaOps.Workflow.Renderer.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<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="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
<!-- TODO: These test files reference Serdica/Bulstrad-specific workflow types that are not in this repository.
Re-enable when those workflow definitions are ported to Stella. -->
<ItemGroup>
<Compile Remove="WorkflowCanonicalCompilerImportTests.cs" />
<Compile Remove="WorkflowCanonicalEmbeddedAssetTests.cs" />
<Compile Remove="WorkflowCanonicalExpressionRuntimeTests.cs" />
<Compile Remove="WorkflowCanonicalizationInventoryTests.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Engine\StellaOps.Workflow.Engine.csproj" />
<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.Renderer.ElkSharp\StellaOps.Workflow.Renderer.ElkSharp.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Renderer.ElkJs\StellaOps.Workflow.Renderer.ElkJs.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Renderer.Msagl\StellaOps.Workflow.Renderer.Msagl.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Renderer.Svg\StellaOps.Workflow.Renderer.Svg.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.DataStore.Oracle\StellaOps.Workflow.DataStore.Oracle.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.ElkSharp\StellaOps.ElkSharp.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Signaling.OracleAq\StellaOps.Workflow.Signaling.OracleAq.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.Oracle;
using StellaOps.Workflow.Engine.Services;
using StellaOps.Workflow.Renderer.ElkJs;
using StellaOps.Workflow.Renderer.ElkSharp;
using StellaOps.Workflow.Renderer.Msagl;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Workflow.Engine.Tests;
internal static class TechnicalStyleWorkflowTestHelpers
{
internal static ServiceProvider CreateServiceProvider(
RecordingWorkflowLegacyRabbitTransport transport,
string? defaultRuntimeProvider = null,
bool includeAdditionalTransportModules = false)
{
var services = new ServiceCollection();
var settings = new Dictionary<string, string?>
{
["WorkflowRetention:OpenStaleAfterDays"] = "30",
["WorkflowRetention:CompletedPurgeAfterDays"] = "180",
["GenericAssignmentPermissions:AdminRoles:0"] = "DBA",
};
if (!string.IsNullOrWhiteSpace(defaultRuntimeProvider))
{
settings["WorkflowRuntime:DefaultProvider"] = defaultRuntimeProvider;
settings["WorkflowRuntime:EnabledProviders:0"] = defaultRuntimeProvider;
}
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(settings)
.Build();
services.AddLogging();
RegisterTestWorkflows(services);
services.AddWorkflowEngineCoreServices(configuration);
services.AddWorkflowModule("transport.legacy-rabbit", "1.0.0");
if (includeAdditionalTransportModules)
{
services.AddWorkflowModule("transport.http", "1.0.0");
services.AddWorkflowModule("transport.graphql", "1.0.0");
services.AddWorkflowModule("transport.rabbit", "1.0.0");
services.AddWorkflowModule("transport.microservice", "1.0.0");
services.AddSingleton<INamedWorkflowRenderGraphLayoutEngine, ElkSharpWorkflowRenderLayoutEngine>();
services.AddSingleton<INamedWorkflowRenderGraphLayoutEngine, ElkJsWorkflowRenderLayoutEngine>();
services.AddSingleton<INamedWorkflowRenderGraphLayoutEngine, MsaglWorkflowRenderLayoutEngine>();
}
services.AddDbContext<WorkflowDbContext>(options =>
options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
services.AddScoped<IWorkflowLegacyRabbitTransport>(_ => transport);
var provider = services.BuildServiceProvider();
// ServiceProviderAccessor.Initialize(provider);
return provider;
}
internal static async Task<WorkflowTaskSummary> GetSingleOpenTaskAsync(
WorkflowRuntimeService runtimeService,
string workflowInstanceId)
{
return (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = workflowInstanceId,
Status = "Open",
})).Tasks.Single();
}
internal static void RegisterTestWorkflows(IServiceCollection services)
{
services.AddWorkflowRegistration<TestApproveApplicationWorkflow, TestApproveApplicationStartRequest>();
services.AddWorkflowRegistration<TestAssistantPrintInsisDocumentsWorkflow, TestAssistantPrintInsisDocumentsStartRequest>();
services.AddWorkflowRegistration<TestAssistantPolicyReinstateWorkflow, TestAssistantPolicyReinstateStartRequest>();
services.AddWorkflowRegistration<TestUpdateSrPolicyIdSrcAndCopyCoversWorkflow, TestUpdateSrPolicyIdSrcAndCopyCoversStartRequest>();
services.AddWorkflowRegistration<TestUserDataCheckConsistencyWorkflow, TestUserDataCheckConsistencyStartRequest>();
}
internal static object? GetPayloadValue(IDictionary<string, object?> payload, string key)
{
return payload.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase)).Value;
}
internal static async Task CompleteCustomerSearchAsync(
WorkflowRuntimeService runtimeService,
string workflowInstanceId,
string customerType,
string searchTerm)
{
var task = await GetSingleOpenTaskAsync(runtimeService, workflowInstanceId);
await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
{
WorkflowTaskId = task.WorkflowTaskId,
ActorId = "workflow-user",
ActorRoles = ["DBA"],
Payload = new Dictionary<string, object?>
{
["srCustType"] = customerType,
["srCustTerm"] = searchTerm,
},
});
}
internal static async Task CompleteObjectTypeAsync(
WorkflowRuntimeService runtimeService,
string workflowInstanceId,
string objectType)
{
var task = await GetSingleOpenTaskAsync(runtimeService, workflowInstanceId);
await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
{
WorkflowTaskId = task.WorkflowTaskId,
ActorId = "workflow-user",
ActorRoles = ["DBA"],
Payload = new Dictionary<string, object?>
{
["objectType"] = objectType,
},
});
}
internal static async Task CompletePricingAsync(
WorkflowRuntimeService runtimeService,
string workflowInstanceId,
long customerId,
string objectCode)
{
var task = await GetSingleOpenTaskAsync(runtimeService, workflowInstanceId);
await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
{
WorkflowTaskId = task.WorkflowTaskId,
ActorId = "workflow-user",
ActorRoles = ["DBA"],
Payload = new Dictionary<string, object?>
{
["policyInfo"] = new { duration = 12, installments = 1 },
["participants"] = Array.Empty<object>(),
["customer"] = new { id = customerId },
["srCustId"] = customerId,
["insuredItems"] = new[]
{
new
{
objectCode,
objectValues = new[] { new { prmCode = "SI", prmCvalue = 100000 } },
},
},
},
});
}
}

View File

@@ -0,0 +1,369 @@
using System;
using System.Collections.Generic;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Engine.Tests;
// ─────────────────────────────────────────────────────────────────────
// ApproveApplication — the primary test workflow.
// Mirrors the Serdica ApproveApplication structure:
// Start → InitState → ActivateTask("Approve Application")
// OnComplete: payload.answer == "reject" → Cancel → Complete
// else → Operations → decision → Convert → Complete
// or Reopen task with bypass roles
// ─────────────────────────────────────────────────────────────────────
internal sealed record TestApproveApplicationStartRequest
{
[WorkflowBusinessId]
[WorkflowBusinessReferencePart("policyId")]
public required long SrPolicyId { get; init; }
[WorkflowBusinessReferencePart("annexId")]
public required long SrAnnexId { get; init; }
[WorkflowBusinessReferencePart("customerId")]
public required long SrCustId { get; init; }
public IReadOnlyCollection<string>? InitialTaskRoles { get; init; }
}
internal sealed class TestApproveApplicationWorkflow
: IDeclarativeWorkflow<TestApproveApplicationStartRequest>
{
public string WorkflowName => "ApproveApplication";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Approve Application";
public IReadOnlyCollection<string> WorkflowRoles { get; } =
[
"DBA",
"UR_UNDERWRITER",
"APR_APPL",
"UR_OPERATIONS",
"UR_EXCLUSIVE_AGENT",
"UR_AGENT",
"UR_ORG_ADMIN",
"UR_HEALTH",
];
public WorkflowSpec<TestApproveApplicationStartRequest> Spec { get; } = WorkflowSpec.For<TestApproveApplicationStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("srAnnexId", WorkflowExpr.Path("start.srAnnexId")),
WorkflowExpr.Prop("srCustId", WorkflowExpr.Path("start.srCustId")),
WorkflowExpr.Prop(
"initialTaskRoles",
WorkflowExpr.Func(
"coalesce",
WorkflowExpr.Path("start.initialTaskRoles"),
WorkflowExpr.Array())),
WorkflowExpr.Prop("lineOfBusiness", WorkflowExpr.Null()),
WorkflowExpr.Prop("showRequireGroupError", WorkflowExpr.Bool(true)),
WorkflowExpr.Prop("policySubstatus", WorkflowExpr.String("REG")),
WorkflowExpr.Prop("isRejected", WorkflowExpr.Bool(false)),
WorkflowExpr.Prop("reopenTask", WorkflowExpr.Bool(false)),
WorkflowExpr.Prop("requiresBatch", WorkflowExpr.Bool(false))))
.AddTask(
WorkflowHumanTask.For<TestApproveApplicationStartRequest>(
"Approve Application",
"ApproveQTApproveApplication",
"business/policies")
.WithPayload(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId")),
WorkflowExpr.Prop("srAnnexId", WorkflowExpr.Path("state.srAnnexId"))))
.OnComplete(flow => flow
.Set("answer", WorkflowExpr.Path("payload.answer"))
.Set("isRejected", WorkflowExpr.Bool(false))
.Set("reopenTask", WorkflowExpr.Bool(false))
.Set("requiresBatch", WorkflowExpr.Bool(false))
.WhenPayloadEquals(
"answer",
"reject",
"Rejected?",
whenTrue => whenTrue
.Set("isRejected", WorkflowExpr.Bool(true))
.Call(
"Cancel Application",
new LegacyRabbitAddress("pas_annexprocessing_cancelaplorqt"),
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId"))))
.Complete(),
whenElse => whenElse
.Call<OperationsResponse>(
"Perform Operations",
new LegacyRabbitAddress("pas_operations_perform", WorkflowLegacyRabbitMode.MicroserviceConsumer),
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId")),
WorkflowExpr.Prop(
"runConditions",
WorkflowExpr.Obj(
WorkflowExpr.Prop("lineOfBusiness", WorkflowExpr.Path("state.lineOfBusiness")),
WorkflowExpr.Prop("operationType", WorkflowExpr.String("POLICY_ISSUING")),
WorkflowExpr.Prop(
"stages",
WorkflowExpr.Array(
WorkflowExpr.String("UNDERWRITING"),
WorkflowExpr.String("CONFIRMATION"),
WorkflowExpr.String("POST_PROCESSING")))))),
"operations")
.Set("reopenTask", WorkflowExpr.Not(WorkflowExpr.Path("result.operations.passed")))
.WhenStateFlag(
"reopenTask",
false,
"Operations Passed?",
whenTrue => whenTrue
.Set("policySubstatus", WorkflowExpr.String("INT_TRNSF_PEND"))
.Call(
"Convert Application To Policy",
new LegacyRabbitAddress("pas_polreg_convertapltopol"),
WorkflowExpr.Obj(
WorkflowExpr.Prop("SrPolicyId", WorkflowExpr.Path("state.srPolicyId")),
WorkflowExpr.Prop("Substatus", WorkflowExpr.String("INT_TRNSF_PEND")),
WorkflowExpr.Prop("ErrorIfAlreadyTheSame", WorkflowExpr.Bool(false)),
WorkflowExpr.Prop("Backdate", WorkflowExpr.Number(100))))
.Call(
"Generate Policy Number",
new LegacyRabbitAddress("pas_annexprocessing_generatepolicyno"),
WorkflowExpr.Obj(
WorkflowExpr.Prop("SrPolicyId", WorkflowExpr.Path("state.srPolicyId"))))
.Call<PolicyProductInfoResponse>(
"Load Policy Product Info",
new LegacyRabbitAddress("pas_get_policy_product_info"),
WorkflowExpr.Obj(
WorkflowExpr.Prop("SrPolicyId", WorkflowExpr.Path("state.srPolicyId"))),
"productInfo")
.Complete(),
reopen => reopen.ActivateTask(
"Approve Application",
WorkflowExpr.Func(
"coalesce",
WorkflowExpr.Path("result.operations.errorsBypassRoles"),
WorkflowExpr.Array()))))))
.StartWith(flow => flow.ActivateTask("Approve Application", WorkflowExpr.Path("state.initialTaskRoles")))
.Build();
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
private sealed record OperationsResponse
{
public required bool Passed { get; init; }
public required Dictionary<string, int> StageFailures { get; init; }
public string[]? ErrorsBypassRoles { get; init; }
}
private sealed record PolicyProductInfoResponse
{
public string? ProductCode { get; init; }
public string? Lob { get; init; }
public string? ContractType { get; init; }
}
}
// ─────────────────────────────────────────────────────────────────────
// AssistantPrintInsisDocuments — Fork + Timer pattern
// ─────────────────────────────────────────────────────────────────────
internal sealed record TestAssistantPrintInsisDocumentsStartRequest
{
[WorkflowBusinessId]
[WorkflowBusinessReferencePart("policyId")]
public required long SrPolicyId { get; init; }
}
internal sealed class TestAssistantPrintInsisDocumentsWorkflow
: IDeclarativeWorkflow<TestAssistantPrintInsisDocumentsStartRequest>
{
public string WorkflowName => "AssistantPrintInsisDocuments";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Assistant Print INSIS Documents";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public WorkflowSpec<TestAssistantPrintInsisDocumentsStartRequest> Spec { get; } = WorkflowSpec.For<TestAssistantPrintInsisDocumentsStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")),
WorkflowExpr.Prop("printAttempt", WorkflowExpr.Number(0)),
WorkflowExpr.Prop("lastPrintAttempt", WorkflowExpr.Number(0))))
.AddTask(
WorkflowHumanTask.For<TestAssistantPrintInsisDocumentsStartRequest>(
"After Print Review",
"AfterPrintReview",
"business/policies",
["DBA"])
.WithPayload(
WorkflowExpr.Obj(
WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase"))))
.OnComplete(flow => flow.Complete()))
.StartWith(flow => flow
.Set("phase", "fork-started")
.Fork(
"Spin off async process",
left => left
.ActivateTask("After Print Review"),
right => right
.Wait("Wait 5m", WorkflowExpr.String("00:05:00"))
.Set("phase", "timer-done"))
.Complete())
.Build();
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
}
// ─────────────────────────────────────────────────────────────────────
// AssistantPolicyReinstate — IPAL branch transfer + retry
// ─────────────────────────────────────────────────────────────────────
internal sealed record TestAssistantPolicyReinstateStartRequest
{
[WorkflowBusinessId]
[WorkflowBusinessReferencePart("policyId")]
public required long SrPolicyId { get; init; }
}
internal sealed class TestAssistantPolicyReinstateWorkflow
: IDeclarativeWorkflow<TestAssistantPolicyReinstateStartRequest>
{
public string WorkflowName => "AssistantPolicyReinstate";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Assistant Policy Reinstate";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public WorkflowSpec<TestAssistantPolicyReinstateStartRequest> Spec { get; } = WorkflowSpec.For<TestAssistantPolicyReinstateStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("existsOnIpal", WorkflowExpr.Bool(false)),
WorkflowExpr.Prop("transferApproved", WorkflowExpr.Bool(false))))
.AddTask(
WorkflowHumanTask.For<TestAssistantPolicyReinstateStartRequest>(
"Retry",
"RetryReinstate",
"business/policies",
["DBA"])
.WithPayload(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId"))))
.OnComplete(flow => flow.Complete()))
.StartWith(flow => flow
.Call(
"Policy Reinstate INSIS",
new LegacyRabbitAddress("pas_policy_reinstate_insis"),
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId"))))
.Set("existsOnIpal", WorkflowExpr.Bool(true))
.WhenStateFlag(
"existsOnIpal",
true,
"Exists on IPAL?",
whenTrue => whenTrue
.Call(
"Transfer Annex",
new LegacyRabbitAddress("pas_transfer_annex"),
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId"))))
.Set("transferApproved", WorkflowExpr.Bool(true))
.WhenStateFlag(
"transferApproved",
true,
"Transfer approved?",
approved => approved.Complete(),
retry => retry.ActivateTask("Retry")),
whenElse => whenElse.Complete()))
.Build();
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
}
// ─────────────────────────────────────────────────────────────────────
// UpdateSrPolicyIdSrcAndCopyCovers — Continue-or-end decision
// ─────────────────────────────────────────────────────────────────────
internal sealed record TestUpdateSrPolicyIdSrcAndCopyCoversStartRequest
{
[WorkflowBusinessId]
[WorkflowBusinessReferencePart("policyId")]
public required long SrPolicyId { get; init; }
}
internal sealed class TestUpdateSrPolicyIdSrcAndCopyCoversWorkflow
: IDeclarativeWorkflow<TestUpdateSrPolicyIdSrcAndCopyCoversStartRequest>
{
public string WorkflowName => "UpdateSrPolicyIdSrcAndCopyCovers";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Update Policy ID Source and Copy Covers";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public WorkflowSpec<TestUpdateSrPolicyIdSrcAndCopyCoversStartRequest> Spec { get; } = WorkflowSpec.For<TestUpdateSrPolicyIdSrcAndCopyCoversStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("shouldContinue", WorkflowExpr.Bool(true))))
.StartWith(flow => flow
.Call(
"Update Policy Source",
new LegacyRabbitAddress("pas_update_policy_source"),
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId"))))
.WhenStateFlag(
"shouldContinue",
true,
"Continue or end process",
whenTrue => whenTrue
.Call(
"Copy Covers",
new LegacyRabbitAddress("pas_copy_covers"),
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId"))))
.Complete(),
whenElse => whenElse.Complete()))
.Build();
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
}
// ─────────────────────────────────────────────────────────────────────
// UserDataCheckConsistency — Simple service call workflow
// ─────────────────────────────────────────────────────────────────────
internal sealed record TestUserDataCheckConsistencyStartRequest
{
[WorkflowBusinessId]
[WorkflowBusinessReferencePart("policyId")]
public required long SrPolicyId { get; init; }
}
internal sealed class TestUserDataCheckConsistencyWorkflow
: IDeclarativeWorkflow<TestUserDataCheckConsistencyStartRequest>
{
public string WorkflowName => "UserDataCheckConsistency";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "User Data Check Consistency";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public WorkflowSpec<TestUserDataCheckConsistencyStartRequest> Spec { get; } = WorkflowSpec.For<TestUserDataCheckConsistencyStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("isConsistent", WorkflowExpr.Bool(false))))
.StartWith(flow => flow
.Call(
"Check User Data",
new LegacyRabbitAddress("pas_check_user_data"),
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId"))))
.Set("isConsistent", WorkflowExpr.Bool(true))
.Complete())
.Build();
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
}

View File

@@ -0,0 +1,742 @@
using System.Collections.Generic;
using System.Linq;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowCanonicalCompilerImportTests
{
private static readonly IWorkflowFunctionCatalog FunctionCatalog =
new WorkflowFunctionCatalog([new WorkflowCoreFunctionProvider() /* TODO: Add Stella-specific function providers when available */]);
private static readonly WorkflowInstalledModule[] InstalledModules =
[
new("workflow.dsl.core", "1.0.0"),
new("workflow.functions.core", "1.0.0"),
new("workflow.functions.bulstrad", "1.0.0"),
new("transport.legacy-rabbit", "1.0.0"),
new("transport.http", "1.0.0"),
];
[Test]
public void Compile_WhenRevertToApplicationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition()
{
var compilation = WorkflowCanonicalDefinitionCompiler.Compile(new RevertToApplicationWorkflow(), FunctionCatalog);
compilation.Succeeded.Should().BeTrue();
compilation.Diagnostics.Should().BeEmpty();
compilation.Definition.Should().NotBeNull();
compilation.Definition!.RequiredModules.Select(x => x.ModuleName)
.Should()
.Contain(["workflow.dsl.core", "workflow.functions.core", "transport.legacy-rabbit"]);
var importValidation = WorkflowCanonicalImportValidator.Validate(
WorkflowCanonicalJsonSerializer.Serialize(compilation.Definition),
InstalledModules,
FunctionCatalog);
importValidation.Succeeded.Should().BeTrue();
importValidation.SchemaErrors.Should().BeEmpty();
importValidation.SemanticErrors.Should().BeEmpty();
importValidation.ModuleErrors.Should().BeEmpty();
importValidation.Definition.Should().BeEquivalentTo(compilation.Definition);
}
[Test]
public void Compile_WhenApprovePolicyApplicationNewUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition()
{
var compilation = WorkflowCanonicalDefinitionCompiler.Compile(new ApprovePolicyApplicationNewWorkflow(), FunctionCatalog);
compilation.Succeeded.Should().BeTrue();
compilation.Diagnostics.Should().BeEmpty();
compilation.Definition.Should().NotBeNull();
compilation.Definition!.Start.InitialSequence.Steps.Should().ContainSingle();
compilation.Definition.Start.InitialSequence.Steps.Single()
.Should()
.BeOfType<WorkflowContinueWithWorkflowStepDeclaration>();
var importValidation = WorkflowCanonicalImportValidator.Validate(
WorkflowCanonicalJsonSerializer.Serialize(compilation.Definition),
InstalledModules,
FunctionCatalog);
importValidation.Succeeded.Should().BeTrue();
importValidation.SchemaErrors.Should().BeEmpty();
importValidation.SemanticErrors.Should().BeEmpty();
importValidation.ModuleErrors.Should().BeEmpty();
}
[Test]
public void Compile_WhenAssistantPrintInsisDocumentsIsCanonicalized_ShouldPreserveForkAndTimerSteps()
{
var compilation = WorkflowCanonicalDefinitionCompiler.Compile(new AssistantPrintInsisDocumentsWorkflow(), FunctionCatalog);
compilation.Succeeded.Should().BeTrue();
compilation.Diagnostics.Should().BeEmpty();
compilation.Definition.Should().NotBeNull();
var steps = EnumerateSteps(compilation.Definition!.Start.InitialSequence).ToArray();
var forkSteps = steps.OfType<WorkflowForkStepDeclaration>().ToArray();
var timerSteps = steps.OfType<WorkflowTimerStepDeclaration>().ToArray();
forkSteps.Should().ContainSingle(step =>
string.Equals(step.StepName, "Spin off async process", global::System.StringComparison.Ordinal));
timerSteps.Should().Contain(step =>
string.Equals(step.StepName, "Wait 5m", global::System.StringComparison.Ordinal));
}
[Test]
public void Compile_WhenUpdateSrPolicyIdSrcAndCopyCoversIsCanonicalized_ShouldPreserveStandaloneEndDecision()
{
var compilation = WorkflowCanonicalDefinitionCompiler.Compile(new UpdateSrPolicyIdSrcAndCopyCoversWorkflow());
compilation.Succeeded.Should().BeTrue();
compilation.Diagnostics.Should().BeEmpty();
compilation.Definition.Should().NotBeNull();
var steps = EnumerateSteps(compilation.Definition!.Start.InitialSequence).ToArray();
var endDecision = steps.OfType<WorkflowDecisionStepDeclaration>().Single(step =>
string.Equals(step.DecisionName, "Continue or end process", global::System.StringComparison.Ordinal));
var standaloneAssignments = EnumerateSteps(endDecision.WhenTrue).OfType<WorkflowSetStateStepDeclaration>().ToArray();
standaloneAssignments.Select(step => step.StateKey)
.Should()
.Contain(["signalResponse", "isEndOfProcess"]);
}
[Test]
public void Compile_WhenAssistantTransferToInsisIsCanonicalized_ShouldPreserveRetryTaskAndTransferDecision()
{
var compilation = WorkflowCanonicalDefinitionCompiler.Compile(new AssistantTransferToInsisWorkflow());
compilation.Succeeded.Should().BeTrue();
compilation.Diagnostics.Should().BeEmpty();
compilation.Definition.Should().NotBeNull();
var startSteps = EnumerateSteps(compilation.Definition!.Start.InitialSequence).ToArray();
startSteps.OfType<WorkflowDecisionStepDeclaration>()
.Should()
.Contain(step => string.Equals(step.DecisionName, "Transfer approved?", global::System.StringComparison.Ordinal));
compilation.Definition.Tasks.Should().ContainSingle(task =>
string.Equals(task.TaskName, "Retry", global::System.StringComparison.Ordinal) &&
string.Equals(task.TaskType, "PolicyIntegrationPartialFailure", global::System.StringComparison.Ordinal));
var retryTask = compilation.Definition.Tasks.Single(task => string.Equals(task.TaskName, "Retry", global::System.StringComparison.Ordinal));
EnumerateSteps(retryTask.OnComplete).OfType<WorkflowTransportCallStepDeclaration>()
.Should()
.Contain(step => string.Equals(step.StepName, "Transfer Annex", global::System.StringComparison.Ordinal));
}
[Test]
public void Compile_WhenAssistantPolicyReinstateIsCanonicalized_ShouldPreserveIpalGatewayAndRetryTask()
{
var compilation = WorkflowCanonicalDefinitionCompiler.Compile(new AssistantPolicyReinstateWorkflow(), FunctionCatalog);
compilation.Succeeded.Should().BeTrue();
compilation.Diagnostics.Should().BeEmpty();
compilation.Definition.Should().NotBeNull();
var startSteps = EnumerateSteps(compilation.Definition!.Start.InitialSequence).ToArray();
var decisionNames = startSteps.OfType<WorkflowDecisionStepDeclaration>().Select(step => step.DecisionName).ToArray();
decisionNames.Should().Contain("Exists on IPAL?");
decisionNames.Should().Contain("Transfer approved?");
compilation.Definition.Tasks.Should().ContainSingle(task =>
string.Equals(task.TaskName, "Retry", global::System.StringComparison.Ordinal) &&
string.Equals(task.TaskType, "PolicyIntegrationPartialFailure", global::System.StringComparison.Ordinal));
}
[Test]
public void Compile_WhenPolicyReinstateIsCanonicalized_ShouldPreserveAnnexDescriptionAndTransferRetry()
{
var compilation = WorkflowCanonicalDefinitionCompiler.Compile(new PolicyReinstateWorkflow(), FunctionCatalog);
compilation.Succeeded.Should().BeTrue();
compilation.Diagnostics.Should().BeEmpty();
compilation.Definition.Should().NotBeNull();
var startSteps = EnumerateSteps(compilation.Definition!.Start.InitialSequence).ToArray();
startSteps.OfType<WorkflowTransportCallStepDeclaration>()
.Should()
.Contain(step => string.Equals(step.StepName, "Get Annex Description", global::System.StringComparison.Ordinal));
startSteps.OfType<WorkflowActivateTaskStepDeclaration>()
.Should()
.Contain(step => string.Equals(step.TaskName, "Confirm Reinstatement", global::System.StringComparison.Ordinal));
compilation.Definition.Tasks.Select(task => task.TaskName)
.Should()
.Contain(["Confirm Reinstatement", "Retry"]);
var confirmTask = compilation.Definition.Tasks.Single(task =>
string.Equals(task.TaskName, "Confirm Reinstatement", global::System.StringComparison.Ordinal));
var confirmSteps = EnumerateSteps(confirmTask.OnComplete).ToArray();
confirmSteps.OfType<WorkflowDecisionStepDeclaration>()
.Should()
.Contain(step => string.Equals(step.DecisionName, "Master contract?", global::System.StringComparison.Ordinal));
confirmSteps.OfType<WorkflowActivateTaskStepDeclaration>()
.Should()
.Contain(step => string.Equals(step.TaskName, "Retry", global::System.StringComparison.Ordinal));
}
[Test]
public void Compile_WhenReviewPolicyCancellationIsCanonicalized_ShouldPreservePremiumStartAndRetryRouting()
{
var compilation = WorkflowCanonicalDefinitionCompiler.Compile(new ReviewPolicyCancellationWorkflow(), FunctionCatalog);
compilation.Succeeded.Should().BeTrue();
compilation.Diagnostics.Should().BeEmpty();
compilation.Definition.Should().NotBeNull();
var startSteps = EnumerateSteps(compilation.Definition!.Start.InitialSequence).ToArray();
startSteps.OfType<WorkflowTransportCallStepDeclaration>()
.Should()
.Contain(step => string.Equals(step.StepName, "Calculate Premium For Object", global::System.StringComparison.Ordinal));
startSteps.OfType<WorkflowActivateTaskStepDeclaration>()
.Should()
.Contain(step => string.Equals(step.TaskName, "Review policy cancellation", global::System.StringComparison.Ordinal));
compilation.Definition.Tasks.Select(task => task.TaskName)
.Should()
.Contain(["Review policy cancellation", "Retry"]);
var retryTask = compilation.Definition.Tasks.Single(task => string.Equals(task.TaskName, "Retry", global::System.StringComparison.Ordinal));
EnumerateSteps(retryTask.OnComplete).OfType<WorkflowDecisionStepDeclaration>()
.Should()
.Contain(step => string.Equals(step.DecisionName, "Retry transfer?", global::System.StringComparison.Ordinal));
}
[TestCaseSource(nameof(GetCanonicalizedWorkflowFactories))]
public void Compile_WhenWorkflowUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition(
string workflowName,
Func<WorkflowCanonicalCompilationResult> compile)
{
var compilation = compile();
compilation.WorkflowName.Should().Be(workflowName);
compilation.Succeeded.Should().BeTrue();
compilation.Diagnostics.Should().BeEmpty();
compilation.Definition.Should().NotBeNull();
var importValidation = WorkflowCanonicalImportValidator.Validate(
WorkflowCanonicalJsonSerializer.Serialize(compilation.Definition!),
InstalledModules,
FunctionCatalog);
importValidation.Succeeded.Should().BeTrue();
importValidation.SchemaErrors.Should().BeEmpty();
importValidation.SemanticErrors.Should().BeEmpty();
importValidation.ModuleErrors.Should().BeEmpty();
importValidation.Definition.Should().BeEquivalentTo(compilation.Definition);
}
private static IEnumerable<TestCaseData> GetCanonicalizedWorkflowFactories()
{
yield return new TestCaseData(
"PdfGenerator",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new PdfGeneratorWorkflow())))
.SetName("Compile_WhenPdfGeneratorUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"QuoteOrAplCancel",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new QuoteOrAplCancelWorkflow())))
.SetName("Compile_WhenQuoteOrAplCancelUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"UpdateSrPolicyIdSrcAndCopyCovers",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new UpdateSrPolicyIdSrcAndCopyCoversWorkflow())))
.SetName("Compile_WhenUpdateSrPolicyIdSrcAndCopyCoversUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AgriculturalCropsPolicyIssue3502",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AgriculturalCropsPolicyIssue3502Workflow())))
.SetName("Compile_WhenAgriculturalCropsPolicyIssue3502UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AgriculturalInsurancePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AgriculturalInsurancePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenAgriculturalInsurancePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AgriculturalInsuranceIssuePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AgriculturalInsuranceIssuePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenAgriculturalInsuranceIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"CargoOneTimePolicyIssue1100",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new CargoOneTimePolicyIssue1100Workflow())))
.SetName("Compile_WhenCargoOneTimePolicyIssue1100UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"CargoPolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new CargoPolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenCargoPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"CargoIssuePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new CargoIssuePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenCargoIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"CompulsoryAccidentAtWorkInsurancePolicyIssue3607",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new CompulsoryAccidentAtWorkInsurancePolicyIssue3607Workflow())))
.SetName("Compile_WhenCompulsoryAccidentAtWorkInsurancePolicyIssue3607UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"ConstructionAndAssemblyRisksPolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new ConstructionAndAssemblyRisksPolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenConstructionAndAssemblyRisksPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"GuestsAtTouristSitesOnTheTerritoryOfTheRepublicOfBulgariaPolicyIssue3635",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new GuestsAtTouristSitesOnTheTerritoryOfTheRepublicOfBulgariaPolicyIssue3635Workflow())))
.SetName("Compile_WhenGuestsAtTouristSitesOnTheTerritoryOfTheRepublicOfBulgariaPolicyIssue3635UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"PersonTravelPackagePolicyIssueEmerald",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new PersonTravelPackagePolicyIssueEmeraldWorkflow())))
.SetName("Compile_WhenPersonTravelPackagePolicyIssueEmeraldUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"PropertyCombinedPolicyIssue2200",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new PropertyCombinedPolicyIssue2200Workflow())))
.SetName("Compile_WhenPropertyCombinedPolicyIssue2200UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"IndustrialPropertyPolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new IndustrialPropertyPolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenIndustrialPropertyPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"IndustrialPropertyIssuePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new IndustrialPropertyIssuePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenIndustrialPropertyIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"ApproveApplication",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new ApproveApplicationWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenApproveApplicationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"InsisIntegrationNew",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new InsisIntegrationNewWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenInsisIntegrationNewUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"ReviewPolicyCancellation",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new ReviewPolicyCancellationWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenReviewPolicyCancellationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"ReviewPolicyOpenForChange",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new ReviewPolicyOpenForChangeWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenReviewPolicyOpenForChangeUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"ReviewPolicyRenew",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new ReviewPolicyRenewWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenReviewPolicyRenewUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"TechnicalInsuranceIssuePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new TechnicalInsuranceIssuePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenTechnicalInsuranceIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"LiabilityPolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new LiabilityPolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenLiabilityPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"LiabilityPolicyForLiabilityObjectOnly",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new LiabilityPolicyForLiabilityObjectOnlyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenLiabilityPolicyForLiabilityObjectOnlyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"LiabilityIssuePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new LiabilityIssuePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenLiabilityIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"FarmAnimalsPolicyIssue3510",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new FarmAnimalsPolicyIssue3510Workflow())))
.SetName("Compile_WhenFarmAnimalsPolicyIssue3510UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"MoneyPolicyIssue3300",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new MoneyPolicyIssue3300Workflow())))
.SetName("Compile_WhenMoneyPolicyIssue3300UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"HomePresentOffer",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new HomePresentOfferWorkflow())))
.SetName("Compile_WhenHomePresentOfferUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"HomeIssuePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new HomeIssuePolicyWorkflow())))
.SetName("Compile_WhenHomeIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"HomeQuotation",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new HomeQuotationWorkflow())))
.SetName("Compile_WhenHomeQuotationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"HomePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new HomePolicyWorkflow())))
.SetName("Compile_WhenHomePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"RailVehiclesPolicyIssue4800",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new RailVehiclesPolicyIssue4800Workflow())))
.SetName("Compile_WhenRailVehiclesPolicyIssue4800UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AirAndRailTransportPolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AirAndRailTransportPolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenAirAndRailTransportPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AirAndRailTransportIssuePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AirAndRailTransportIssuePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenAirAndRailTransportIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"GenericHealthGroupPolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new GenericHealthGroupPolicyWorkflow())))
.SetName("Compile_WhenGenericHealthGroupPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"GenericHealthIndividualPolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new GenericHealthIndividualPolicyWorkflow())))
.SetName("Compile_WhenGenericHealthIndividualPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"GroupPolicies",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new GroupPoliciesWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenGroupPoliciesUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"GroupPoliciesIssuePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new GroupPoliciesIssuePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenGroupPoliciesIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"HealthClaimReimbursement",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new HealthClaimReimbursementWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenHealthClaimReimbursementUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AutomatedHealthClaimReimbursement",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AutomatedHealthClaimReimbursementWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenAutomatedHealthClaimReimbursementUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"HealthClaimPar",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new HealthClaimParWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenHealthClaimParUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"HealthGroupPolicies",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new HealthGroupPoliciesWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenHealthGroupPoliciesUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"HealthGroupPoliciesIssuePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new HealthGroupPoliciesIssuePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenHealthGroupPoliciesIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"HealthIndividualPolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new HealthIndividualPolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenHealthIndividualPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"HealthIndividualIssuePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new HealthIndividualIssuePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenHealthIndividualIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VehicleCasco100",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleCasco100Workflow())))
.SetName("Compile_WhenVehicleCasco100UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VehicleBorderlineMtplPolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleBorderlineMtplPolicyWorkflow())))
.SetName("Compile_WhenVehicleBorderlineMtplPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VehicleCascoPolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleCascoPolicyWorkflow())))
.SetName("Compile_WhenVehicleCascoPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VehicleCascoPolicyWithPhotos",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleCascoPolicyWithPhotosWorkflow())))
.SetName("Compile_WhenVehicleCascoPolicyWithPhotosUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VesselInsurancePolicyIssue1126",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VesselInsurancePolicyIssue1126Workflow())))
.SetName("Compile_WhenVesselInsurancePolicyIssue1126UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantChangeVehicleOwnership",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantChangeVehicleOwnershipWorkflow())))
.SetName("Compile_WhenAssistantChangeVehicleOwnershipUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantRegister2215Quote",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantRegister2215QuoteWorkflow())))
.SetName("Compile_WhenAssistantRegister2215QuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantRegisterMtplQuote",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantRegisterMtplQuoteWorkflow())))
.SetName("Compile_WhenAssistantRegisterMtplQuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantTransferToInsis",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantTransferToInsisWorkflow())))
.SetName("Compile_WhenAssistantTransferToInsisUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"ChangeVehicleOwnership",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new ChangeVehicleOwnershipWorkflow())))
.SetName("Compile_WhenChangeVehicleOwnershipUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"ChangeVehicleRegistration",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new ChangeVehicleRegistrationWorkflow())))
.SetName("Compile_WhenChangeVehicleRegistrationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VehicleMtplPolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleMtplPolicyWorkflow())))
.SetName("Compile_WhenVehicleMtplPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VehicleClaim",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleClaimWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenVehicleClaimUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VehicleSharedIssuePolicyCasco",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleSharedIssuePolicyCascoWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenVehicleSharedIssuePolicyCascoUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VehicleSharedPresentOffer",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleSharedPresentOfferWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenVehicleSharedPresentOfferUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VehicleSharedIssuePolicyMtpl",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleSharedIssuePolicyMtplWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenVehicleSharedIssuePolicyMtplUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VehicleSharedGetCarInfo",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleSharedGetCarInfoWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenVehicleSharedGetCarInfoUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VehicleMtplQuote",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleMtplQuoteWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenVehicleMtplQuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VehicleCascoQuote",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleCascoQuoteWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenVehicleCascoQuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"CascoWithPhotoIssuePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new CascoWithPhotoIssuePolicyWorkflow())))
.SetName("Compile_WhenCascoWithPhotoIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"NewVehicleSharedIssuePolicyCasco",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new NewVehicleSharedIssuePolicyCascoWorkflow())))
.SetName("Compile_WhenNewVehicleSharedIssuePolicyCascoUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"ClaimNotification",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new ClaimNotificationWorkflow())))
.SetName("Compile_WhenClaimNotificationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"ClaimProcessDocuments",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new ClaimProcessDocumentsWorkflow())))
.SetName("Compile_WhenClaimProcessDocumentsUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"ClaimProcessEvaluations",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new ClaimProcessEvaluationsWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenClaimProcessEvaluationsUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"ClaimProcessNotification",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new ClaimProcessNotificationWorkflow())))
.SetName("Compile_WhenClaimProcessNotificationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantAddAnnex",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantAddAnnexWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenAssistantAddAnnexUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantAddCover",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantAddCoverWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenAssistantAddCoverUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantClaimNotification",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantClaimNotificationWorkflow())))
.SetName("Compile_WhenAssistantClaimNotificationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantPrintInsisDocuments",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantPrintInsisDocumentsWorkflow())))
.SetName("Compile_WhenAssistantPrintInsisDocumentsUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantPolicyCancellation",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantPolicyCancellationWorkflow())))
.SetName("Compile_WhenAssistantPolicyCancellationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantPolicyReinstate",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantPolicyReinstateWorkflow())))
.SetName("Compile_WhenAssistantPolicyReinstateUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantCustomerOpenForChange",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantCustomerOpenForChangeWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenAssistantCustomerOpenForChangeUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantIssueApplication",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantIssueApplicationWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenAssistantIssueApplicationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantHomeIssueApplication",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantHomeIssueApplicationWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenAssistantHomeIssueApplicationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantHomeRegisterQuote",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantHomeRegisterQuoteWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenAssistantHomeRegisterQuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantPersonIssueApplication",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantPersonIssueApplicationWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenAssistantPersonIssueApplicationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantPersonRegisterQuote",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantPersonRegisterQuoteWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenAssistantPersonRegisterQuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AssistantRegisterQuote",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantRegisterQuoteWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenAssistantRegisterQuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"PersonPolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new PersonPolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenPersonPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"PersonIssuePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new PersonIssuePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenPersonIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"PersonLifePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new PersonLifePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenPersonLifePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"PersonPolicyEmerald",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new PersonPolicyEmeraldWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenPersonPolicyEmeraldUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"CompulsoryAccidentAtWorkInsurancePolicyIssue3607Policy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new CompulsoryAccidentAtWorkInsurancePolicyIssue3607PolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenCompulsoryAccidentAtWorkInsurancePolicyIssue3607PolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"GuestsAtTouristSitesOnTheTerritoryOfTheRepublicOfBulgariaPolicyIssue3635Policy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new GuestsAtTouristSitesOnTheTerritoryOfTheRepublicOfBulgariaPolicyIssue3635PolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenGuestsAtTouristSitesOnTheTerritoryOfTheRepublicOfBulgariaPolicyIssue3635PolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"ApproveTreaty",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new ApproveTreatyWorkflow())))
.SetName("Compile_WhenApproveTreatyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"ApproveFacultative",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new ApproveFacultativeWorkflow())))
.SetName("Compile_WhenApproveFacultativeUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"UserDataCheckConsistency",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new UserDataCheckConsistencyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenUserDataCheckConsistencyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"AnnexCancellation",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new AnnexCancellationWorkflow())))
.SetName("Compile_WhenAnnexCancellationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"FinanceInsurancePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new FinanceInsurancePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenFinanceInsurancePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"FinanceInsuranceIssuePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new FinanceInsuranceIssuePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenFinanceInsuranceIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"OpenForChangePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new OpenForChangePolicyWorkflow())))
.SetName("Compile_WhenOpenForChangePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"PolicyCancellation",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new PolicyCancellationWorkflow())))
.SetName("Compile_WhenPolicyCancellationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"PolicyRenew",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new PolicyRenewWorkflow())))
.SetName("Compile_WhenPolicyRenewUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"PolicyReinstate",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new PolicyReinstateWorkflow())))
.SetName("Compile_WhenPolicyReinstateUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"CustomerOpenForChange",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new CustomerOpenForChangeWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenCustomerOpenForChangeUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"TechnicalInsurancePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new TechnicalInsurancePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenTechnicalInsurancePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"ContinueOnOpenedAnnex",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new ContinueOnOpenedAnnexWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenContinueOnOpenedAnnexUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"QuotationConfirm",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new QuotationConfirmWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenQuotationConfirmUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VesselGetInfo",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VesselGetInfoWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenVesselGetInfoUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VesselRegisterQuote",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VesselRegisterQuoteWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenVesselRegisterQuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VesselPresentOffer",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VesselPresentOfferWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenVesselPresentOfferUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VesselIssuePolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VesselIssuePolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenVesselIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
yield return new TestCaseData(
"VesselPolicy",
new Func<WorkflowCanonicalCompilationResult>(() => WorkflowCanonicalDefinitionCompiler.Compile(new VesselPolicyWorkflow(), FunctionCatalog)))
.SetName("Compile_WhenVesselPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition");
}
private static IEnumerable<WorkflowStepDeclaration> EnumerateSteps(WorkflowStepSequenceDeclaration sequence)
{
foreach (var step in sequence.Steps)
{
yield return step;
foreach (var nested in EnumerateNestedSteps(step))
{
yield return nested;
}
}
}
private static IEnumerable<WorkflowStepDeclaration> EnumerateNestedSteps(WorkflowStepDeclaration step)
{
switch (step)
{
case WorkflowTransportCallStepDeclaration transport:
if (transport.WhenFailure is not null)
{
foreach (var nested in EnumerateSteps(transport.WhenFailure))
{
yield return nested;
}
}
if (transport.WhenTimeout is not null)
{
foreach (var nested in EnumerateSteps(transport.WhenTimeout))
{
yield return nested;
}
}
yield break;
case WorkflowDecisionStepDeclaration decision:
foreach (var nested in EnumerateSteps(decision.WhenTrue))
{
yield return nested;
}
foreach (var nested in EnumerateSteps(decision.WhenElse))
{
yield return nested;
}
yield break;
case WorkflowRepeatStepDeclaration repeat:
foreach (var nested in EnumerateSteps(repeat.Body))
{
yield return nested;
}
yield break;
case WorkflowForkStepDeclaration fork:
foreach (var branch in fork.Branches)
{
foreach (var nested in EnumerateSteps(branch))
{
yield return nested;
}
}
yield break;
default:
yield break;
}
}
}

View File

@@ -0,0 +1,402 @@
using System.Collections.Generic;
using System.Linq;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowCanonicalDefinitionTests
{
private static readonly IWorkflowFunctionCatalog CoreFunctionCatalog =
new WorkflowFunctionCatalog([new WorkflowCoreFunctionProvider()]);
[Test]
public void SerializeDeserialize_WhenCanonicalDefinitionIsValid_ShouldRoundTrip()
{
var definition = new WorkflowCanonicalDefinition
{
WorkflowName = "TestWorkflow",
WorkflowVersion = "1.0.0",
DisplayName = "Test Workflow",
StartRequest = new WorkflowRequestContractDeclaration
{
ContractName = "TestWorkflowRequest",
SchemaReference = "schemas/test-workflow-request.json",
},
WorkflowRoles = ["UR_AGENT"],
BusinessReference = new WorkflowBusinessReferenceDeclaration
{
KeyExpression = new WorkflowPathExpressionDefinition
{
Path = "state.srPolicyId",
},
Parts =
[
new WorkflowNamedExpressionDefinition
{
Name = "policyId",
Expression = new WorkflowPathExpressionDefinition
{
Path = "state.srPolicyId",
},
},
],
},
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = new WorkflowObjectExpressionDefinition
{
Properties =
[
new WorkflowNamedExpressionDefinition
{
Name = "srPolicyId",
Expression = new WorkflowPathExpressionDefinition
{
Path = "start.srPolicyId",
},
},
],
},
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowSetStateStepDeclaration
{
StateKey = "answer",
ValueExpression = new WorkflowStringExpressionDefinition
{
Value = "pre-calc",
},
},
new WorkflowActivateTaskStepDeclaration
{
TaskName = "Present Offer",
},
],
},
},
Tasks =
[
new WorkflowTaskDeclaration
{
TaskName = "Present Offer",
TaskType = "PresentOffer",
RouteExpression = new WorkflowStringExpressionDefinition
{
Value = "business/policies",
},
PayloadExpression = new WorkflowObjectExpressionDefinition
{
Properties =
[
new WorkflowNamedExpressionDefinition
{
Name = "srPolicyId",
Expression = new WorkflowPathExpressionDefinition
{
Path = "state.srPolicyId",
},
},
],
},
TaskRoles = ["UR_AGENT"],
OnComplete = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowTransportCallStepDeclaration
{
StepName = "Convert To Policy",
Invocation = new WorkflowTransportInvocationDeclaration
{
Address = new WorkflowLegacyRabbitAddressDeclaration
{
Command = "pas_polreg_convertapltopol",
Mode = WorkflowLegacyRabbitMode.Envelope,
},
PayloadExpression = new WorkflowObjectExpressionDefinition
{
Properties =
[
new WorkflowNamedExpressionDefinition
{
Name = "srPolicyId",
Expression = new WorkflowPathExpressionDefinition
{
Path = "state.srPolicyId",
},
},
],
},
},
ResultKey = "convertResult",
WhenFailure = new WorkflowStepSequenceDeclaration
{
Steps = [new WorkflowCompleteStepDeclaration()],
},
},
new WorkflowCompleteStepDeclaration(),
],
},
},
],
};
var json = WorkflowCanonicalJsonSerializer.Serialize(definition);
var roundTripped = WorkflowCanonicalJsonSerializer.Deserialize(json);
roundTripped.Should().BeEquivalentTo(definition);
}
[Test]
public void SerializeDeserialize_WhenUsingGroupedExpressionAndRabbitAddress_ShouldRoundTrip()
{
var definition = new WorkflowCanonicalDefinition
{
WorkflowName = "RabbitWorkflow",
WorkflowVersion = "1.0.0",
DisplayName = "Rabbit Workflow",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = WorkflowExpr.Group(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")))),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowTransportCallStepDeclaration
{
StepName = "Publish",
Invocation = new WorkflowTransportInvocationDeclaration
{
Address = new WorkflowRabbitAddressDeclaration
{
Exchange = "pas.exchange",
RoutingKey = "policy.publish",
},
PayloadExpression = WorkflowExpr.Group(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId")))),
},
},
new WorkflowCompleteStepDeclaration(),
],
},
},
};
var json = WorkflowCanonicalJsonSerializer.Serialize(definition);
var roundTripped = WorkflowCanonicalJsonSerializer.Deserialize(json);
roundTripped.Should().BeEquivalentTo(definition);
}
[Test]
public void SerializeDeserialize_WhenUsingExternalSignalStep_ShouldRoundTrip()
{
var definition = new WorkflowCanonicalDefinition
{
WorkflowName = "ExternalSignalWorkflow",
WorkflowVersion = "1.0.0",
DisplayName = "External Signal Workflow",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = WorkflowExpr.Obj(
WorkflowExpr.Prop("phase", WorkflowExpr.String("waiting"))),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowExternalSignalStepDeclaration
{
StepName = "Wait For Upload",
SignalNameExpression = WorkflowExpr.String("documents-uploaded"),
ResultKey = "uploadSignal",
},
new WorkflowCompleteStepDeclaration(),
],
},
},
};
var json = WorkflowCanonicalJsonSerializer.Serialize(definition);
var roundTripped = WorkflowCanonicalJsonSerializer.Deserialize(json);
roundTripped.Should().BeEquivalentTo(definition);
}
[Test]
public void Validate_WhenActivateTaskReferencesUnknownTask_ShouldReturnError()
{
var definition = new WorkflowCanonicalDefinition
{
WorkflowName = "InvalidWorkflow",
WorkflowVersion = "1.0.0",
DisplayName = "Invalid Workflow",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = new WorkflowObjectExpressionDefinition(),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowActivateTaskStepDeclaration
{
TaskName = "Unknown",
},
],
},
},
};
var result = WorkflowCanonicalDefinitionValidator.Validate(definition);
result.Succeeded.Should().BeFalse();
result.Errors.Should().Contain(x => x.Code == "WFVAL038");
}
[Test]
public void Validate_WhenRabbitAddressIsMissingExchange_ShouldReturnError()
{
var definition = new WorkflowCanonicalDefinition
{
WorkflowName = "InvalidRabbitWorkflow",
WorkflowVersion = "1.0.0",
DisplayName = "Invalid Rabbit Workflow",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = WorkflowExpr.Obj(),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowTransportCallStepDeclaration
{
StepName = "Publish",
Invocation = new WorkflowTransportInvocationDeclaration
{
Address = new WorkflowRabbitAddressDeclaration
{
Exchange = "",
RoutingKey = "policy.publish",
},
},
},
],
},
},
};
var result = WorkflowCanonicalDefinitionValidator.Validate(definition);
result.Succeeded.Should().BeFalse();
result.Errors.Should().Contain(x => x.Code == "WFVAL046" && x.Path.EndsWith(".exchange"));
}
[Test]
public void Validate_WhenFunctionExpressionReferencesUnknownFunction_ShouldReturnError()
{
var definition = new WorkflowCanonicalDefinition
{
WorkflowName = "UnknownFunctionWorkflow",
WorkflowVersion = "1.0.0",
DisplayName = "Unknown Function Workflow",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = WorkflowExpr.Func("doesNotExist", WorkflowExpr.Path("start.value")),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps = [new WorkflowCompleteStepDeclaration()],
},
},
};
var result = WorkflowCanonicalDefinitionValidator.Validate(definition, CoreFunctionCatalog);
result.Succeeded.Should().BeFalse();
result.Errors.Should().Contain(x => x.Code == "WFVAL064" && x.Path.EndsWith(".functionName"));
}
[Test]
public void Validate_WhenFunctionExpressionHasInvalidArgumentCount_ShouldReturnError()
{
var definition = new WorkflowCanonicalDefinition
{
WorkflowName = "InvalidArgsWorkflow",
WorkflowVersion = "1.0.0",
DisplayName = "Invalid Args Workflow",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = WorkflowExpr.Func("if", WorkflowExpr.Bool(true)),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps = [new WorkflowCompleteStepDeclaration()],
},
},
};
var result = WorkflowCanonicalDefinitionValidator.Validate(definition, CoreFunctionCatalog);
result.Succeeded.Should().BeFalse();
result.Errors.Should().Contain(x => x.Code == "WFVAL065" && x.Path.EndsWith(".arguments"));
}
[Test]
public void Compile_WhenWorkflowUsesCurrentDslDelegates_ShouldReturnCanonicalizationDiagnostics()
{
var result = WorkflowCanonicalDefinitionCompiler.Compile(new SampleDeclarativeWorkflow());
result.Succeeded.Should().BeFalse();
result.Definition.Should().BeNull();
var codes = result.Diagnostics.Select(x => x.Code).ToArray();
codes.Should().Contain("WFCD001");
codes.Should().Contain("WFCD002");
codes.Should().Contain("WFCD010");
}
private sealed record SampleStartRequest(long SrPolicyId);
private sealed class SampleDeclarativeWorkflow : IDeclarativeWorkflow<SampleStartRequest>
{
private static readonly WorkflowHumanTaskDefinition<SampleStartRequest> ReviewTask =
WorkflowHumanTask.For<SampleStartRequest>(
"Review",
"ReviewTask",
"business/policies")
.WithPayload(context => new
{
srPolicyId = context.StateValues["srPolicyId"].Get<long>(),
})
.OnComplete(flow => flow
.Set(
"answer",
context => context.PayloadValues["answer"].Get<string>())
.Complete());
public string WorkflowName => "SampleDeclarativeWorkflow";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Sample Declarative Workflow";
public IReadOnlyCollection<string> WorkflowRoles => [];
public WorkflowSpec<SampleStartRequest> Spec { get; } = WorkflowSpec.For<SampleStartRequest>()
.InitializeState(startRequest => new
{
srPolicyId = startRequest.SrPolicyId,
answer = "open",
})
.AddTask(ReviewTask)
.StartWith(ReviewTask)
.Build();
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
}
}

View File

@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowCanonicalEmbeddedAssetTests
{
[Test]
public void EmbeddedCanonicalTemplates_WhenScanningBulstradPluginAssembly_ShouldExposeExpectedResources()
{
var assembly = typeof(PolicyPresentOfferCanonicalTemplate).Assembly;
var resourceNames = assembly.GetManifestResourceNames()
.Where(x => x.Contains(".CanonicalTemplates.", global::System.StringComparison.Ordinal))
.OrderBy(x => x, global::System.StringComparer.Ordinal)
.ToArray();
resourceNames.Should().Contain(
[
"StellaOps.Workflow.Engine.Tests.CanonicalTemplates.ClaimDetermineRisk.task.template.json",
"StellaOps.Workflow.Engine.Tests.CanonicalTemplates.HomeWorkflow.current-policy.business-reference.json",
"StellaOps.Workflow.Engine.Tests.CanonicalTemplates.HomeWorkflow.register-home-quote.payload.json",
"StellaOps.Workflow.Engine.Tests.CanonicalTemplates.PolicyPresentOffer.template.json",
]);
}
[Test]
public void EmbeddedCanonicalTaskTemplate_WhenLoadedDirectly_ShouldDeserializeTaskFragment()
{
var task = WorkflowCanonicalTemplateLoader.LoadEmbeddedFragment<WorkflowTaskDeclaration>(
typeof(ClaimDetermineRiskCanonicalTemplate).Assembly,
"StellaOps.Workflow.Engine.Tests.CanonicalTemplates.ClaimDetermineRisk.task.template.json",
new global::System.Collections.Generic.Dictionary<string, string>());
task.TaskName.Should().Be("Determine cover/Risk/Insured Object/Damaged Objects");
task.OnComplete.Steps.Should().HaveCount(3);
task.OnComplete.Steps.OfType<WorkflowTransportCallStepDeclaration>()
.Single().StepName.Should().Be("Approve Notification");
}
[Test]
public void EmbeddedCanonicalDefinitionTemplate_WhenLoadedAsText_ShouldContainExpectedBindingTokens()
{
var templateText = WorkflowCanonicalTemplateLoader.LoadEmbeddedText(
typeof(PolicyPresentOfferCanonicalTemplate).Assembly,
"StellaOps.Workflow.Engine.Tests.CanonicalTemplates.PolicyPresentOffer.template.json");
templateText.Should().Contain("{{WORKFLOW_NAME}}");
templateText.Should().Contain("{{TASK_ROLES}}");
templateText.Should().Contain("{{PRESENT_OFFER_ON_COMPLETE_STEPS}}");
}
[Test]
public void EmbeddedCanonicalFragmentTemplate_WhenLoadedDirectly_ShouldDeserializeHomePayloadFragment()
{
var expression = WorkflowCanonicalTemplateLoader.LoadEmbeddedFragment<WorkflowExpressionDefinition>(
typeof(HomeWorkflowCanonicalTemplate).Assembly,
"StellaOps.Workflow.Engine.Tests.CanonicalTemplates.HomeWorkflow.register-home-quote.payload.json",
new global::System.Collections.Generic.Dictionary<string, string>());
expression.Should().BeOfType<WorkflowObjectExpressionDefinition>();
var objectExpression = (WorkflowObjectExpressionDefinition)expression;
objectExpression.Properties.Select(x => x.Name).Should().Contain(
[
"ProductCode",
"AddressId",
"PolicyValues",
]);
}
}

View File

@@ -0,0 +1,614 @@
using System.Collections.Generic;
using System.Text.Json;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowCanonicalExpressionRuntimeTests
{
[Test]
public void Evaluate_WhenExpressionUsesStartStateResultAndFunctions_ShouldResolveDeterministically()
{
var context = CreateContext();
context.SetResult("operations", new { passed = true }.AsJsonElement());
var expression = WorkflowExpr.Obj(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.srPolicyId")),
WorkflowExpr.Prop("startPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("addressId", WorkflowExpr.Func(
"coalesce",
WorkflowExpr.Path("state.address.addressId.value"),
WorkflowExpr.Path("state.address.addressId"))),
WorkflowExpr.Prop("nextStep", WorkflowExpr.Group(WorkflowExpr.Func(
"if",
WorkflowExpr.Path("result.operations.passed"),
WorkflowExpr.String("ConvertToPolicy"),
WorkflowExpr.String("ConvertToApplication")))));
var evaluated = WorkflowCanonicalExpressionRuntime.Evaluate(expression, context);
evaluated.Should().BeEquivalentTo(new Dictionary<string, object?>
{
["policyId"] = 17L,
["startPolicyId"] = 13L,
["addressId"] = 55L,
["nextStep"] = "ConvertToPolicy",
});
}
[Test]
public void EvaluateBusinessReference_WhenCanonicalDeclarationUsesStatePaths_ShouldNormalizeStructuredReference()
{
var context = CreateContext();
var businessReference = WorkflowCanonicalExpressionRuntime.EvaluateBusinessReference(
HomeWorkflowCanonicalTemplate.GetCurrentPolicyBusinessReferenceDeclaration(),
context);
businessReference.Key.Should().Be("17");
businessReference.Parts.Should().BeEquivalentTo(new Dictionary<string, object?>
{
["policyId"] = 17L,
["annexId"] = 19L,
["customerId"] = 23L,
});
}
[Test]
public void Evaluate_WhenExpressionUsesLengthAndSelectManyPath_ShouldReturnGenericCollectionCounts()
{
var context = CreateContext();
context.SetResult("printBatch", new
{
documentsStatus = new object?[]
{
new { srDocsId = 1001L, fileName = "policy.pdf" },
new { srDocsId = (long?)null, fileName = "annex.pdf" },
},
}.AsJsonElement());
var expression = WorkflowExpr.Obj(
WorkflowExpr.Prop(
"documentsCount",
WorkflowExpr.Func(
"length",
WorkflowExpr.Path("result.printBatch.documentsStatus"))),
WorkflowExpr.Prop(
"documentIdsCount",
WorkflowExpr.Func(
"length",
WorkflowExpr.Func(
"selectManyPath",
WorkflowExpr.Path("result.printBatch.documentsStatus"),
WorkflowExpr.String("srDocsId")))));
var evaluated = WorkflowCanonicalExpressionRuntime.Evaluate(expression, context);
evaluated.Should().BeEquivalentTo(new Dictionary<string, object?>
{
["documentsCount"] = 2L,
["documentIdsCount"] = 1L,
});
}
[Test]
public void Evaluate_WhenExpressionUsesConcat_ShouldJoinLiteralAndRuntimeValues()
{
var context = CreateContext();
var evaluated = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func(
"concat",
WorkflowExpr.String("Treaty #"),
WorkflowExpr.Path("start.srPolicyId"),
WorkflowExpr.String("/"),
WorkflowExpr.Path("state.srPolicyId")),
context);
evaluated.Should().Be("Treaty #13/17");
}
[Test]
public void Evaluate_WhenExpressionUsesAdd_ShouldReturnNumericSum()
{
var context = CreateContext();
var evaluated = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func(
"add",
WorkflowExpr.Func(
"coalesce",
WorkflowExpr.Path("state.agentPollAttempt"),
WorkflowExpr.Number(0)),
WorkflowExpr.Number(1)),
context);
evaluated.Should().Be(1L);
}
[Test]
public void Evaluate_WhenExpressionUsesFirstAndMergeObjects_ShouldBuildMergedObjectPayload()
{
var context = CreateContext();
var evaluated = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func(
"mergeObjects",
WorkflowExpr.Func("first", WorkflowExpr.Path("state.objectData")),
WorkflowExpr.Obj(
WorkflowExpr.Prop("polObjectId", WorkflowExpr.Path("state.polObjectId")),
WorkflowExpr.Prop("objectCode", WorkflowExpr.String("VESSEL")))),
context);
evaluated.Should().BeEquivalentTo(new Dictionary<string, object?>
{
["existingField"] = "value",
["polObjectId"] = 31L,
["objectCode"] = "VESSEL",
});
}
[Test]
public void Evaluate_WhenExpressionUsesPluginFunctionAndRuntimeProvider_ShouldResolvePluginExtension()
{
var context = CreateContext(new ProviderBackedFunctionRuntime(new WorkflowCoreFunctionProvider() /* TODO: Add Stella-specific function provider when available */));
var evaluated = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func("bulstrad.tryReadCustomerId", WorkflowExpr.Path("state.customer")),
context);
evaluated.Should().Be(23L);
}
[Test]
public void Evaluate_WhenExpressionUsesAssistantCustomerFunctions_ShouldResolveDeclarativePayloads()
{
var context = CreateContext(new ProviderBackedFunctionRuntime(new WorkflowCoreFunctionProvider() /* TODO: Add Stella-specific function provider when available */));
context.WorkflowState["customer"] = new
{
email = "person@example.com",
custPid = "8801010000",
}.AsJsonElement();
context.WorkflowState["srCustId"] = JsonDocument.Parse("null").RootElement.Clone();
WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func("bulstrad.canLookupCustomer", WorkflowExpr.Path("state.customer")),
context)
.Should()
.Be(true);
WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func("bulstrad.isPersonCustomer", WorkflowExpr.Path("state.customer")),
context)
.Should()
.Be(true);
var lookupPayload = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func("bulstrad.buildCustomerLookupPayload", WorkflowExpr.Path("state.customer")),
context);
var lookupPayloadElement = lookupPayload.AsJsonElement();
var lookupFilter = lookupPayloadElement.GetRequiredProperty<JsonElement>("filters")[0];
lookupFilter.GetRequiredProperty<string>("prop").Should().Be("SrCust.CContacts.Details");
lookupFilter.GetRequiredProperty<string>("comparison").Should().Be("contains");
lookupFilter.GetRequiredProperty<string>("value").Should().Be("person@example.com");
var registerPayload = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func("bulstrad.buildRegisterCustomerPayload", WorkflowExpr.Path("state.customer")),
context);
var registerPayloadElement = registerPayload.AsJsonElement();
registerPayloadElement.GetRequiredProperty<string>("email").Should().Be("person@example.com");
registerPayloadElement.GetRequiredProperty<string>("custPid").Should().Be("8801010000");
var contact = registerPayloadElement.GetRequiredProperty<JsonElement>("cContacts")[0];
contact.GetRequiredProperty<string>("channelType").Should().Be("EMAIL");
contact.GetRequiredProperty<string>("details").Should().Be("person@example.com");
contact.GetRequiredProperty<string>("preferable").Should().Be("Y");
}
[Test]
public void Evaluate_WhenExpressionAssignsFirstPolicyObjectIdToInsuredItems_ShouldDecorateObjectValues()
{
var context = CreateContext(new ProviderBackedFunctionRuntime(new WorkflowCoreFunctionProvider() /* TODO: Add Stella-specific function provider when available */));
context.WorkflowState["insuredItems"] = new object?[]
{
new
{
objectValues = new object?[]
{
new { prmCode = "SUMINS" },
},
},
}.AsJsonElement();
context.SetResult("assistantObjects", new
{
items = new object?[]
{
new { id = 44001L },
},
}.AsJsonElement());
var evaluated = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func(
"bulstrad.assignFirstPolicyObjectIdToInsuredItems",
WorkflowExpr.Path("state.insuredItems"),
WorkflowExpr.Path("result.assistantObjects")),
context);
var insuredItems = evaluated.AsJsonElement();
insuredItems[0]
.GetRequiredProperty<JsonElement>("objectValues")[0]
.GetRequiredProperty<long>("polObjectId")
.Should()
.Be(44001L);
}
[Test]
public void Evaluate_WhenExpressionUsesConsistencyFunctions_ShouldMergeAndFinalizeResults()
{
var context = CreateContext(new ProviderBackedFunctionRuntime(new WorkflowCoreFunctionProvider() /* TODO: Add Stella-specific function provider when available */));
context.WorkflowState["userData"] = new
{
importedUsers = new object?[]
{
new { email = "alpha@example.com" },
new { email = "beta@example.com" },
},
}.AsJsonElement();
context.SetResult("profile", new
{
consistencyCheckUserResults = new object?[]
{
new { email = "alpha@example.com", accountExists = false, dataIssues = "" },
new { email = "beta@example.com", accountExists = true, dataIssues = "" },
},
}.AsJsonElement());
context.SetResult("account", new
{
consistencyCheckUserResults = new object?[]
{
new { email = "alpha@example.com", accountExists = true, dataIssues = "" },
new { email = "beta@example.com", accountExists = false, dataIssues = "" },
},
}.AsJsonElement());
var initialResults = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func("bulstrad.buildInitialConsistencyResults", WorkflowExpr.Path("state.userData")),
context);
context.WorkflowState["userResults"] = initialResults.AsJsonElement();
var mergedProfile = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func(
"bulstrad.mergeConsistencyResults",
WorkflowExpr.Path("state.userResults"),
WorkflowExpr.Path("result.profile"),
WorkflowExpr.String("ProfileData")),
context);
context.WorkflowState["userResults"] = mergedProfile.AsJsonElement();
var mergedAccount = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func(
"bulstrad.mergeConsistencyResults",
WorkflowExpr.Path("state.userResults"),
WorkflowExpr.Path("result.account"),
WorkflowExpr.String("AccountData")),
context);
context.WorkflowState["userResults"] = mergedAccount.AsJsonElement();
var finalized = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func("bulstrad.markConsistencyResultsPassed", WorkflowExpr.Path("state.userResults")),
context);
var results = finalized.AsJsonElement().GetRequiredProperty<JsonElement>("consistencyCheckUserResults");
results.GetArrayLength().Should().Be(2);
results[0].GetRequiredProperty<bool>("accountExists").Should().BeTrue();
results[1].GetRequiredProperty<bool>("accountExists").Should().BeTrue();
results[0].GetRequiredProperty<bool>("passed").Should().BeTrue();
results[1].GetRequiredProperty<bool>("passed").Should().BeTrue();
}
[Test]
public void Evaluate_WhenExpressionUsesClaimAgentDocumentsPlugin_ShouldBuildMcpFileDescriptors()
{
var context = CreateContext(new ProviderBackedFunctionRuntime(new WorkflowCoreFunctionProvider() /* TODO: Add Stella-specific function provider when available */));
context.SetResult("claimDocuments", new
{
items = new object?[]
{
new
{
srDocId = 70101L,
docType = "application/pdf",
clmDocCode = "INVOICE",
filename = "invoice.pdf",
},
},
}.AsJsonElement());
var evaluated = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func(
"bulstrad.buildClaimAgentDocuments",
WorkflowExpr.Path("result.claimDocuments")),
context);
var documents = evaluated.AsJsonElement();
documents.ValueKind.Should().Be(JsonValueKind.Array);
documents.GetArrayLength().Should().Be(1);
documents[0].GetRequiredProperty<string>("file_id").Should().Be("70101");
documents[0].GetRequiredProperty<string>("provider").Should().Be("serdica");
documents[0].GetRequiredProperty<string>("mime_type").Should().Be("application/pdf");
documents[0].GetRequiredProperty<JsonElement>("metadata").GetRequiredProperty<string>("docCode").Should().Be("INVOICE");
}
[Test]
public void Evaluate_WhenExpressionUsesToolDocumentsPlugin_ShouldBuildCombinedMcpFileDescriptors()
{
var context = CreateContext(new ProviderBackedFunctionRuntime(new WorkflowCoreFunctionProvider() /* TODO: Add Stella-specific function provider when available */));
context.SetResult("claimDocuments", new
{
items = new object?[]
{
new
{
srDocId = 73001L,
docType = "image/jpeg",
clmDocCode = "PHOTO",
filename = "claim-photo.jpg",
},
},
}.AsJsonElement());
context.SetResult("policyDocuments", new
{
items = new object?[]
{
new
{
srDocId = 73002L,
docType = "image/png",
filename = "policy-photo.png",
},
},
}.AsJsonElement());
var evaluated = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func(
"bulstrad.buildToolDocuments",
WorkflowExpr.Path("result.claimDocuments"),
WorkflowExpr.Path("result.policyDocuments")),
context);
var documents = evaluated.AsJsonElement();
documents.ValueKind.Should().Be(JsonValueKind.Array);
documents.GetArrayLength().Should().Be(2);
documents[0].GetRequiredProperty<string>("file_id").Should().Be("73001");
documents[1].GetRequiredProperty<string>("file_id").Should().Be("73002");
}
[Test]
public void Evaluate_WhenExpressionUsesVehicleDamageCostPlugin_ShouldBuildToolPayload()
{
var context = CreateContext(new ProviderBackedFunctionRuntime(new WorkflowCoreFunctionProvider() /* TODO: Add Stella-specific function provider when available */));
context.SetResult("damageDetection", new
{
status = "success",
data = new
{
data = new
{
detection_status = "damages_detected",
damages = new object?[]
{
new
{
part = "front_bumper",
details = new object?[]
{
new
{
type = "scratch",
severity = "moderate",
},
},
},
},
},
},
}.AsJsonElement());
context.SetResult("policyVehicle", new
{
items = new object?[]
{
new
{
id = 83001L,
srAnnexId = 93001L,
carData = new
{
regno = "CA1234AB",
vin = "VIN-123",
make = "Toyota",
model = "Corolla",
},
},
},
}.AsJsonElement());
context.SetResult("vehicleEvaluations", new
{
items = new object?[]
{
new
{
id = 84001L,
evlStatus = "NEW",
rqAmnt = 300.00m,
currency = "BGN",
},
},
}.AsJsonElement());
var evaluated = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func(
"bulstrad.buildVehicleDamageCostToolPayload",
WorkflowExpr.Path("result.damageDetection"),
WorkflowExpr.Path("result.policyVehicle"),
WorkflowExpr.Path("result.vehicleEvaluations")),
context);
var payload = evaluated.AsJsonElement();
payload.GetRequiredProperty<string>("toolName").Should().Be("motor_calculate_damage_cost");
var parameters = payload.GetRequiredProperty<JsonElement>("parameters");
parameters.GetRequiredProperty<string>("car_make").Should().Be("Toyota");
parameters.GetRequiredProperty<string>("car_model").Should().Be("Corolla");
parameters.GetRequiredProperty<string>("currency").Should().Be("BGN");
parameters.GetRequiredProperty<string>("damages_data").Should().Contain("front_bumper");
}
[Test]
public void Evaluate_WhenExpressionUsesVehicleDamageEvaluationPlugin_ShouldBuildEvaluationPayload()
{
var context = CreateContext(new ProviderBackedFunctionRuntime(new WorkflowCoreFunctionProvider() /* TODO: Add Stella-specific function provider when available */));
context.SetResult("damageDetection", new
{
status = "success",
data = new
{
data = new
{
detection_status = "damages_detected",
damages = new object?[]
{
new
{
part = "front_bumper",
details = new object?[]
{
new
{
type = "scratch",
severity = "moderate",
},
},
},
},
},
},
}.AsJsonElement());
context.SetResult("damageCost", new
{
status = "success",
data = new
{
data = new
{
total_cost = new
{
formatted = "BGN 250.00",
currency = "BGN",
amount = 250.00m,
},
breakdown = new object?[]
{
new
{
part = "front_bumper",
type = "scratch",
severity = "moderate",
cost = new Dictionary<string, decimal>
{
["BGN"] = 250.00m,
},
},
},
},
},
}.AsJsonElement());
context.SetResult("vehicleEvaluations", new
{
items = new object?[]
{
new
{
id = 84001L,
evlStatus = "NEW",
rqAmnt = 300.00m,
currency = "BGN",
},
},
}.AsJsonElement());
var evaluated = WorkflowCanonicalExpressionRuntime.Evaluate(
WorkflowExpr.Func(
"bulstrad.buildVehicleDamageEvaluationPayload",
WorkflowExpr.Path("result.damageDetection"),
WorkflowExpr.Path("result.damageCost"),
WorkflowExpr.Path("result.vehicleEvaluations")),
context);
var payload = evaluated.AsJsonElement();
payload.GetRequiredProperty<JsonElement>("damageDetection").GetArrayLength().Should().Be(1);
payload.GetRequiredProperty<JsonElement>("damageCost").GetRequiredProperty<decimal>("amount").Should().Be(250.00m);
payload.GetRequiredProperty<JsonElement>("damageCostBreakdown")[0].GetRequiredProperty<decimal>("cost").Should().Be(250.00m);
payload.GetRequiredProperty<decimal>("evaluationAmount").Should().Be(300.00m);
}
private static WorkflowSpecExecutionContext<SampleStartRequest> CreateContext(
IWorkflowFunctionRuntime? functionRuntime = null)
{
return new WorkflowSpecExecutionContext<SampleStartRequest>(
"TestWorkflow",
new SampleStartRequest
{
SrPolicyId = 13,
},
new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase)
{
["srPolicyId"] = 17L.AsJsonElement(),
["srAnnexId"] = 19L.AsJsonElement(),
["srCustId"] = 23L.AsJsonElement(),
["polObjectId"] = 31L.AsJsonElement(),
["address"] = new
{
addressId = new
{
value = 55L,
},
}.AsJsonElement(),
["objectData"] = new object?[]
{
new
{
existingField = "value",
},
}.AsJsonElement(),
["customer"] = new
{
srCustId = 23L,
}.AsJsonElement(),
["agentPollAttempt"] = JsonDocument.Parse("null").RootElement.Clone(),
},
new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase),
functionRuntime: functionRuntime);
}
private sealed class SampleStartRequest
{
public long SrPolicyId { get; init; }
}
private sealed class ProviderBackedFunctionRuntime(IWorkflowFunctionRuntimeProvider provider) : IWorkflowFunctionRuntime
{
public bool TryEvaluate(
string functionName,
IReadOnlyCollection<object?> arguments,
WorkflowCanonicalEvaluationContext context,
out object? result)
{
return provider.TryEvaluate(functionName, arguments, context, out result);
}
}
}

View File

@@ -0,0 +1,185 @@
using System.Collections.Generic;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowCanonicalImportValidationTests
{
[Test]
public void TryParse_WhenModuleVersionExpressionUsesRanges_ShouldParseAndEvaluate()
{
var parsed = WorkflowModuleVersionExpression.TryParse(
"v>=1.0.0, <2.0.0",
out var requirement,
out var error);
parsed.Should().BeTrue(error);
requirement.IsSatisfiedBy("1.5.0").Should().BeTrue();
requirement.IsSatisfiedBy("2.0.0").Should().BeFalse();
}
[Test]
public void Validate_WhenCanonicalJsonAndInstalledModulesAreValid_ShouldSucceed()
{
var definition = CreateValidDefinition() with
{
RequiredModules =
[
new WorkflowRequiredModuleDeclaration
{
ModuleName = "transport.legacy-rabbit",
VersionExpression = ">=1.0.0",
},
],
};
var result = WorkflowCanonicalImportValidator.Validate(
WorkflowCanonicalJsonSerializer.Serialize(definition),
[
new WorkflowInstalledModule("transport.legacy-rabbit", "1.2.0"),
]);
result.SchemaErrors.Should().BeEmpty();
result.SemanticErrors.Should().BeEmpty();
result.ModuleErrors.Should().BeEmpty();
result.Succeeded.Should().BeTrue();
result.Definition.Should().BeEquivalentTo(definition);
}
[Test]
public void Validate_WhenRequiredModuleIsMissing_ShouldReturnModuleError()
{
var definition = CreateValidDefinition() with
{
RequiredModules =
[
new WorkflowRequiredModuleDeclaration
{
ModuleName = "transport.http",
VersionExpression = ">=1.0.0",
},
],
};
var result = WorkflowCanonicalImportValidator.Validate(
WorkflowCanonicalJsonSerializer.Serialize(definition),
[]);
result.SchemaErrors.Should().BeEmpty();
result.SemanticErrors.Should().BeEmpty();
result.Succeeded.Should().BeFalse();
result.ModuleErrors.Should().Contain(error => error.Code == "WFIMP010");
}
[Test]
public void Validate_WhenJsonIsMalformed_ShouldReturnSchemaError()
{
var result = WorkflowCanonicalImportValidator.Validate("{ not-json");
result.Succeeded.Should().BeFalse();
result.SchemaErrors.Should().Contain(error => error.Code == "WFSCHEMA000" || error.Code == "WFSCHEMA002");
}
[Test]
public void GetSchemaJson_WhenRequested_ShouldExposeWorkflowDefinitionShape()
{
var schemaJson = WorkflowCanonicalJsonSchema.GetSchemaJson();
schemaJson.Should().Contain("workflowName");
schemaJson.Should().Contain("workflowVersion");
schemaJson.Should().Contain("requiredModules");
schemaJson.Should().Contain("external-signal");
}
[Test]
public void Validate_WhenCompiledExternalSignalWorkflow_ShouldSucceed()
{
var compilation = WorkflowCanonicalDefinitionCompiler.Compile(new ExternalSignalImportWorkflow());
compilation.Succeeded.Should().BeTrue();
compilation.Diagnostics.Should().BeEmpty();
compilation.Definition.Should().NotBeNull();
compilation.Definition!.Start.InitialSequence.Steps.First()
.Should().BeOfType<WorkflowExternalSignalStepDeclaration>();
var result = WorkflowCanonicalImportValidator.Validate(
WorkflowCanonicalJsonSerializer.Serialize(compilation.Definition),
[
new WorkflowInstalledModule("workflow.dsl.core", "1.0.0"),
]);
result.SchemaErrors.Should().BeEmpty();
result.SemanticErrors.Should().BeEmpty();
result.ModuleErrors.Should().BeEmpty();
result.Succeeded.Should().BeTrue();
}
private static WorkflowCanonicalDefinition CreateValidDefinition()
{
return new WorkflowCanonicalDefinition
{
WorkflowName = "ImportValidationWorkflow",
WorkflowVersion = "1.0.0",
DisplayName = "Import Validation Workflow",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId"))),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowActivateTaskStepDeclaration
{
TaskName = "Review",
},
],
},
},
Tasks =
[
new WorkflowTaskDeclaration
{
TaskName = "Review",
TaskType = "Review",
RouteExpression = WorkflowExpr.String("business/workflows"),
PayloadExpression = WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId"))),
OnComplete = new WorkflowStepSequenceDeclaration
{
Steps = [new WorkflowCompleteStepDeclaration()],
},
},
],
};
}
private sealed record ExternalSignalImportStartRequest
{
public long SrPolicyId { get; init; }
}
private sealed class ExternalSignalImportWorkflow : IDeclarativeWorkflow<ExternalSignalImportStartRequest>
{
public string WorkflowName => "ExternalSignalImportWorkflow";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "External Signal Import Workflow";
public IReadOnlyCollection<string> WorkflowRoles => [];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<ExternalSignalImportStartRequest> Spec { get; } = WorkflowSpec.For<ExternalSignalImportStartRequest>()
.InitializeState(WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("phase", WorkflowExpr.String("waiting"))))
.StartWith(flow => flow
.WaitForSignal("Wait For Upload", WorkflowExpr.String("documents-uploaded"), "uploadSignal")
.Complete())
.Build();
}
}

View File

@@ -0,0 +1,101 @@
using System;
using System.Linq;
using System.Reflection;
using StellaOps.Workflow.Abstractions;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowCanonicalizationInventoryTests
{
[Test]
public void Compile_WhenScanningBulstradWorkflowCorpus_ShouldProduceNoCanonicalizationDiagnostics()
{
var assembly = typeof(ApproveApplicationWorkflow).Assembly;
var workflowTypes = assembly
.GetTypes()
.Where(type => type is { IsAbstract: false, IsClass: true })
.Where(type => TryGetDeclarativeWorkflowInterface(type) is not null)
.OrderBy(type => type.FullName, StringComparer.Ordinal)
.ToArray();
workflowTypes.Should().HaveCountGreaterThan(100);
var results = workflowTypes
.Select(CompileWorkflow)
.ToArray();
results.Should().OnlyContain(result => result.WorkflowName.Length > 0);
results.SelectMany(result => result.Diagnostics)
.Should().BeEmpty();
var diagnosticSummary = results
.SelectMany(result => result.Diagnostics)
.GroupBy(diagnostic => diagnostic.Code, StringComparer.Ordinal)
.OrderBy(group => group.Key, StringComparer.Ordinal)
.Select(group => $"{group.Key}: {group.Count()}")
.ToArray();
TestContext.Out.WriteLine($"Discovered workflows: {workflowTypes.Length}");
if (diagnosticSummary.Length == 0)
{
TestContext.Out.WriteLine("Canonicalization diagnostic summary: none");
}
else
{
TestContext.Out.WriteLine("Canonicalization diagnostic summary:");
foreach (var line in diagnosticSummary)
{
TestContext.Out.WriteLine($" {line}");
}
}
}
private static WorkflowCanonicalCompilationResult CompileWorkflow(Type workflowType)
{
var functionCatalog = BuildFunctionCatalog(workflowType.Assembly);
var workflowInterface = TryGetDeclarativeWorkflowInterface(workflowType)
?? throw new InvalidOperationException($"Type '{workflowType.FullName}' is not a declarative workflow.");
var startRequestType = workflowInterface.GetGenericArguments()[0];
var workflowInstance = Activator.CreateInstance(workflowType)
?? throw new InvalidOperationException($"Workflow '{workflowType.FullName}' could not be created.");
var compileMethod = typeof(WorkflowCanonicalDefinitionCompiler)
.GetMethod(nameof(WorkflowCanonicalDefinitionCompiler.Compile), BindingFlags.Public | BindingFlags.Static)
?? throw new InvalidOperationException("Workflow canonical compiler method was not found.");
var closedMethod = compileMethod.MakeGenericMethod(startRequestType);
return (WorkflowCanonicalCompilationResult)(closedMethod.Invoke(null, [workflowInstance, functionCatalog])
?? throw new InvalidOperationException($"Workflow '{workflowType.FullName}' did not return a compilation result."));
}
private static IWorkflowFunctionCatalog BuildFunctionCatalog(Assembly assembly)
{
var providers = new List<IWorkflowFunctionProvider>
{
new WorkflowCoreFunctionProvider(),
};
providers.AddRange(
assembly
.GetTypes()
.Where(type => type is { IsAbstract: false, IsClass: true })
.Where(type => typeof(IWorkflowFunctionProvider).IsAssignableFrom(type))
.Where(type => type.GetConstructor(Type.EmptyTypes) is not null)
.Select(type => (IWorkflowFunctionProvider)Activator.CreateInstance(type)!));
return new WorkflowFunctionCatalog(providers);
}
private static Type? TryGetDeclarativeWorkflowInterface(Type workflowType)
{
return workflowType
.GetInterfaces()
.FirstOrDefault(interfaceType =>
interfaceType.IsGenericType
&& interfaceType.GetGenericTypeDefinition() == typeof(IDeclarativeWorkflow<>));
}
}

View File

@@ -0,0 +1,539 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowDeclarativeBuilderTests
{
[Test]
public void WorkflowHumanTaskFor_WhenRolesProvided_ShouldPopulateTaskDescriptorRoles()
{
var task = WorkflowHumanTask.For<FakeStartRequest>(
"Approve Application",
"ApproveQTApproveApplication",
"business/policies",
["UR_UNDERWRITER", "APR_APPL"])
.WithPayload(_ => new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase))
.Build();
task.Descriptor.TaskRoles.Should().Equal("UR_UNDERWRITER", "APR_APPL");
}
[Test]
public void WorkflowSpec_WhenStartedWithServiceSequence_ShouldBuildWithoutInitialTaskAndWithoutTaskDescriptors()
{
var spec = WorkflowSpec.For<FakeStartRequest>()
.InitializeState(_ => new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase))
.StartWith(flow => flow
.Set("started", true)
.Complete())
.Build();
spec.InitialTaskName.Should().BeNull();
spec.InitialSequence.Steps.Should().HaveCount(2);
spec.TaskDescriptors.Should().BeEmpty();
}
[Test]
public void WorkflowSpec_WhenInitializeStateUsesAnonymousObject_ShouldConvertToJsonDictionary()
{
var spec = WorkflowSpec.For<FakeStartRequest>()
.InitializeState(startRequest => new
{
startRequest.SrPolicyId,
isOpen = true,
})
.StartWith(flow => flow.Complete())
.Build();
var state = spec.InitializeState(new FakeStartRequest { SrPolicyId = 7100345L });
state["SrPolicyId"].Get<long>().Should().Be(7100345L);
state["isOpen"].Get<bool>().Should().BeTrue();
}
[Test]
public void Call_WhenUsingAddressAndPayloadFactory_ShouldKeepPayloadOutsideAddress()
{
var task = CreateTask(flow => flow.Call<OperationsResponse>(
"Perform Operations",
new Address("pas_operations", "pas_operations_perform"),
context => new
{
srPolicyId = context.StateValues["srPolicyId"].Get<long>(),
operationType = "POLICY_ISSUING",
},
"operations"));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowMicroserviceCallStepDefinition<FakeStartRequest>>().Subject;
step.MicroserviceName.Should().Be("pas_operations");
step.Command.Should().Be("pas_operations_perform");
step.ResultKey.Should().Be("operations");
JsonSerializer.Serialize(step.PayloadFactory(CreateContext()))
.Should().Contain("\"srPolicyId\":7100345")
.And.Contain("\"operationType\":\"POLICY_ISSUING\"");
}
[Test]
public void Call_WhenUsingLegacyRabbitAddress_ShouldBuildLegacyRabbitStepDefinition()
{
var task = CreateTask(flow => flow.Call<OperationsResponse>(
"Perform Operations",
new LegacyRabbitAddress("pas_operations_perform", WorkflowLegacyRabbitMode.MicroserviceConsumer),
context => new
{
srPolicyId = context.StateValues["srPolicyId"].Get<long>(),
operationType = "POLICY_ISSUING",
},
"operations"));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowLegacyRabbitCallStepDefinition<FakeStartRequest>>().Subject;
step.Command.Should().Be("pas_operations_perform");
step.Mode.Should().Be(WorkflowLegacyRabbitMode.MicroserviceConsumer);
step.ResultKey.Should().Be("operations");
JsonSerializer.Serialize(step.PayloadFactory(CreateContext()))
.Should().Contain("\"srPolicyId\":7100345")
.And.Contain("\"operationType\":\"POLICY_ISSUING\"");
}
[Test]
public void Call_WhenFailureBranchConfigured_ShouldBuildMicroserviceStepWithFailureHandlers()
{
var task = CreateTask(flow => flow.Call(
"Release Locked Document Numbers",
new Address("pas", "bst_blanknumbersrelease"),
context => new
{
srPolicyId = context.StateValues["srPolicyId"].Get<long>(),
},
whenFailure => whenFailure.Set("releaseDocNumbersFailed", true)));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowMicroserviceCallStepDefinition<FakeStartRequest>>().Subject;
step.MicroserviceName.Should().Be("pas");
step.Command.Should().Be("bst_blanknumbersrelease");
step.ResultKey.Should().BeNull();
step.FailureHandlers.Should().NotBeNull();
step.FailureHandlers!.WhenFailure.Steps.Should().ContainSingle()
.Which.Should().BeOfType<WorkflowStateAssignmentStepDefinition<FakeStartRequest>>();
step.FailureHandlers.WhenTimeout.Steps.Should().BeEmpty();
}
[Test]
public void Call_WhenUsingLegacyRabbitAddressWithFailureBranch_ShouldBuildLegacyRabbitStepWithFailureHandlers()
{
var task = CreateTask(flow => flow.Call(
"Release Locked Document Numbers",
new LegacyRabbitAddress("bst_blanknumbersrelease"),
context => new
{
srPolicyId = context.StateValues["srPolicyId"].Get<long>(),
},
whenFailure => whenFailure.Set("releaseDocNumbersFailed", true)));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowLegacyRabbitCallStepDefinition<FakeStartRequest>>().Subject;
step.Command.Should().Be("bst_blanknumbersrelease");
step.Mode.Should().Be(WorkflowLegacyRabbitMode.Envelope);
step.ResultKey.Should().BeNull();
step.FailureHandlers.Should().NotBeNull();
step.FailureHandlers!.WhenFailure.Steps.Should().ContainSingle()
.Which.Should().BeOfType<WorkflowStateAssignmentStepDefinition<FakeStartRequest>>();
step.FailureHandlers.WhenTimeout.Steps.Should().BeEmpty();
}
[Test]
public void Call_WhenUsingHttpAddress_ShouldBuildHttpStepDefinition()
{
var task = CreateTask(flow => flow.Call<object, OperationsResponse>(
"Check Identity Data",
new HttpAddress("authority", "/api/account/consistencyCheckUsers"),
context => new
{
srPolicyId = context.StateValues["srPolicyId"].Get<long>(),
},
"identity"));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowHttpCallStepDefinition<FakeStartRequest>>().Subject;
step.Target.Should().Be("authority");
step.Method.Should().Be("POST");
step.Path.Should().Be("/api/account/consistencyCheckUsers");
step.ResultKey.Should().Be("identity");
JsonSerializer.Serialize(step.PayloadFactory(CreateContext()))
.Should().Contain("\"srPolicyId\":7100345");
}
[Test]
public void Call_WhenUsingHttpAddressWithFailureBranch_ShouldBuildHttpStepWithFailureHandlers()
{
var task = CreateTask(flow => flow.Call<object, OperationsResponse>(
"Check Identity Data",
new HttpAddress("authority", "/api/account/consistencyCheckUsers"),
context => new
{
srPolicyId = context.StateValues["srPolicyId"].Get<long>(),
},
whenFailure => whenFailure.Set("checkIdentityFailed", true),
whenTimeout => whenTimeout.Set("checkIdentityTimedOut", true),
resultKey: "identity"));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowHttpCallStepDefinition<FakeStartRequest>>().Subject;
step.Target.Should().Be("authority");
step.Path.Should().Be("/api/account/consistencyCheckUsers");
step.ResultKey.Should().Be("identity");
step.FailureHandlers.Should().NotBeNull();
step.FailureHandlers!.WhenFailure.Steps.Should().ContainSingle();
step.FailureHandlers.WhenTimeout.Steps.Should().ContainSingle();
}
[Test]
public void Call_WhenTimeoutBranchConfigured_ShouldBuildFailureHandlersWithTimeoutBranch()
{
var task = CreateTask(flow => flow.Call(
"Transfer Policy To INSIS",
new Address("pas", "bst_integration_processsendpolicyrequest"),
context => new
{
SrPolicyId = context.StateValues["srPolicyId"].Get<long>(),
OptionDoSynchronousCall = true,
},
whenFailure => whenFailure.Set("revertToApplication", true),
whenTimeout => whenTimeout.ContinueWith(
"Start Approve Application",
new WorkflowReference("ApproveApplication"),
context => new
{
srPolicyId = context.StateValues["srPolicyId"].Get<long>(),
})));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowMicroserviceCallStepDefinition<FakeStartRequest>>().Subject;
step.FailureHandlers.Should().NotBeNull();
step.FailureHandlers!.WhenFailure.Steps.Should().ContainSingle();
step.FailureHandlers.WhenTimeout.Steps.Should().ContainSingle()
.Which.Should().BeOfType<WorkflowContinueWithStepDefinition<FakeStartRequest>>();
}
[Test]
public void Call_WhenAutoCompleteFailureAndTimeoutConfigured_ShouldBuildCompleteBranches()
{
var task = CreateTask(flow => flow.Call(
"Invoke Policy Transfer",
new Address("pas", "bst_integration_processsendpolicyrequest"),
context => new
{
SrPolicyId = context.StateValues["srPolicyId"].Get<long>(),
},
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowMicroserviceCallStepDefinition<FakeStartRequest>>().Subject;
step.FailureHandlers.Should().NotBeNull();
step.FailureHandlers!.WhenFailure.Steps.Should().ContainSingle()
.Which.Should().BeOfType<WorkflowCompleteStepDefinition<FakeStartRequest>>();
step.FailureHandlers.WhenTimeout.Steps.Should().ContainSingle()
.Which.Should().BeOfType<WorkflowCompleteStepDefinition<FakeStartRequest>>();
}
[Test]
public void QueryGraphql_WhenUsingAddressAndVariablesFactory_ShouldConvertVariablesToDictionary()
{
var task = CreateTask(flow => flow.QueryGraphql<object, GraphqlResponse>(
"Load Product",
new GraphqlAddress("pnc", "query Product($srPolicyId: Long!) { product(srPolicyId: $srPolicyId) { code } }", "Product"),
context => new
{
srPolicyId = context.StateValues["srPolicyId"].Get<long>(),
},
"product"));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowGraphqlCallStepDefinition<FakeStartRequest>>().Subject;
step.Target.Should().Be("pnc");
step.OperationName.Should().Be("Product");
step.ResultKey.Should().Be("product");
WorkflowBusinessReferenceExtensions.ConvertBusinessReferenceValueToString(
step.VariablesFactory(CreateContext())["srPolicyId"])
.Should().Be("7100345");
}
[Test]
public void QueryGraphql_WhenAutoCompleteFailureAndTimeoutConfigured_ShouldBuildCompleteBranches()
{
var task = CreateTask(flow => flow.QueryGraphql<object, GraphqlResponse>(
"Load Product",
new GraphqlAddress("pnc", "query Product($srPolicyId: Long!) { product(srPolicyId: $srPolicyId) { code } }", "Product"),
context => new
{
srPolicyId = context.StateValues["srPolicyId"].Get<long>(),
},
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete,
"product"));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowGraphqlCallStepDefinition<FakeStartRequest>>().Subject;
step.FailureHandlers.Should().NotBeNull();
step.FailureHandlers!.WhenFailure.Steps.Should().ContainSingle()
.Which.Should().BeOfType<WorkflowCompleteStepDefinition<FakeStartRequest>>();
step.FailureHandlers.WhenTimeout.Steps.Should().ContainSingle()
.Which.Should().BeOfType<WorkflowCompleteStepDefinition<FakeStartRequest>>();
}
[Test]
public void Run_WhenAutoCompleteFailureAndTimeoutConfigured_ShouldBuildCompleteBranches()
{
var task = CreateTask(flow => flow.Run(
"Inline Step",
(_, _, _) => Task.CompletedTask,
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowInlineStepDefinition<FakeStartRequest>>().Subject;
step.FailureHandlers.Should().NotBeNull();
step.FailureHandlers!.WhenFailure.Steps.Should().ContainSingle()
.Which.Should().BeOfType<WorkflowCompleteStepDefinition<FakeStartRequest>>();
step.FailureHandlers.WhenTimeout.Steps.Should().ContainSingle()
.Which.Should().BeOfType<WorkflowCompleteStepDefinition<FakeStartRequest>>();
}
[Test]
public void ContinueWith_WhenUsingWorkflowReference_ShouldBuildStartRequestFromReferenceAndPayload()
{
var task = CreateTask(flow => flow.ContinueWith(
"Continue To Policy Issue",
new WorkflowReference("IssuePolicy", "2.1.0"),
context => new
{
srPolicyId = context.StateValues["srPolicyId"].Get<long>(),
reason = "Approved",
},
context => new WorkflowBusinessReference
{
Parts = new Dictionary<string, object?>
{
["policyId"] = context.StateValues["srPolicyId"].Get<long>(),
},
}));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowContinueWithStepDefinition<FakeStartRequest>>().Subject;
var request = step.StartWorkflowRequestFactory(CreateContext());
request.WorkflowName.Should().Be("IssuePolicy");
request.WorkflowVersion.Should().Be("2.1.0");
WorkflowBusinessReferenceExtensions.ConvertBusinessReferenceValueToString(request.Payload["srPolicyId"])
.Should().Be("7100345");
WorkflowBusinessReferenceExtensions.ConvertBusinessReferenceValueToString(request.Payload["reason"])
.Should().Be("Approved");
request.BusinessReference.Should().NotBeNull();
WorkflowBusinessReferenceExtensions.ConvertBusinessReferenceValueToString(request.BusinessReference!.Parts["policyId"])
.Should().Be("7100345");
request.BusinessReference.Key.Should().Be("policyId=7100345");
}
[Test]
public void WhenExpression_WhenConfigured_ShouldBuildConditionalStepWithElseBranch()
{
var task = CreateTask(flow => flow.WhenExpression(
"Rejected?",
context => string.Equals(context.PayloadValues["answer"].Get<string>(), "reject", StringComparison.OrdinalIgnoreCase),
whenTrue => whenTrue.Set("isRejected", true),
whenElse => whenElse.Set("isRejected", false)));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowConditionalStepDefinition<FakeStartRequest>>().Subject;
step.WhenTrue.Steps.Should().ContainSingle();
step.WhenElse.Steps.Should().ContainSingle();
step.Condition.Evaluate(CreateContext(answer: "reject")).Should().BeTrue();
step.Condition.Evaluate(CreateContext(answer: "approve")).Should().BeFalse();
}
[Test]
public void SetBusinessReference_WhenConfigured_ShouldBuildBusinessReferenceAssignmentStep()
{
var task = CreateTask(flow => flow.SetBusinessReference(
context => new WorkflowBusinessReference
{
Key = context.StateValues["srPolicyId"].Get<long>().ToString(),
Parts = new Dictionary<string, object?>
{
["policyId"] = context.StateValues["srPolicyId"].Get<long>(),
},
}));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowBusinessReferenceAssignmentStepDefinition<FakeStartRequest>>().Subject;
var context = CreateContext();
var businessReference = step.BusinessReferenceFactory(context);
businessReference.Should().NotBeNull();
businessReference!.Key.Should().Be("7100345");
businessReference.Parts.Should().ContainKey("policyId").WhoseValue.Should().Be(7100345L);
}
[Test]
public void WorkflowHumanTaskBuilder_WhenDynamicRouteConfigured_ShouldResolveRouteFromWorkflowState()
{
var task = WorkflowHumanTask.For<FakeStartRequest>(
"Confirm Changes",
"CustomerChangeConfirmChanges",
"business/customers/person")
.WithRoute(context => string.Equals(context.StateValues["custType"].Get<string>(), "P", StringComparison.OrdinalIgnoreCase)
? "business/customers/person"
: "business/customers/legal-entity")
.WithPayload(context => new
{
srPolicyId = context.StateValues["srPolicyId"].Get<long>(),
})
.Build();
var context = new WorkflowSpecExecutionContext<FakeStartRequest>(
"CustomerOpenForChange",
new FakeStartRequest { SrPolicyId = 7100345L },
new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase)
.Assign("srPolicyId", 7100345L)
.Assign("custType", "L"),
new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase));
task.ResolveRoute(context).Should().Be("business/customers/legal-entity");
}
[Test]
public void WaitForkAndSubWorkflow_WhenConfigured_ShouldProduceDedicatedStepDefinitions()
{
var task = CreateTask(flow => flow
.Wait("Wait One Hour", _ => TimeSpan.FromHours(1))
.Fork(
"Parallel Validation",
left => left.Set("leftDone", true),
right => right.Set("rightDone", true))
.SubWorkflow(
"Run Child Workflow",
new WorkflowReference("ChildWorkflow"),
context => new
{
srPolicyId = context.StateValues["srPolicyId"].Get<long>(),
},
resultKey: "child"));
var steps = task.OnComplete.Steps.ToArray();
steps[0].Should().BeOfType<WorkflowTimerStepDefinition<FakeStartRequest>>();
steps[1].Should().BeOfType<WorkflowForkStepDefinition<FakeStartRequest>>();
steps[2].Should().BeOfType<WorkflowSubWorkflowStepDefinition<FakeStartRequest>>();
var subWorkflow = (WorkflowSubWorkflowStepDefinition<FakeStartRequest>)steps[2];
var request = subWorkflow.StartWorkflowRequestFactory(CreateContext());
request.WorkflowName.Should().Be("ChildWorkflow");
WorkflowBusinessReferenceExtensions.ConvertBusinessReferenceValueToString(request.Payload["srPolicyId"])
.Should().Be("7100345");
subWorkflow.ResultKey.Should().Be("child");
}
[Test]
public void WaitForSignal_WhenConfigured_ShouldProduceExternalSignalStepDefinition()
{
var task = CreateTask(flow => flow
.WaitForSignal("Wait For Documents", WorkflowExpr.String("documents-uploaded"), resultKey: "uploadSignal")
.Complete());
var steps = task.OnComplete.Steps.ToArray();
steps[0].Should().BeOfType<WorkflowExternalSignalStepDefinition<FakeStartRequest>>();
steps[1].Should().BeOfType<WorkflowCompleteStepDefinition<FakeStartRequest>>();
var signalStep = (WorkflowExternalSignalStepDefinition<FakeStartRequest>)steps[0];
signalStep.SignalNameFactory(CreateContext()).Should().Be("documents-uploaded");
signalStep.ResultKey.Should().Be("uploadSignal");
signalStep.SignalNameExpression.Should().NotBeNull();
}
[Test]
public void Repeat_WhenConfiguredWithExpressions_ShouldProduceRepeatStepDefinition()
{
var task = CreateTask(flow => flow.Repeat(
"Retry Print Batch",
WorkflowExpr.Number(3),
"attempt",
WorkflowExpr.Path("state.retryRequired"),
repeat => repeat
.Set("retryRequired", WorkflowExpr.Bool(false))
.Set("attemptObserved", WorkflowExpr.Path("state.attempt"))));
var step = task.OnComplete.Steps.Should().ContainSingle().Which
.Should().BeOfType<WorkflowRepeatStepDefinition<FakeStartRequest>>().Subject;
step.StepName.Should().Be("Retry Print Batch");
step.IterationStateKey.Should().Be("attempt");
step.MaxIterationsFactory(CreateContext()).Should().Be(3);
step.ContinueWhileEvaluator!(CreateContext()).Should().BeFalse();
step.Body.Steps.Should().HaveCount(2);
}
private static WorkflowHumanTaskDefinition<FakeStartRequest> CreateTask(
Action<WorkflowFlowBuilder<FakeStartRequest>> configure)
{
return WorkflowHumanTask.For<FakeStartRequest>(
"Approve Application",
"ApproveQTApproveApplication",
"business/policies")
.WithPayload(context => new
{
srPolicyId = context.StateValues["srPolicyId"].Get<long>(),
})
.OnComplete(configure);
}
private static WorkflowSpecExecutionContext<FakeStartRequest> CreateContext(string answer = "approve")
{
return new WorkflowSpecExecutionContext<FakeStartRequest>(
"ApproveApplication",
new FakeStartRequest { SrPolicyId = 7100345L },
new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase)
.Assign("srPolicyId", 7100345L),
new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase)
.Assign("answer", answer));
}
private sealed record FakeStartRequest
{
public long SrPolicyId { get; init; }
}
private sealed record OperationsResponse;
private sealed record GraphqlResponse;
}

View File

@@ -0,0 +1,91 @@
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Workflow.Abstractions;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
/// <summary>
/// Renders decompiled C# source and canonical JSON for all workflows
/// into docs/decompiled-samples/ for visual inspection.
/// </summary>
[TestFixture]
public class WorkflowDecompilerOutputTests
{
private static readonly string OutputRoot = Path.GetFullPath(
Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "..", "docs", "decompiled-samples"));
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
};
[Test]
public void RenderAllDecompiledOutputs()
{
var transport = new RecordingWorkflowLegacyRabbitTransport();
using var provider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider(
transport,
WorkflowRuntimeProviderNames.Engine);
var store = provider.GetRequiredService<IWorkflowRuntimeDefinitionStore>();
var catalog = provider.GetRequiredService<IWorkflowRegistrationCatalog>();
var csharpDir = Path.Combine(OutputRoot, "csharp");
var jsonDir = Path.Combine(OutputRoot, "json");
Directory.CreateDirectory(csharpDir);
Directory.CreateDirectory(jsonDir);
var rendered = 0;
var skipped = 0;
foreach (var registration in catalog.GetRegistrations().OrderBy(r => r.Definition.WorkflowName))
{
var name = registration.Definition.WorkflowName;
WorkflowRuntimeDefinition? definition;
try
{
definition = store.GetDefinition(name);
}
catch
{
skipped++;
continue;
}
if (definition?.CanonicalDefinition is null)
{
skipped++;
continue;
}
var canonical = definition.CanonicalDefinition;
// Render C# source via Roslyn decompiler
var csharpSource = WorkflowCanonicalDecompiler.Decompile(canonical);
File.WriteAllText(Path.Combine(csharpDir, $"{name}.cs"), csharpSource);
// Render canonical JSON
var jsonSource = JsonSerializer.Serialize(canonical, JsonOptions);
File.WriteAllText(Path.Combine(jsonDir, $"{name}.json"), jsonSource);
rendered++;
}
TestContext.Out.WriteLine($"Rendered {rendered} workflows, skipped {skipped}.");
TestContext.Out.WriteLine($"C# output: {csharpDir}");
TestContext.Out.WriteLine($"JSON output: {jsonDir}");
rendered.Should().BeGreaterThan(0, "at least one workflow should be rendered");
}
}

View File

@@ -0,0 +1,54 @@
using System;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Services;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowHostedJobLockServiceTests
{
[Test]
public async Task TryAcquireAsync_WhenLeaseIsActiveForDifferentOwner_ShouldRejectSecondOwner()
{
var lockService = new InMemoryWorkflowHostedJobLockService();
var now = new DateTime(2026, 3, 12, 8, 0, 0, DateTimeKind.Utc);
var firstAcquired = await lockService.TryAcquireAsync("wf-retention", "node-a", now, TimeSpan.FromMinutes(5));
var secondAcquired = await lockService.TryAcquireAsync("wf-retention", "node-b", now.AddMinutes(1), TimeSpan.FromMinutes(5));
firstAcquired.Should().BeTrue();
secondAcquired.Should().BeFalse();
}
[Test]
public async Task TryAcquireAsync_WhenLeaseExpires_ShouldAllowNextOwner()
{
var lockService = new InMemoryWorkflowHostedJobLockService();
var now = new DateTime(2026, 3, 12, 8, 0, 0, DateTimeKind.Utc);
await lockService.TryAcquireAsync("wf-retention", "node-a", now, TimeSpan.FromMinutes(5));
var nextAcquired = await lockService.TryAcquireAsync("wf-retention", "node-b", now.AddMinutes(6), TimeSpan.FromMinutes(5));
nextAcquired.Should().BeTrue();
}
[Test]
public async Task ReleaseAsync_WhenOwnerMatches_ShouldAllowImmediateReacquire()
{
var lockService = new InMemoryWorkflowHostedJobLockService();
var now = new DateTime(2026, 3, 12, 8, 0, 0, DateTimeKind.Utc);
await lockService.TryAcquireAsync("wf-retention", "node-a", now, TimeSpan.FromMinutes(5));
await lockService.ReleaseAsync("wf-retention", "node-a");
var reacquired = await lockService.TryAcquireAsync("wf-retention", "node-b", now.AddMinutes(1), TimeSpan.FromMinutes(5));
reacquired.Should().BeTrue();
}
}

View File

@@ -0,0 +1,681 @@
using FluentAssertions;
using NUnit.Framework;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Engine.Rendering;
using StellaOps.Workflow.Engine.Services;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowRenderingPipelineTests
{
[Test]
public void WorkflowRenderLayoutEngineResolver_WhenProviderOmitted_ShouldUseElkSharpByDefault()
{
using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
var resolver = provider.GetRequiredService<IWorkflowRenderLayoutEngineResolver>();
var engine = resolver.Resolve();
engine.ProviderName.Should().Be(WorkflowRenderLayoutProviderNames.ElkSharp);
}
[Test]
public void WorkflowRenderGraphCompiler_WhenApproveApplicationCompiled_ShouldProduceStartTaskAndEndNodes()
{
using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
var store = provider.GetRequiredService<IWorkflowRuntimeDefinitionStore>();
var compiler = provider.GetRequiredService<IWorkflowRenderGraphCompiler>();
var graph = compiler.Compile(store.GetRequiredDefinition("ApproveApplication"));
graph.Nodes.Should().Contain(x => x.Id == "start" && x.Kind == "Start");
graph.Nodes.Should().Contain(x => x.Kind == "HumanTask" && x.SemanticKey == "Approve Application");
graph.Nodes.Should().Contain(x => x.Id == "end" && x.Kind == "End");
graph.Edges.Should().NotBeEmpty();
}
[Test]
public void WorkflowRenderGraphCompiler_WhenAssistantPrintInsisDocumentsCompiled_ShouldPreserveForkAndTimerNodes()
{
using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
var store = provider.GetRequiredService<IWorkflowRuntimeDefinitionStore>();
var compiler = provider.GetRequiredService<IWorkflowRenderGraphCompiler>();
var graph = compiler.Compile(store.GetRequiredDefinition("AssistantPrintInsisDocuments"));
graph.Nodes.Should().Contain(x => x.Kind == "Fork" && x.Label.Contains("Spin off async process", StringComparison.Ordinal));
graph.Nodes.Should().Contain(x => x.Kind == "Timer" && x.Label.Contains("Wait 5m", StringComparison.Ordinal));
graph.Nodes.Should().NotContain(x => x.Kind == "Complete");
}
[Test]
public void WorkflowRenderGraphCompiler_WhenDecisionDefinitionCompiled_ShouldLabelGatewayRoutes()
{
var compiler = new WorkflowRenderGraphCompiler();
var definition = new WorkflowRuntimeDefinition
{
Registration = new WorkflowRegistration
{
WorkflowType = typeof(Dictionary<string, object?>),
StartRequestType = typeof(Dictionary<string, object?>),
Definition = new WorkflowDefinitionDescriptor
{
WorkflowName = "DecisionSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Decision Smoke",
Tasks = [],
},
BindStartRequest = payload => payload,
ExtractBusinessReference = _ => null,
},
Descriptor = new WorkflowDefinitionDescriptor
{
WorkflowName = "DecisionSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Decision Smoke",
Tasks = [],
},
ExecutionKind = WorkflowRuntimeExecutionKind.Declarative,
CanonicalDefinition = new WorkflowCanonicalDefinition
{
WorkflowName = "DecisionSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Decision Smoke",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = new WorkflowObjectExpressionDefinition(),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowDecisionStepDeclaration
{
DecisionName = "Approved?",
ConditionExpression = new WorkflowBinaryExpressionDefinition
{
Operator = "==",
Left = new WorkflowPathExpressionDefinition { Path = "payload.answer" },
Right = new WorkflowStringExpressionDefinition { Value = "approve" },
},
WhenTrue = new WorkflowStepSequenceDeclaration
{
Steps = [new WorkflowCompleteStepDeclaration()],
},
WhenElse = new WorkflowStepSequenceDeclaration
{
Steps = [new WorkflowCompleteStepDeclaration()],
},
},
],
},
},
},
};
var graph = compiler.Compile(definition);
graph.Edges.Should().Contain(x => x.Label == "when payload.answer == \"approve\"");
graph.Edges.Should().Contain(x => x.Label == "otherwise");
}
[Test]
public void WorkflowRenderGraphCompiler_WhenTransportFailureAndTimeoutComplete_ShouldLabelCompletionNodesByBranch()
{
var compiler = new WorkflowRenderGraphCompiler();
var failureBranch = new WorkflowStepSequenceDeclaration
{
Steps = [new WorkflowCompleteStepDeclaration()],
};
var timeoutBranch = new WorkflowStepSequenceDeclaration
{
Steps = [new WorkflowSetStateStepDeclaration
{
StateKey = "timedOut",
ValueExpression = new WorkflowBooleanExpressionDefinition { Value = true },
},
new WorkflowCompleteStepDeclaration()],
};
var definition = new WorkflowRuntimeDefinition
{
Registration = new WorkflowRegistration
{
WorkflowType = typeof(Dictionary<string, object?>),
StartRequestType = typeof(Dictionary<string, object?>),
Definition = new WorkflowDefinitionDescriptor
{
WorkflowName = "TransportSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Transport Smoke",
Tasks = [],
},
BindStartRequest = payload => payload,
ExtractBusinessReference = _ => null,
},
Descriptor = new WorkflowDefinitionDescriptor
{
WorkflowName = "TransportSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Transport Smoke",
Tasks = [],
},
ExecutionKind = WorkflowRuntimeExecutionKind.Declarative,
CanonicalDefinition = new WorkflowCanonicalDefinition
{
WorkflowName = "TransportSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Transport Smoke",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = new WorkflowObjectExpressionDefinition(),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowTransportCallStepDeclaration
{
StepName = "Notify",
Invocation = new WorkflowTransportInvocationDeclaration
{
Address = new WorkflowLegacyRabbitAddressDeclaration
{
Command = "transport.notify",
},
},
WhenFailure = failureBranch,
WhenTimeout = timeoutBranch,
},
],
},
},
},
};
var graph = compiler.Compile(definition);
graph.Nodes.Should().NotContain(x => x.Label == "Complete on Failure");
graph.Nodes.Should().Contain(x => x.Label == "Set timedOut");
graph.Nodes.Should().NotContain(x => x.Kind == "Complete");
graph.Edges.Should().Contain(x => x.SourceNodeId == "start/1" && x.TargetNodeId == "end" && x.Label == "on failure");
graph.Edges.Should().Contain(x => x.SourceNodeId == "start/1/timeout/1" && x.TargetNodeId == "end");
}
[Test]
public void WorkflowRenderGraphCompiler_WhenDecisionFallsThroughWithoutElseBranch_ShouldMarkDefaultGatewayOutput()
{
var compiler = new WorkflowRenderGraphCompiler();
var definition = new WorkflowRuntimeDefinition
{
Registration = new WorkflowRegistration
{
WorkflowType = typeof(Dictionary<string, object?>),
StartRequestType = typeof(Dictionary<string, object?>),
Definition = new WorkflowDefinitionDescriptor
{
WorkflowName = "GatewayDefaultSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Gateway Default Smoke",
Tasks = [],
},
BindStartRequest = payload => payload,
ExtractBusinessReference = _ => null,
},
Descriptor = new WorkflowDefinitionDescriptor
{
WorkflowName = "GatewayDefaultSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Gateway Default Smoke",
Tasks = [],
},
ExecutionKind = WorkflowRuntimeExecutionKind.Declarative,
CanonicalDefinition = new WorkflowCanonicalDefinition
{
WorkflowName = "GatewayDefaultSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Gateway Default Smoke",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = new WorkflowObjectExpressionDefinition(),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowDecisionStepDeclaration
{
DecisionName = "Approved?",
ConditionExpression = new WorkflowBooleanExpressionDefinition { Value = true },
WhenTrue = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowSetStateStepDeclaration
{
StateKey = "approved",
ValueExpression = new WorkflowBooleanExpressionDefinition { Value = true },
},
],
},
WhenElse = new WorkflowStepSequenceDeclaration(),
},
new WorkflowCompleteStepDeclaration(),
],
},
},
},
};
var graph = compiler.Compile(definition);
graph.Edges.Should().Contain(x => x.Label == "when true");
graph.Edges.Should().Contain(x => x.Label == "default");
}
[Test]
public void WorkflowRenderGraphCompiler_WhenLinearSettersExist_ShouldCollapseThemIntoSingleSettingNode()
{
var compiler = new WorkflowRenderGraphCompiler();
var definition = new WorkflowRuntimeDefinition
{
Registration = new WorkflowRegistration
{
WorkflowType = typeof(Dictionary<string, object?>),
StartRequestType = typeof(Dictionary<string, object?>),
Definition = new WorkflowDefinitionDescriptor
{
WorkflowName = "SetterSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Setter Smoke",
Tasks = [],
},
BindStartRequest = payload => payload,
ExtractBusinessReference = _ => null,
},
Descriptor = new WorkflowDefinitionDescriptor
{
WorkflowName = "SetterSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Setter Smoke",
Tasks = [],
},
ExecutionKind = WorkflowRuntimeExecutionKind.Declarative,
CanonicalDefinition = new WorkflowCanonicalDefinition
{
WorkflowName = "SetterSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Setter Smoke",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = new WorkflowObjectExpressionDefinition(),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowSetStateStepDeclaration
{
StateKey = "alphaValue",
ValueExpression = new WorkflowStringExpressionDefinition { Value = "a" },
},
new WorkflowSetStateStepDeclaration
{
StateKey = "betaValue",
ValueExpression = new WorkflowStringExpressionDefinition { Value = "b" },
},
new WorkflowSetStateStepDeclaration
{
StateKey = "gammaValue",
ValueExpression = new WorkflowStringExpressionDefinition { Value = "c" },
},
new WorkflowCompleteStepDeclaration(),
],
},
},
},
};
var graph = compiler.Compile(definition);
graph.Nodes.Count(x => x.Kind == "SetState").Should().Be(1);
graph.Nodes.Should().Contain(x => x.Label.Contains("Setting:", StringComparison.Ordinal));
graph.Nodes.Should().Contain(x => x.Label.Contains("alphaValue", StringComparison.Ordinal));
graph.Nodes.Should().Contain(x => x.Label.Contains("betaValue", StringComparison.Ordinal));
}
[Test]
public void WorkflowRenderGraphCompiler_WhenFailureAndTimeoutBranchesMatch_ShouldReuseSingleHandledBranch()
{
var compiler = new WorkflowRenderGraphCompiler();
var sharedBranch = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowSetStateStepDeclaration
{
StateKey = "notificationPrivateNoteFailed",
ValueExpression = new WorkflowBooleanExpressionDefinition { Value = true },
},
],
};
var definition = new WorkflowRuntimeDefinition
{
Registration = new WorkflowRegistration
{
WorkflowType = typeof(Dictionary<string, object?>),
StartRequestType = typeof(Dictionary<string, object?>),
Definition = new WorkflowDefinitionDescriptor
{
WorkflowName = "HandledBranchSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Handled Branch Smoke",
Tasks = [],
},
BindStartRequest = payload => payload,
ExtractBusinessReference = _ => null,
},
Descriptor = new WorkflowDefinitionDescriptor
{
WorkflowName = "HandledBranchSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Handled Branch Smoke",
Tasks = [],
},
ExecutionKind = WorkflowRuntimeExecutionKind.Declarative,
CanonicalDefinition = new WorkflowCanonicalDefinition
{
WorkflowName = "HandledBranchSmoke",
WorkflowVersion = "1.0.0",
DisplayName = "Handled Branch Smoke",
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = new WorkflowObjectExpressionDefinition(),
InitialSequence = new WorkflowStepSequenceDeclaration
{
Steps =
[
new WorkflowTransportCallStepDeclaration
{
StepName = "Send Private Note",
Invocation = new WorkflowTransportInvocationDeclaration
{
Address = new WorkflowLegacyRabbitAddressDeclaration
{
Command = "transport.private-note",
},
},
WhenFailure = sharedBranch,
WhenTimeout = sharedBranch,
},
],
},
},
},
};
var graph = compiler.Compile(definition);
graph.Edges.Should().Contain(x => x.Label == "on failure / timeout");
graph.Nodes.Count(x => x.Label.Contains("notificationPrivateNoteFailed", StringComparison.Ordinal)).Should().Be(1);
}
// TODO: Requires Serdica endpoints (WorkflowDiagramGetEndpoint)
// These endpoint classes were replaced by ASP.NET minimal API endpoints in StellaOps.Workflow.WebService.
// [Test]
// public async Task WorkflowDiagramGetEndpoint_WhenLayoutProviderRequested_ShouldReturnSelectedProvider()
// {
// using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
// var endpoint = new WorkflowDiagramGetEndpoint(provider.GetRequiredService<WorkflowDiagramService>());
//
// var response = await endpoint.ConsumeAsync(new WorkflowDiagramGetRequest
// {
// WorkflowName = "ApproveApplication",
// LayoutProvider = WorkflowRenderLayoutProviderNames.Msagl,
// });
//
// response.LayoutProvider.Should().Be(WorkflowRenderLayoutProviderNames.Msagl);
// response.Nodes.Should().Contain(x => x.Id == "start");
// response.Edges.Should().NotBeEmpty();
// }
// [Test]
// public async Task WorkflowDiagramGetEndpoint_WhenLayoutEffortRequested_ShouldEchoResolvedControls()
// {
// using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
// var endpoint = new WorkflowDiagramGetEndpoint(provider.GetRequiredService<WorkflowDiagramService>());
//
// var response = await endpoint.ConsumeAsync(new WorkflowDiagramGetRequest
// {
// WorkflowName = "ApproveApplication",
// LayoutEffort = "best",
// LayoutOrderingIterations = 18,
// LayoutPlacementIterations = 9,
// });
//
// response.LayoutProvider.Should().Be(WorkflowRenderLayoutProviderNames.ElkSharp);
// response.LayoutEffort.Should().Be("Best");
// response.LayoutOrderingIterations.Should().Be(18);
// response.LayoutPlacementIterations.Should().Be(9);
// }
[Test]
public async Task ElkSharpAndElkJs_WhenRenderingTenCanonicalDefinitions_ShouldPreserveStructureAndTaskOrder()
{
using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
var store = provider.GetRequiredService<IWorkflowRuntimeDefinitionStore>();
var compiler = provider.GetRequiredService<IWorkflowRenderGraphCompiler>();
var resolver = provider.GetRequiredService<IWorkflowRenderLayoutEngineResolver>();
var workflowNames = WorkflowRenderingTestHelpers.GetSampleWorkflowNames(provider, count: 10);
workflowNames.Should().HaveCount(10);
foreach (var workflowName in workflowNames)
{
var definition = store.GetRequiredDefinition(workflowName);
var graph = compiler.Compile(definition);
var elkSharpLayout = await resolver.Resolve(WorkflowRenderLayoutProviderNames.ElkSharp)
.LayoutAsync(graph);
var elkJsLayout = await resolver.Resolve(WorkflowRenderLayoutProviderNames.ElkJs)
.LayoutAsync(graph);
elkSharpLayout.Nodes.Select(x => x.Id).Should().BeEquivalentTo(elkJsLayout.Nodes.Select(x => x.Id));
elkSharpLayout.Edges.Select(x => x.Id).Should().BeEquivalentTo(elkJsLayout.Edges.Select(x => x.Id));
elkSharpLayout.Nodes.Should().OnlyContain(node => HasFiniteCoordinates(node));
elkJsLayout.Nodes.Should().OnlyContain(node => HasFiniteCoordinates(node));
CalculateForwardEdgeRatio(elkSharpLayout).Should().BeGreaterThan(0.40d, workflowName);
CalculateForwardEdgeRatio(elkJsLayout).Should().BeGreaterThan(0.40d, workflowName);
GetTaskOrder(elkSharpLayout).Should().Equal(GetTaskOrder(elkJsLayout), workflowName);
}
}
[TestCase("ApproveApplication")]
[TestCase("AssistantPrintInsisDocuments")]
public async Task AllRenderProviders_WhenRenderingComplexWorkflow_ShouldProduceFiniteLayouts(string workflowName)
{
using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
var store = provider.GetRequiredService<IWorkflowRuntimeDefinitionStore>();
var compiler = provider.GetRequiredService<IWorkflowRenderGraphCompiler>();
var resolver = provider.GetRequiredService<IWorkflowRenderLayoutEngineResolver>();
var graph = compiler.Compile(store.GetRequiredDefinition(workflowName));
foreach (var providerName in WorkflowRenderingTestHelpers.ProviderNames)
{
var layout = await resolver.Resolve(providerName).LayoutAsync(graph);
layout.Nodes.Should().NotBeEmpty($"{workflowName} / {providerName}");
layout.Edges.Should().NotBeEmpty($"{workflowName} / {providerName}");
layout.Nodes.Should().OnlyContain(x => HasFiniteCoordinates(x), $"{workflowName} / {providerName}");
}
}
[Test]
public void AssistantPrintInsisDocuments_WhenCompiledToRenderGraph_ShouldContainForkAndTimerNodes()
{
using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
var store = provider.GetRequiredService<IWorkflowRuntimeDefinitionStore>();
var compiler = provider.GetRequiredService<IWorkflowRenderGraphCompiler>();
var graph = compiler.Compile(store.GetRequiredDefinition("AssistantPrintInsisDocuments"));
graph.Nodes.Should().Contain(node => node.Kind == "Fork" && node.Label == "Spin off async process");
graph.Nodes.Should().Contain(node => node.Kind == "Timer" && node.Label == "Wait 5m");
}
[Test]
public void AssistantPolicyReinstate_WhenCompiledToRenderGraph_ShouldPreserveIpalBranchTransferAndRetry()
{
using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
var store = provider.GetRequiredService<IWorkflowRuntimeDefinitionStore>();
var compiler = provider.GetRequiredService<IWorkflowRenderGraphCompiler>();
var graph = compiler.Compile(store.GetRequiredDefinition("AssistantPolicyReinstate"));
graph.Nodes.Should().Contain(node => node.Kind == "Decision" && node.Label == "Exists on IPAL?");
graph.Nodes.Should().Contain(node => node.Kind == "TransportCall" && node.Label == "Policy Reinstate INSIS");
graph.Nodes.Should().Contain(node => node.Kind == "TransportCall" && node.Label == "Transfer Annex");
graph.Nodes.Should().Contain(node => node.Kind == "Decision" && node.Label == "Transfer approved?");
graph.Nodes.Should().Contain(node => node.Kind == "HumanTask" && node.Label == "Retry");
}
[Test]
public void UpdateSrPolicyIdSrcAndCopyCovers_WhenCompiledToRenderGraph_ShouldPreserveContinueOrEndDecision()
{
using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
var store = provider.GetRequiredService<IWorkflowRuntimeDefinitionStore>();
var compiler = provider.GetRequiredService<IWorkflowRenderGraphCompiler>();
var graph = compiler.Compile(store.GetRequiredDefinition("UpdateSrPolicyIdSrcAndCopyCovers"));
graph.Nodes.Should().Contain(node => node.Kind == "Decision" && node.Label == "Continue or end process");
}
[Test]
[Category("RenderingArtifacts")]
public async Task RenderingArtifacts_WhenGeneratingAllCanonicalDefinitions_ShouldWriteSvgJsonAndPngFiles()
{
using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
var workflowNames = WorkflowRenderingTestHelpers.GetAllCanonicalWorkflowNames(provider);
var outputRoot = WorkflowRenderingTestHelpers.GetArtifactOutputRoot();
workflowNames.Should().NotBeEmpty();
await WorkflowRenderingTestHelpers.GenerateArtifactsAsync(provider, workflowNames, outputRoot);
foreach (var workflowName in workflowNames)
{
var workflowDirectory = Path.Combine(outputRoot, SanitizeFileName(workflowName));
File.Exists(Path.Combine(workflowDirectory, "elksharp.png")).Should().BeTrue();
File.Exists(Path.Combine(workflowDirectory, "elkjs.png")).Should().BeTrue();
File.Exists(Path.Combine(workflowDirectory, "msagl.png")).Should().BeTrue();
}
}
[Test]
[Category("RenderingArtifacts")]
public async Task RenderingArtifacts_WhenGeneratingTenCanonicalDefinitions_ShouldWriteSvgJsonAndPngFiles()
{
using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
var workflowNames = WorkflowRenderingTestHelpers.GetSampleWorkflowNames(provider, count: 10);
var outputRoot = WorkflowRenderingTestHelpers.GetArtifactOutputRoot();
await WorkflowRenderingTestHelpers.GenerateArtifactsAsync(provider, workflowNames, outputRoot);
foreach (var workflowName in workflowNames)
{
var workflowDirectory = Path.Combine(outputRoot, SanitizeFileName(workflowName));
File.Exists(Path.Combine(workflowDirectory, "elksharp.png")).Should().BeTrue();
File.Exists(Path.Combine(workflowDirectory, "elkjs.png")).Should().BeTrue();
File.Exists(Path.Combine(workflowDirectory, "msagl.png")).Should().BeTrue();
}
}
[Test]
[Category("RenderingArtifacts")]
public async Task RenderingArtifacts_WhenGeneratingApproveAndAssistantPrint_ShouldWriteProviderPngFiles()
{
using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
var outputRoot = WorkflowRenderingTestHelpers.GetArtifactOutputRoot();
var workflowNames = new[] { "ApproveApplication", "AssistantPrintInsisDocuments" };
await WorkflowRenderingTestHelpers.GenerateArtifactsAsync(provider, workflowNames, outputRoot);
foreach (var workflowName in workflowNames)
{
var workflowDirectory = Path.Combine(outputRoot, SanitizeFileName(workflowName));
File.Exists(Path.Combine(workflowDirectory, "elksharp.png")).Should().BeTrue();
File.Exists(Path.Combine(workflowDirectory, "elkjs.png")).Should().BeTrue();
File.Exists(Path.Combine(workflowDirectory, "msagl.png")).Should().BeTrue();
}
}
[Test]
[Category("RenderingArtifacts")]
public async Task RenderingArtifacts_WhenGeneratingUserDataCheckConsistency_ShouldWriteProviderPngFiles()
{
using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
var outputRoot = WorkflowRenderingTestHelpers.GetArtifactOutputRoot();
var workflowNames = new[] { "UserDataCheckConsistency" };
await WorkflowRenderingTestHelpers.GenerateArtifactsAsync(provider, workflowNames, outputRoot);
foreach (var workflowName in workflowNames)
{
var workflowDirectory = Path.Combine(outputRoot, SanitizeFileName(workflowName));
File.Exists(Path.Combine(workflowDirectory, "elksharp.png")).Should().BeTrue();
File.Exists(Path.Combine(workflowDirectory, "elkjs.png")).Should().BeTrue();
File.Exists(Path.Combine(workflowDirectory, "msagl.png")).Should().BeTrue();
}
}
private static bool HasFiniteCoordinates(WorkflowRenderPositionedNode node)
{
return IsFinite(node.X)
&& IsFinite(node.Y)
&& IsFinite(node.Width)
&& IsFinite(node.Height);
}
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
}
private static double CalculateForwardEdgeRatio(WorkflowRenderLayoutResult layout)
{
if (layout.Edges.Count == 0)
{
return 1d;
}
var nodesById = layout.Nodes.ToDictionary(x => x.Id, StringComparer.Ordinal);
var forwardEdges = layout.Edges.Count(edge =>
{
var source = nodesById[edge.SourceNodeId];
var target = nodesById[edge.TargetNodeId];
var sourceCenterX = source.X + (source.Width / 2d);
var targetCenterX = target.X + (target.Width / 2d);
return targetCenterX >= sourceCenterX - 1d;
});
return forwardEdges / (double)layout.Edges.Count;
}
private static IReadOnlyCollection<string> GetTaskOrder(WorkflowRenderLayoutResult layout)
{
return layout.Nodes
.Where(x => string.Equals(x.SemanticType, "Task", StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.X)
.ThenBy(x => x.Y)
.Select(x => x.SemanticKey ?? x.Id)
.ToArray();
}
private static string SanitizeFileName(string value)
{
var invalid = Path.GetInvalidFileNameChars();
return new string(value.Select(character => invalid.Contains(character) ? '_' : character).ToArray());
}
}

View File

@@ -0,0 +1,151 @@
using System.Text.Json;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Renderer.Svg;
using StellaOps.Workflow.Engine.Services;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Workflow.Engine.Tests;
public static class WorkflowRenderingTestHelpers
{
internal static readonly string[] ProviderNames =
[
WorkflowRenderLayoutProviderNames.ElkSharp,
WorkflowRenderLayoutProviderNames.ElkJs,
WorkflowRenderLayoutProviderNames.Msagl,
];
internal static ServiceProvider CreateRenderingServiceProvider()
{
return TechnicalStyleWorkflowTestHelpers.CreateServiceProvider(
new RecordingWorkflowLegacyRabbitTransport(),
includeAdditionalTransportModules: true);
}
internal static string[] GetSampleWorkflowNames(ServiceProvider provider, int count = 10)
{
return GetCanonicalWorkflowNames(provider, count);
}
internal static string[] GetAllCanonicalWorkflowNames(ServiceProvider provider)
{
return GetCanonicalWorkflowNames(provider, count: null);
}
private static string[] GetCanonicalWorkflowNames(ServiceProvider provider, int? count)
{
var registrations = provider
.GetRequiredService<IWorkflowRegistrationCatalog>()
.GetRegistrations()
.OrderBy(x => x.Definition.WorkflowName, StringComparer.OrdinalIgnoreCase)
.ThenByDescending(x => x.Definition.WorkflowVersion, WorkflowVersioning.SemanticComparer)
.ToArray();
var store = provider.GetRequiredService<IWorkflowRuntimeDefinitionStore>();
var selectedNames = new List<string>(count ?? registrations.Length);
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var registration in registrations)
{
if (!seenNames.Add(registration.Definition.WorkflowName))
{
continue;
}
try
{
var definition = store.GetRequiredDefinition(
registration.Definition.WorkflowName,
registration.Definition.WorkflowVersion);
if (definition.CanonicalDefinition is null)
{
continue;
}
selectedNames.Add(registration.Definition.WorkflowName);
if (count is not null && selectedNames.Count == count.Value)
{
break;
}
}
catch (InvalidOperationException)
{
}
}
return selectedNames.ToArray();
}
internal static async Task GenerateArtifactsAsync(
ServiceProvider provider,
IReadOnlyCollection<string> workflowNames,
string outputRoot,
CancellationToken cancellationToken = default)
{
var store = provider.GetRequiredService<IWorkflowRuntimeDefinitionStore>();
var compiler = provider.GetRequiredService<IWorkflowRenderGraphCompiler>();
var resolver = provider.GetRequiredService<IWorkflowRenderLayoutEngineResolver>();
var svgRenderer = new WorkflowRenderSvgRenderer();
var pngExporter = new WorkflowRenderPngExporter();
foreach (var workflowName in workflowNames)
{
var definition = store.GetRequiredDefinition(workflowName);
var graph = compiler.Compile(definition);
var workflowDirectory = Path.Combine(outputRoot, SanitizeFileName(workflowName));
Directory.CreateDirectory(workflowDirectory);
foreach (var providerName in ProviderNames)
{
var engine = resolver.Resolve(providerName);
var layout = await engine.LayoutAsync(graph, cancellationToken: cancellationToken);
var svgDocument = svgRenderer.Render(layout, $"{workflowName} [{providerName}]");
var basePath = Path.Combine(workflowDirectory, providerName.ToLowerInvariant());
await File.WriteAllTextAsync($"{basePath}.svg", svgDocument.Svg, cancellationToken);
await File.WriteAllTextAsync(
$"{basePath}.json",
JsonSerializer.Serialize(layout, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true,
}),
cancellationToken);
await pngExporter.ExportAsync(svgDocument, $"{basePath}.png", cancellationToken: cancellationToken);
}
}
}
internal static string GetArtifactOutputRoot()
{
var probeDirectory = new DirectoryInfo(AppContext.BaseDirectory);
while (probeDirectory is not null)
{
if (File.Exists(Path.Combine(probeDirectory.FullName, "Directory.Build.props")))
{
return Path.Combine(
probeDirectory.FullName,
"src",
"Workflow",
"__Tests",
"docs",
"renderings",
DateTime.UtcNow.ToString("yyyyMMdd", global::System.Globalization.CultureInfo.InvariantCulture));
}
probeDirectory = probeDirectory.Parent;
}
return Path.Combine(
AppContext.BaseDirectory,
"TestResults",
"workflow-renderings",
DateTime.UtcNow.ToString("yyyyMMdd", global::System.Globalization.CultureInfo.InvariantCulture));
}
private static string SanitizeFileName(string value)
{
var invalid = Path.GetInvalidFileNameChars();
return new string(value.Select(character => invalid.Contains(character) ? '_' : character).ToArray());
}
}

View File

@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.DataStore.Oracle;
using StellaOps.Workflow.Engine.Services;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowRetentionServiceTests
{
[Test]
public async Task RunAsync_WhenOpenInstanceAndTaskArePastStaleThreshold_ShouldMarkThemAsStale()
{
using var provider = CreateServiceProvider();
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var dbContext = provider.GetRequiredService<WorkflowDbContext>();
var retentionService = provider.GetRequiredService<WorkflowRetentionService>();
var runtimeStateStore = provider.GetRequiredService<IWorkflowRuntimeStateStore>();
var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "ApproveApplication",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 7200345M,
["srAnnexId"] = 9994M,
["srCustId"] = 7794M,
},
});
var now = new DateTime(2026, 3, 11, 10, 0, 0, DateTimeKind.Utc);
var instance = await dbContext.WorkflowInstances.SingleAsync(x => x.WorkflowInstanceId == startResponse.WorkflowInstanceId);
var task = await dbContext.WorkflowTasks.SingleAsync(x => x.WorkflowInstanceId == startResponse.WorkflowInstanceId);
instance.StaleAfterUtc = now.AddMinutes(-5);
task.StaleAfterUtc = now.AddMinutes(-5);
await dbContext.SaveChangesAsync();
var result = await retentionService.RunAsync(now);
result.StaleInstancesMarked.Should().Be(1);
result.StaleTasksMarked.Should().Be(1);
result.PurgedRuntimeStates.Should().Be(0);
instance.Status.Should().Be("Stale");
task.Status.Should().Be("Stale");
var runtimeState = await runtimeStateStore.GetAsync(startResponse.WorkflowInstanceId);
runtimeState.Should().NotBeNull();
runtimeState!.RuntimeStatus.Should().Be("Stale");
}
[Test]
public async Task RunAsync_WhenCompletedInstanceIsExpired_ShouldPurgeInstanceTasksAndEvents()
{
var transport = new RecordingWorkflowLegacyRabbitTransport()
.Respond("pas_annexprocessing_cancelaplorqt", new { Cancelled = true });
using var provider = CreateServiceProvider(transport);
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var dbContext = provider.GetRequiredService<WorkflowDbContext>();
var retentionService = provider.GetRequiredService<WorkflowRetentionService>();
var runtimeStateStore = provider.GetRequiredService<IWorkflowRuntimeStateStore>();
var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "ApproveApplication",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 8200345M,
["srAnnexId"] = 9995M,
["srCustId"] = 7795M,
},
});
var task = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
})).Tasks.Single();
await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
{
WorkflowTaskId = task.WorkflowTaskId,
ActorId = "user-1",
ActorRoles = ["APR_APPL"],
Payload = new Dictionary<string, object?>
{
["answer"] = "reject",
},
});
var now = new DateTime(2026, 3, 11, 12, 0, 0, DateTimeKind.Utc);
var instance = await dbContext.WorkflowInstances.SingleAsync(x => x.WorkflowInstanceId == startResponse.WorkflowInstanceId);
var completedTask = await dbContext.WorkflowTasks.SingleAsync(x => x.WorkflowTaskId == task.WorkflowTaskId);
instance.PurgeAfterUtc = now.AddMinutes(-5);
completedTask.PurgeAfterUtc = now.AddMinutes(-5);
await dbContext.SaveChangesAsync();
var result = await retentionService.RunAsync(now);
result.PurgedInstances.Should().Be(1);
result.PurgedTasks.Should().Be(1);
result.PurgedTaskEvents.Should().BeGreaterThan(0);
result.PurgedRuntimeStates.Should().Be(1);
dbContext.WorkflowInstances.Should().BeEmpty();
dbContext.WorkflowTasks.Should().BeEmpty();
dbContext.WorkflowTaskEvents.Should().BeEmpty();
(await runtimeStateStore.GetAsync(startResponse.WorkflowInstanceId)).Should().BeNull();
}
private static ServiceProvider CreateServiceProvider(RecordingWorkflowLegacyRabbitTransport? transport = null)
{
var services = new ServiceCollection();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["WorkflowRetention:OpenStaleAfterDays"] = "30",
["WorkflowRetention:CompletedPurgeAfterDays"] = "180",
["GenericAssignmentPermissions:AdminRoles:0"] = "DBA",
})
.Build();
services.AddLogging();
TechnicalStyleWorkflowTestHelpers.RegisterTestWorkflows(services);
services.AddWorkflowEngineCoreServices(configuration);
services.AddDbContext<WorkflowDbContext>(options =>
options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
services.AddScoped<IWorkflowLegacyRabbitTransport>(_ => transport ?? new RecordingWorkflowLegacyRabbitTransport());
var provider = services.BuildServiceProvider();
// ServiceProviderAccessor.Initialize(provider);
return provider;
}
}

View File

@@ -0,0 +1,305 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using FluentAssertions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
/// <summary>
/// Real round-trip compiler fidelity tests using Roslyn dynamic compilation.
/// For every registered declarative workflow:
/// 1. Get the compiled canonical definition (JSON₁)
/// 2. Decompile to C# source via WorkflowCanonicalDecompiler
/// 3. Compile the generated C# source with Roslyn into an in-memory assembly
/// 4. Instantiate the workflow class from the dynamic assembly
/// 5. Compile it back to canonical definition (JSON₂)
/// 6. Assert JSON₁ == JSON₂
///
/// This catches ANY information loss in the decompiler — missing steps, truncated
/// expressions, wrong addresses, lost failure/timeout branches, etc.
/// </summary>
[TestFixture]
public class WorkflowRoundTripCompilerTests
{
private static ServiceProvider? provider;
private static IWorkflowRuntimeDefinitionStore? definitionStore;
private static MetadataReference[]? references;
[OneTimeSetUp]
public void Setup()
{
var transport = new RecordingWorkflowLegacyRabbitTransport();
provider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider(
transport,
WorkflowRuntimeProviderNames.Engine);
definitionStore = provider.GetRequiredService<IWorkflowRuntimeDefinitionStore>();
references = BuildMetadataReferences();
}
[OneTimeTearDown]
public void Teardown()
{
provider?.Dispose();
}
private static IEnumerable<string> AllDeclarativeWorkflows()
{
var transport = new RecordingWorkflowLegacyRabbitTransport();
using var tempProvider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider(
transport,
WorkflowRuntimeProviderNames.Engine);
var catalog = tempProvider.GetRequiredService<IWorkflowRegistrationCatalog>();
return catalog.GetRegistrations()
.Select(r => r.Definition.WorkflowName)
.OrderBy(x => x)
.ToArray();
}
[TestCaseSource(nameof(AllDeclarativeWorkflows))]
public void RoundTrip_DecompileAndRecompile_ShouldProduceIdenticalCanonicalJson(string workflowName)
{
// Step 1: Get the original compiled definition
WorkflowRuntimeDefinition? definition;
try
{
definition = definitionStore!.GetDefinition(workflowName);
}
catch
{
Assert.Ignore($"Workflow '{workflowName}' failed to compile — skipped.");
return;
}
if (definition?.CanonicalDefinition is null)
{
Assert.Ignore($"Workflow '{workflowName}' has no canonical definition.");
return;
}
var original = definition.CanonicalDefinition;
var originalJson = SerializeDefinition(original);
// Step 2: Decompile to C# source
var csharpSource = WorkflowCanonicalDecompiler.Decompile(original);
// Step 3: Compile the generated C# with Roslyn
var compilation = CSharpCompilation.Create(
$"RoundTrip_{workflowName}",
syntaxTrees: [CSharpSyntaxTree.ParseText(csharpSource)],
references: references,
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
.WithNullableContextOptions(NullableContextOptions.Enable));
using var ms = new MemoryStream();
var emitResult = compilation.Emit(ms);
if (!emitResult.Success)
{
var errors = emitResult.Diagnostics
.Where(d => d.Severity == DiagnosticSeverity.Error)
.Select(d => d.ToString())
.ToArray();
Assert.Fail(
$"Decompiled C# for '{workflowName}' failed to compile:\n{string.Join("\n", errors.Take(10))}\n\n--- Source ---\n{csharpSource[..Math.Min(csharpSource.Length, 500)]}");
return;
}
// Step 4: Load the assembly and find the workflow class
ms.Seek(0, SeekOrigin.Begin);
var loadContext = new AssemblyLoadContext($"RoundTrip_{workflowName}", isCollectible: true);
try
{
var assembly = loadContext.LoadFromStream(ms);
var workflowType = assembly.GetTypes()
.FirstOrDefault(t => t.GetInterfaces()
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDeclarativeWorkflow<>)));
workflowType.Should().NotBeNull(
$"compiled assembly for '{workflowName}' should contain a class implementing IDeclarativeWorkflow<>");
var workflowInstance = Activator.CreateInstance(workflowType!);
workflowInstance.Should().NotBeNull();
// Step 5: Compile the instantiated workflow back to canonical definition
var declarativeInterface = workflowType!.GetInterfaces()
.First(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDeclarativeWorkflow<>));
var startRequestType = declarativeInterface.GetGenericArguments()[0];
var compileMethod = typeof(WorkflowCanonicalDefinitionCompiler)
.GetMethod("Compile", BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(startRequestType);
var result = (WorkflowCanonicalCompilationResult)compileMethod.Invoke(null, [workflowInstance, null])!;
result.Definition.Should().NotBeNull(
$"recompiled '{workflowName}' should produce a canonical definition. Diagnostics: {string.Join(", ", result.Diagnostics.Select(d => d.Message))}");
// Step 6: Compare JSON
var recompiledJson = SerializeDefinition(result.Definition!);
// Normalize: the recompiled definition will have a different StartRequest.Schema
// (generated from the dynamic type) and possibly different ContractName.
// Strip these for comparison since they depend on CLR type identity.
var originalNormalized = NormalizeForComparison(originalJson);
var recompiledNormalized = NormalizeForComparison(recompiledJson);
recompiledNormalized.Should().Be(originalNormalized,
$"round-trip of '{workflowName}': decompile → compile should produce identical canonical JSON");
}
finally
{
loadContext.Unload();
}
}
[TestCaseSource(nameof(AllDeclarativeWorkflows))]
public void Decompile_ShouldProduceCompilableCSharpSource(string workflowName)
{
WorkflowRuntimeDefinition? definition;
try
{
definition = definitionStore!.GetDefinition(workflowName);
}
catch
{
Assert.Ignore($"Workflow '{workflowName}' failed to compile — skipped.");
return;
}
if (definition?.CanonicalDefinition is null)
{
Assert.Ignore($"Workflow '{workflowName}' has no canonical definition.");
return;
}
var csharpSource = WorkflowCanonicalDecompiler.Decompile(definition.CanonicalDefinition);
var compilation = CSharpCompilation.Create(
$"CompileCheck_{workflowName}",
syntaxTrees: [CSharpSyntaxTree.ParseText(csharpSource)],
references: references,
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
.WithNullableContextOptions(NullableContextOptions.Enable));
var errors = compilation.GetDiagnostics()
.Where(d => d.Severity == DiagnosticSeverity.Error)
.ToArray();
errors.Should().BeEmpty(
$"decompiled C# for '{workflowName}' should compile without errors. " +
$"Errors: {string.Join("; ", errors.Take(5).Select(e => e.ToString()))}");
}
[Test]
public void AllDeclarativeWorkflows_ShouldHaveAtLeastOneWorkflow()
{
var workflows = AllDeclarativeWorkflows().ToArray();
workflows.Should().NotBeEmpty("at least one declarative workflow should be registered");
TestContext.Out.WriteLine($"Found {workflows.Length} declarative workflows for round-trip testing.");
}
private static string SerializeDefinition(WorkflowCanonicalDefinition definition)
{
return JsonSerializer.Serialize(definition, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
});
}
/// <summary>
/// Strips startRequest (schema + contractName differ between original CLR type and dynamic type)
/// and requiredModules (inferred at compile time, may differ) for fair comparison.
/// </summary>
private static string NormalizeForComparison(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var dict = new Dictionary<string, JsonElement>();
foreach (var prop in root.EnumerateObject())
{
// Skip fields that depend on CLR type identity
if (string.Equals(prop.Name, "startRequest", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (string.Equals(prop.Name, "requiredModules", StringComparison.OrdinalIgnoreCase))
{
continue;
}
dict[prop.Name] = prop.Value.Clone();
}
return JsonSerializer.Serialize(dict, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
});
}
private static MetadataReference[] BuildMetadataReferences()
{
var refs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Add all currently loaded assemblies that are relevant
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
if (asm.IsDynamic || string.IsNullOrWhiteSpace(asm.Location))
{
continue;
}
refs.Add(asm.Location);
}
// Ensure core runtime assemblies are included
var runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
foreach (var dll in Directory.GetFiles(runtimeDir, "System*.dll"))
{
refs.Add(dll);
}
refs.Add(Path.Combine(runtimeDir, "netstandard.dll"));
return refs
.Where(File.Exists)
.Where(path =>
{
try
{
// Skip native DLLs that aren't managed assemblies
using var fs = File.OpenRead(path);
using var pe = new global::System.Reflection.PortableExecutable.PEReader(fs);
return pe.HasMetadata;
}
catch
{
return false;
}
})
.Select(path => (MetadataReference)MetadataReference.CreateFromFile(path))
.ToArray();
}
}

View File

@@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Engine.Definitions;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowRuntimeDefinitionStoreTests
{
[Test]
public void GetRequiredDefinition_WhenDeclarativeWorkflowIsValid_ShouldReturnCanonicalRuntimeDefinition()
{
var store = CreateStore(BuildRegistration<ValidRuntimeDefinitionWorkflow, RuntimeDefinitionStartRequest>());
var definition = store.GetRequiredDefinition(ValidRuntimeDefinitionWorkflow.WorkflowNameValue);
definition.ExecutionKind.Should().Be(WorkflowRuntimeExecutionKind.Declarative);
definition.CanonicalDefinition.Should().NotBeNull();
definition.CanonicalDefinition!.WorkflowName.Should().Be(ValidRuntimeDefinitionWorkflow.WorkflowNameValue);
definition.CanonicalDefinition.Start.InitializeStateExpression.Should().NotBeNull();
definition.CanonicalDefinition.Tasks.Should().ContainSingle(x => x.TaskName == "Review");
}
[Test]
public void GetDefinition_WhenVersionOmitted_ShouldReturnLatestDefinition()
{
var older = BuildPlainRegistration("VersionedWorkflow", "1.0.0");
var newer = BuildPlainRegistration("VersionedWorkflow", "2.0.0");
var store = CreateStore(older, newer);
var definition = store.GetRequiredDefinition("VersionedWorkflow");
definition.Descriptor.WorkflowVersion.Should().Be("2.0.0");
definition.ExecutionKind.Should().Be(WorkflowRuntimeExecutionKind.DefinitionOnly);
}
[Test]
public void GetRequiredDefinition_WhenCustomHandlerWorkflowRegistered_ShouldReturnHandlerExecutionKind()
{
var registration = BuildPlainRegistration(
"CustomHandlerWorkflow",
"1.0.0",
handlerType: typeof(FakeCustomWorkflowExecutionHandler));
var store = CreateStore(registration);
var definition = store.GetRequiredDefinition("CustomHandlerWorkflow");
definition.ExecutionKind.Should().Be(WorkflowRuntimeExecutionKind.CustomHandler);
definition.CanonicalDefinition.Should().BeNull();
}
[Test]
public void GetRequiredDefinition_WhenDeclarativeWorkflowContainsClrDelegate_ShouldThrow()
{
var store = CreateStore(BuildRegistration<InvalidRuntimeDefinitionWorkflow, RuntimeDefinitionStartRequest>());
var act = () => store.GetRequiredDefinition(InvalidRuntimeDefinitionWorkflow.WorkflowNameValue);
act.Should().Throw<InvalidOperationException>()
.WithMessage("*WFCD001*");
}
[Test]
public void GetRequiredDefinition_WhenAnotherDeclarativeWorkflowIsInvalid_ShouldStillResolveUnrelatedDefinition()
{
var store = CreateStore(
BuildPlainRegistration("PlainRuntimeDefinitionWorkflow", "1.0.0"),
BuildRegistration<InvalidRuntimeDefinitionWorkflow, RuntimeDefinitionStartRequest>());
var definition = store.GetRequiredDefinition("PlainRuntimeDefinitionWorkflow");
definition.ExecutionKind.Should().Be(WorkflowRuntimeExecutionKind.DefinitionOnly);
definition.CanonicalDefinition.Should().BeNull();
}
private static WorkflowRuntimeDefinitionStore CreateStore(params WorkflowRegistration[] registrations)
{
return new WorkflowRuntimeDefinitionStore(
new FakeWorkflowRegistrationCatalog(registrations),
new FakeWorkflowModuleCatalog(
[
new WorkflowInstalledModule("workflow.dsl.core", "1.0.0"),
new WorkflowInstalledModule("workflow.functions.core", "1.0.0"),
]),
new WorkflowFunctionCatalog([new WorkflowCoreFunctionProvider()]));
}
private static WorkflowRegistration BuildRegistration<TWorkflow, TStartRequest>()
where TWorkflow : class, ISerdicaWorkflow<TStartRequest>, new()
where TStartRequest : class, new()
{
var workflow = new TWorkflow();
return new WorkflowRegistration
{
WorkflowType = typeof(TWorkflow),
StartRequestType = typeof(TStartRequest),
Definition = new WorkflowDefinitionDescriptor
{
WorkflowName = workflow.WorkflowName,
WorkflowVersion = workflow.WorkflowVersion,
DisplayName = workflow.DisplayName,
WorkflowRoles = workflow.WorkflowRoles.ToArray(),
Tasks = workflow.Tasks.ToArray(),
},
BindStartRequest = _ => new TStartRequest(),
ExtractBusinessReference = _ => null,
};
}
private static WorkflowRegistration BuildPlainRegistration(
string workflowName,
string workflowVersion,
Type? handlerType = null)
{
return new WorkflowRegistration
{
WorkflowType = typeof(object),
StartRequestType = typeof(Dictionary<string, object?>),
HandlerType = handlerType,
Definition = new WorkflowDefinitionDescriptor
{
WorkflowName = workflowName,
WorkflowVersion = workflowVersion,
DisplayName = workflowName,
},
BindStartRequest = payload => payload,
ExtractBusinessReference = _ => null,
};
}
private sealed class FakeWorkflowRegistrationCatalog(params WorkflowRegistration[] registrations)
: IWorkflowRegistrationCatalog
{
private readonly WorkflowRegistration[] items = registrations
.OrderBy(x => x.Definition.WorkflowName, StringComparer.OrdinalIgnoreCase)
.ThenByDescending(x => x.Definition.WorkflowVersion, WorkflowVersioning.SemanticComparer)
.ToArray();
public IReadOnlyCollection<WorkflowRegistration> GetRegistrations()
{
return items;
}
public WorkflowRegistration? GetRegistration(string workflowName, string? workflowVersion = null)
{
return items.FirstOrDefault(x =>
string.Equals(x.Definition.WorkflowName, workflowName, StringComparison.OrdinalIgnoreCase)
&& (string.IsNullOrWhiteSpace(workflowVersion)
|| string.Equals(x.Definition.WorkflowVersion, workflowVersion, StringComparison.OrdinalIgnoreCase)));
}
}
private sealed class FakeWorkflowModuleCatalog(IReadOnlyCollection<WorkflowInstalledModule> modules) : IWorkflowModuleCatalog
{
public IReadOnlyCollection<WorkflowInstalledModule> GetInstalledModules()
{
return modules;
}
}
private sealed class FakeCustomWorkflowExecutionHandler : IWorkflowExecutionHandler
{
public Task<WorkflowStartExecutionPlan> StartAsync(
WorkflowStartExecutionContext context,
CancellationToken cancellationToken = default)
{
throw new NotSupportedException();
}
public Task<WorkflowTaskCompletionPlan> CompleteTaskAsync(
WorkflowTaskExecutionContext context,
CancellationToken cancellationToken = default)
{
throw new NotSupportedException();
}
}
private sealed record RuntimeDefinitionStartRequest
{
public long SrPolicyId { get; init; }
}
private sealed class ValidRuntimeDefinitionWorkflow : IDeclarativeWorkflow<RuntimeDefinitionStartRequest>
{
public const string WorkflowNameValue = "ValidRuntimeDefinitionWorkflow";
public string WorkflowName => WorkflowNameValue;
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Valid Runtime Definition Workflow";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<RuntimeDefinitionStartRequest> Spec { get; } = WorkflowSpec.For<RuntimeDefinitionStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId"))))
.StartWith(
WorkflowHumanTask.For<RuntimeDefinitionStartRequest>(
"Review",
"ReviewRuntimeDefinition",
"workflow/review")
.WithPayload(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId"))))
.Build())
.Build();
}
private sealed class InvalidRuntimeDefinitionWorkflow : IDeclarativeWorkflow<RuntimeDefinitionStartRequest>
{
public const string WorkflowNameValue = "InvalidRuntimeDefinitionWorkflow";
public string WorkflowName => WorkflowNameValue;
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Invalid Runtime Definition Workflow";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<RuntimeDefinitionStartRequest> Spec { get; } = WorkflowSpec.For<RuntimeDefinitionStartRequest>()
.InitializeState(startRequest => new Dictionary<string, JsonElement>
{
["srPolicyId"] = JsonSerializer.SerializeToElement(startRequest.SrPolicyId),
})
.StartWith(
WorkflowHumanTask.For<RuntimeDefinitionStartRequest>(
"Review",
"ReviewRuntimeDefinition",
"workflow/review")
.WithPayload(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId"))))
.Build())
.Build();
}
}

View File

@@ -0,0 +1,597 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.DataStore.Oracle;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Engine.HostedServices;
using StellaOps.Workflow.Engine.Services;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowRuntimeRecoveryTests
{
[Test]
public async Task RunOnceAsync_WhenProviderIsRecreatedWithPersistedTimerSignal_ShouldResumeWorkflowFromStoredState()
{
var databaseRoot = new InMemoryDatabaseRoot();
var databaseName = Guid.NewGuid().ToString("N");
var runtimeStateBacking = new PersistedWorkflowRuntimeStateBacking();
var signalBacking = new PersistedWorkflowSignalBacking();
string workflowInstanceId;
using (var provider = CreateServiceProvider(databaseRoot, databaseName, runtimeStateBacking, signalBacking))
{
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = TimerRecoveryWorkflow.WorkflowNameValue,
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 910001L,
},
});
workflowInstanceId = startResponse.WorkflowInstanceId;
signalBacking.ScheduledSignals.Should().ContainSingle();
var startedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowInstanceId,
});
startedInstance.Instance.RuntimeStatus.Should().Be("WaitingForSignal");
ReadString(startedInstance.WorkflowState["phase"]).Should().Be("waiting");
ReadLong(startedInstance.RuntimeState!.State["version"]).Should().Be(1L);
}
signalBacking.PromoteDueSignals(DateTime.UtcNow.AddMinutes(10));
using (var provider = CreateServiceProvider(databaseRoot, databaseName, runtimeStateBacking, signalBacking))
{
var worker = provider.GetRequiredService<WorkflowSignalPumpWorker>();
var processed = await worker.RunOnceAsync("workflow-service", CancellationToken.None);
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
processed.Should().BeTrue();
signalBacking.CompletedSignals.Should().ContainSingle();
var resumedTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = workflowInstanceId,
Status = WorkflowTaskStatuses.Open,
});
var resumedTask = resumedTasks.Tasks.Should().ContainSingle().Subject;
var resumedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowInstanceId,
});
resumedTask.TaskName.Should().Be("Recovered Timer Review");
ReadString(resumedTask.Payload["phase"]).Should().Be("after-timer");
ReadBool(resumedTask.Payload["timerFired"]).Should().BeTrue();
resumedInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask");
ReadString(resumedInstance.WorkflowState["phase"]).Should().Be("after-timer");
ReadLong(resumedInstance.RuntimeState!.State["version"]).Should().Be(2L);
await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
{
WorkflowTaskId = resumedTask.WorkflowTaskId,
ActorId = "recovery-user",
ActorRoles = ["DBA"],
Payload = new Dictionary<string, object?>(),
});
var completedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowInstanceId,
});
completedInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed);
completedInstance.Instance.RuntimeStatus.Should().Be(WorkflowInstanceStatuses.Completed);
ReadLong(completedInstance.RuntimeState!.State["version"]).Should().Be(3L);
}
}
[Test]
public async Task RunOnceAsync_WhenProviderIsRecreatedAfterExternalSignalIsRaised_ShouldResumeWorkflowFromStoredState()
{
var databaseRoot = new InMemoryDatabaseRoot();
var databaseName = Guid.NewGuid().ToString("N");
var runtimeStateBacking = new PersistedWorkflowRuntimeStateBacking();
var signalBacking = new PersistedWorkflowSignalBacking();
string workflowInstanceId;
using (var provider = CreateServiceProvider(databaseRoot, databaseName, runtimeStateBacking, signalBacking))
{
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = ExternalSignalRecoveryWorkflow.WorkflowNameValue,
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 910002L,
},
});
workflowInstanceId = startResponse.WorkflowInstanceId;
var waitingInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowInstanceId,
});
waitingInstance.Instance.RuntimeStatus.Should().Be("WaitingForSignal");
ReadString(waitingInstance.WorkflowState["phase"]).Should().Be("waiting-external");
ReadLong(waitingInstance.RuntimeState!.State["version"]).Should().Be(1L);
}
using (var provider = CreateServiceProvider(databaseRoot, databaseName, runtimeStateBacking, signalBacking))
{
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var worker = provider.GetRequiredService<WorkflowSignalPumpWorker>();
var raiseResponse = await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest
{
WorkflowInstanceId = workflowInstanceId,
SignalName = "documents-uploaded",
Payload = new Dictionary<string, object?>
{
["documentId"] = 991337L,
},
});
raiseResponse.Queued.Should().BeTrue();
var processed = await worker.RunOnceAsync("workflow-service", CancellationToken.None);
processed.Should().BeTrue();
var openTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = workflowInstanceId,
Status = WorkflowTaskStatuses.Open,
});
var openTask = openTasks.Tasks.Should().ContainSingle().Subject;
var resumedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowInstanceId,
});
openTask.TaskName.Should().Be("Recovered External Review");
ReadString(openTask.Payload["phase"]).Should().Be("after-external");
ReadLong(openTask.Payload["documentId"]).Should().Be(991337L);
resumedInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask");
ReadString(resumedInstance.WorkflowState["phase"]).Should().Be("after-external");
ReadLong(resumedInstance.WorkflowState["documentId"]).Should().Be(991337L);
ReadLong(resumedInstance.RuntimeState!.State["version"]).Should().Be(2L);
}
}
private static ServiceProvider CreateServiceProvider(
InMemoryDatabaseRoot databaseRoot,
string databaseName,
PersistedWorkflowRuntimeStateBacking runtimeStateBacking,
PersistedWorkflowSignalBacking signalBacking)
{
var services = new ServiceCollection();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["WorkflowRetention:OpenStaleAfterDays"] = "30",
["WorkflowRetention:CompletedPurgeAfterDays"] = "180",
["WorkflowRuntime:DefaultProvider"] = WorkflowRuntimeProviderNames.Engine,
["WorkflowRuntime:EnabledProviders:0"] = WorkflowRuntimeProviderNames.Engine,
["WorkflowAq:ConsumerName"] = "workflow-service",
})
.Build();
services.AddLogging();
services.AddWorkflowRegistration<TimerRecoveryWorkflow, RecoveryStartRequest>();
services.AddWorkflowRegistration<ExternalSignalRecoveryWorkflow, RecoveryStartRequest>();
services.AddWorkflowEngineCoreServices(configuration);
services.AddDbContext<WorkflowDbContext>(options => options.UseInMemoryDatabase(databaseName, databaseRoot));
services.Replace(ServiceDescriptor.Singleton<IWorkflowRuntimeStateStore>(
_ => new PersistedWorkflowRuntimeStateStore(runtimeStateBacking)));
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalBus>(
_ => new PersistedWorkflowSignalBus(signalBacking)));
services.Replace(ServiceDescriptor.Scoped<IWorkflowScheduleBus>(
_ => new PersistedWorkflowScheduleBus(signalBacking)));
var provider = services.BuildServiceProvider();
// ServiceProviderAccessor.Initialize(provider);
return provider;
}
private static string ReadString(object? value)
{
return value switch
{
string text => text,
JsonElement jsonElement => jsonElement.Get<string>(),
_ => throw new AssertionException("Value is not a string."),
};
}
private static bool ReadBool(object? value)
{
return value switch
{
bool boolean => boolean,
JsonElement jsonElement => jsonElement.Get<bool>(),
_ => throw new AssertionException("Value is not a boolean."),
};
}
private static long ReadLong(object? value)
{
return value switch
{
long number => number,
int number => number,
JsonElement jsonElement when jsonElement.TryGetInt64(out var number) => number,
_ => throw new AssertionException("Value is not an integer."),
};
}
private sealed class PersistedWorkflowRuntimeStateBacking
{
public Dictionary<string, WorkflowRuntimeStateRecord> Records { get; } = new(StringComparer.OrdinalIgnoreCase);
}
private sealed class PersistedWorkflowRuntimeStateStore(
PersistedWorkflowRuntimeStateBacking backing) : IWorkflowRuntimeStateStore
{
public Task UpsertAsync(
WorkflowRuntimeStateRecord state,
CancellationToken cancellationToken = default)
{
backing.Records[state.WorkflowInstanceId] = state;
return Task.CompletedTask;
}
public Task<WorkflowRuntimeStateRecord?> GetAsync(
string workflowInstanceId,
CancellationToken cancellationToken = default)
{
backing.Records.TryGetValue(workflowInstanceId, out var state);
return Task.FromResult(state);
}
public Task<IReadOnlyCollection<WorkflowRuntimeStateRecord>> GetManyAsync(
IReadOnlyCollection<string> workflowInstanceIds,
CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyCollection<WorkflowRuntimeStateRecord>>(
workflowInstanceIds
.Where(backing.Records.ContainsKey)
.Select(id => backing.Records[id])
.ToArray());
}
public Task<int> MarkStaleAsync(
IReadOnlyCollection<string> workflowInstanceIds,
DateTime updatedOnUtc,
CancellationToken cancellationToken = default)
{
var updated = 0;
foreach (var workflowInstanceId in workflowInstanceIds)
{
if (!backing.Records.TryGetValue(workflowInstanceId, out var state))
{
continue;
}
backing.Records[workflowInstanceId] = state with
{
StaleAfterUtc = updatedOnUtc,
LastUpdatedOnUtc = updatedOnUtc,
};
updated++;
}
return Task.FromResult(updated);
}
public Task<int> DeleteAsync(
IReadOnlyCollection<string> workflowInstanceIds,
CancellationToken cancellationToken = default)
{
var deleted = 0;
foreach (var workflowInstanceId in workflowInstanceIds)
{
if (backing.Records.Remove(workflowInstanceId))
{
deleted++;
}
}
return Task.FromResult(deleted);
}
}
private sealed class PersistedWorkflowSignalBacking
{
private readonly object gate = new();
private readonly List<PersistedQueuedSignal> queuedSignals = [];
private readonly List<(WorkflowSignalEnvelope Signal, DateTime DueAtUtc)> scheduledSignals = [];
private readonly List<WorkflowSignalEnvelope> completedSignals = [];
private readonly List<WorkflowSignalEnvelope> deadLetterSignals = [];
public IReadOnlyCollection<(WorkflowSignalEnvelope Signal, DateTime DueAtUtc)> ScheduledSignals
{
get
{
lock (gate)
{
return scheduledSignals
.Select(x => (CloneEnvelope(x.Signal), x.DueAtUtc))
.ToArray();
}
}
}
public IReadOnlyCollection<WorkflowSignalEnvelope> CompletedSignals
{
get
{
lock (gate)
{
return completedSignals.Select(CloneEnvelope).ToArray();
}
}
}
public void Enqueue(WorkflowSignalEnvelope signal, int deliveryCount = 1)
{
lock (gate)
{
queuedSignals.Add(new PersistedQueuedSignal(CloneEnvelope(signal), deliveryCount));
}
}
public void Schedule(WorkflowSignalEnvelope signal, DateTime dueAtUtc)
{
lock (gate)
{
scheduledSignals.Add((CloneEnvelope(signal), dueAtUtc));
}
}
public PersistedQueuedSignal? TryReceive()
{
lock (gate)
{
if (queuedSignals.Count == 0)
{
return null;
}
var signal = queuedSignals[0];
queuedSignals.RemoveAt(0);
return signal;
}
}
public void Complete(PersistedQueuedSignal signal)
{
lock (gate)
{
completedSignals.Add(CloneEnvelope(signal.Envelope));
}
}
public void Abandon(PersistedQueuedSignal signal)
{
lock (gate)
{
queuedSignals.Add(signal with { DeliveryCount = signal.DeliveryCount + 1 });
}
}
public void DeadLetter(PersistedQueuedSignal signal)
{
lock (gate)
{
deadLetterSignals.Add(CloneEnvelope(signal.Envelope));
}
}
public void PromoteDueSignals(DateTime utcNow)
{
lock (gate)
{
var dueSignals = scheduledSignals
.Where(x => x.DueAtUtc <= utcNow)
.ToArray();
foreach (var dueSignal in dueSignals)
{
queuedSignals.Add(new PersistedQueuedSignal(CloneEnvelope(dueSignal.Signal), 1));
}
scheduledSignals.RemoveAll(x => x.DueAtUtc <= utcNow);
}
}
}
private sealed record PersistedQueuedSignal(WorkflowSignalEnvelope Envelope, int DeliveryCount);
private sealed class PersistedWorkflowSignalBus(
PersistedWorkflowSignalBacking backing) : IWorkflowSignalBus
{
public Task PublishAsync(WorkflowSignalEnvelope envelope, CancellationToken cancellationToken = default)
{
backing.Enqueue(envelope);
return Task.CompletedTask;
}
public Task PublishDeadLetterAsync(WorkflowSignalEnvelope envelope, CancellationToken cancellationToken = default)
{
backing.DeadLetter(new PersistedQueuedSignal(envelope, 1));
return Task.CompletedTask;
}
public Task<IWorkflowSignalLease?> ReceiveAsync(
string consumerName,
CancellationToken cancellationToken = default)
{
var signal = backing.TryReceive();
return Task.FromResult<IWorkflowSignalLease?>(
signal is null
? null
: new PersistedWorkflowSignalLease(backing, signal));
}
}
private sealed class PersistedWorkflowScheduleBus(
PersistedWorkflowSignalBacking backing) : IWorkflowScheduleBus
{
public Task ScheduleAsync(
WorkflowSignalEnvelope envelope,
DateTime dueAtUtc,
CancellationToken cancellationToken = default)
{
backing.Schedule(envelope, dueAtUtc);
return Task.CompletedTask;
}
}
private sealed class PersistedWorkflowSignalLease(
PersistedWorkflowSignalBacking backing,
PersistedQueuedSignal signal) : IWorkflowSignalLease
{
public WorkflowSignalEnvelope Envelope { get; } = CloneEnvelope(signal.Envelope);
public int DeliveryCount => signal.DeliveryCount;
public Task CompleteAsync(CancellationToken cancellationToken = default)
{
backing.Complete(signal);
return Task.CompletedTask;
}
public Task AbandonAsync(CancellationToken cancellationToken = default)
{
backing.Abandon(signal);
return Task.CompletedTask;
}
public Task DeadLetterAsync(CancellationToken cancellationToken = default)
{
backing.DeadLetter(signal);
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}
private sealed record RecoveryStartRequest
{
[WorkflowBusinessId]
[WorkflowBusinessReferencePart("policyId")]
public long SrPolicyId { get; init; }
}
private sealed class TimerRecoveryWorkflow : IDeclarativeWorkflow<RecoveryStartRequest>
{
public const string WorkflowNameValue = "TimerRecoveryWorkflow";
public const string WorkflowVersionValue = "1.0.0";
public string WorkflowName => WorkflowNameValue;
public string WorkflowVersion => WorkflowVersionValue;
public string DisplayName => "Timer Recovery Workflow";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<RecoveryStartRequest> Spec { get; } = WorkflowSpec.For<RecoveryStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")),
WorkflowExpr.Prop("timerFired", WorkflowExpr.Bool(false))))
.AddTask(
WorkflowHumanTask.For<RecoveryStartRequest>(
"Recovered Timer Review",
"RecoveredTimerReview",
"business/policies",
["DBA"])
.WithPayload(
WorkflowExpr.Obj(
WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase")),
WorkflowExpr.Prop("timerFired", WorkflowExpr.Path("state.timerFired"))))
.OnComplete(flow => flow.Complete()))
.StartWith(flow => flow
.Set("phase", "waiting")
.Wait("Wait Before Recovered Review", WorkflowExpr.String("00:05:00"))
.Set("timerFired", true)
.Set("phase", "after-timer")
.ActivateTask("Recovered Timer Review"))
.Build();
}
private sealed class ExternalSignalRecoveryWorkflow : IDeclarativeWorkflow<RecoveryStartRequest>
{
public const string WorkflowNameValue = "ExternalSignalRecoveryWorkflow";
public const string WorkflowVersionValue = "1.0.0";
public string WorkflowName => WorkflowNameValue;
public string WorkflowVersion => WorkflowVersionValue;
public string DisplayName => "External Signal Recovery Workflow";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<RecoveryStartRequest> Spec { get; } = WorkflowSpec.For<RecoveryStartRequest>()
.InitializeState(
WorkflowExpr.Obj(
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")),
WorkflowExpr.Prop("documentId", WorkflowExpr.Number(0L))))
.AddTask(
WorkflowHumanTask.For<RecoveryStartRequest>(
"Recovered External Review",
"RecoveredExternalReview",
"business/policies",
["DBA"])
.WithPayload(
WorkflowExpr.Obj(
WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase")),
WorkflowExpr.Prop("documentId", WorkflowExpr.Path("state.documentId"))))
.OnComplete(flow => flow.Complete()))
.StartWith(flow => flow
.Set("phase", "waiting-external")
.WaitForSignal("Wait For Upload", WorkflowExpr.String("documents-uploaded"), resultKey: "uploadSignal")
.Set("documentId", WorkflowExpr.Path("result.uploadSignal.documentId"))
.Set("phase", "after-external")
.ActivateTask("Recovered External Review"))
.Build();
}
private static WorkflowSignalEnvelope CloneEnvelope(WorkflowSignalEnvelope envelope)
{
return envelope with
{
Payload = envelope.Payload.ToDictionary(x => x.Key, x => x.Value.Clone(), StringComparer.OrdinalIgnoreCase),
};
}
}

View File

@@ -0,0 +1,434 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.DataStore.Oracle;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Engine.Services;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowRuntimeServiceTransactionTests
{
[Test]
public async Task ResumeSignalAsync_WhenRuntimeStateWriteConflicts_ShouldRollbackProjectionChanges()
{
using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
using var provider = CreateServiceProvider(connection, new ThrowOnSecondVersionRuntimeStateStore());
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = TransactionalSignalWorkflow.WorkflowNameValue,
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 810001L,
},
});
await runtimeService.ResumeSignalAsync(new WorkflowSignalEnvelope
{
SignalId = "resume-1",
WorkflowInstanceId = startResponse.WorkflowInstanceId,
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.ExternalSignal,
ExpectedVersion = 1,
WaitingToken = "external-wait",
});
var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
});
var openTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
Status = WorkflowTaskStatuses.Open,
});
instance.Instance.RuntimeStatus.Should().Be("WaitingForSignal");
ReadString(instance.WorkflowState["phase"]).Should().Be("waiting");
ReadLong(instance.RuntimeState!.State["version"]).Should().Be(1L);
openTasks.Tasks.Should().BeEmpty();
}
[Test]
public async Task ResumeSignalAsync_WhenRuntimeStateWriteSucceeds_ShouldCommitProjectionChanges()
{
using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
using var provider = CreateServiceProvider(connection, new InMemoryWorkflowRuntimeStateStore());
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = TransactionalSignalWorkflow.WorkflowNameValue,
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 810002L,
},
});
await runtimeService.ResumeSignalAsync(new WorkflowSignalEnvelope
{
SignalId = "resume-2",
WorkflowInstanceId = startResponse.WorkflowInstanceId,
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.ExternalSignal,
ExpectedVersion = 1,
WaitingToken = "external-wait",
});
var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
});
var openTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = startResponse.WorkflowInstanceId,
Status = WorkflowTaskStatuses.Open,
});
instance.Instance.RuntimeStatus.Should().Be("WaitingForTask");
ReadString(instance.WorkflowState["phase"]).Should().Be("resumed");
ReadLong(instance.RuntimeState!.State["version"]).Should().Be(2L);
openTasks.Tasks.Should().ContainSingle();
openTasks.Tasks.Single().TaskName.Should().Be("Review Documents");
}
private static ServiceProvider CreateServiceProvider(
SqliteConnection connection,
IWorkflowRuntimeStateStore runtimeStateStore)
{
var services = new ServiceCollection();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["WorkflowRetention:OpenStaleAfterDays"] = "30",
["WorkflowRetention:CompletedPurgeAfterDays"] = "180",
})
.Build();
services.AddLogging();
services.AddWorkflowRegistration<TransactionalSignalWorkflow, TransactionalSignalStartRequest>();
services.AddWorkflowEngineCoreServices(configuration);
services.AddDbContext<WorkflowDbContext>(options => options.UseSqlite(connection));
services.AddScoped<IWorkflowRuntimeOrchestrator, TransactionalSignalRuntimeOrchestrator>();
services.AddSingleton<IWorkflowRuntimeStateStore>(runtimeStateStore);
services.AddSingleton<NoopWorkflowSignalBus>();
services.AddSingleton<NoopWorkflowScheduleBus>();
services.AddScoped<IWorkflowSignalBus>(serviceProvider => serviceProvider.GetRequiredService<NoopWorkflowSignalBus>());
services.AddScoped<IWorkflowScheduleBus>(serviceProvider => serviceProvider.GetRequiredService<NoopWorkflowScheduleBus>());
var provider = services.BuildServiceProvider();
// ServiceProviderAccessor.Initialize(provider); // TODO: Requires Serdica-specific types
using var scope = provider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<WorkflowDbContext>();
InitializeProjectionSchema(dbContext);
return provider;
}
private static void InitializeProjectionSchema(WorkflowDbContext dbContext)
{
dbContext.Database.ExecuteSqlRaw(
"""
CREATE TABLE IF NOT EXISTS WF_INSTANCES (
WF_INSTANCE_PK INTEGER NOT NULL PRIMARY KEY,
WF_INSTANCE_ID TEXT NOT NULL UNIQUE,
WF_NAME TEXT NOT NULL,
WF_VERSION TEXT NOT NULL,
BUSINESS_ID TEXT NULL,
BUSINESS_REFERENCE_JSON TEXT NULL,
STATUS TEXT NOT NULL,
STATE_JSON TEXT NOT NULL,
CREATED_ON_UTC TEXT NOT NULL,
COMPLETED_ON_UTC TEXT NULL,
STALE_AFTER_UTC TEXT NULL,
PURGE_AFTER_UTC TEXT NULL
);
""");
dbContext.Database.ExecuteSqlRaw(
"""
CREATE TABLE IF NOT EXISTS WF_TASKS (
WF_TASK_PK INTEGER NOT NULL PRIMARY KEY,
WF_TASK_ID TEXT NOT NULL UNIQUE,
WF_INSTANCE_ID TEXT NOT NULL,
WF_NAME TEXT NOT NULL,
WF_VERSION TEXT NOT NULL,
TASK_NAME TEXT NOT NULL,
TASK_TYPE TEXT NOT NULL,
ROUTE TEXT NOT NULL,
BUSINESS_ID TEXT NULL,
BUSINESS_REFERENCE_JSON TEXT NULL,
ASSIGNEE TEXT NULL,
STATUS TEXT NOT NULL,
WORKFLOW_ROLES_JSON TEXT NOT NULL,
TASK_ROLES_JSON TEXT NOT NULL,
RUNTIME_ROLES_JSON TEXT NOT NULL,
EFFECTIVE_ROLES_JSON TEXT NOT NULL,
PAYLOAD_JSON TEXT NOT NULL,
CREATED_ON_UTC TEXT NOT NULL,
COMPLETED_ON_UTC TEXT NULL,
STALE_AFTER_UTC TEXT NULL,
PURGE_AFTER_UTC TEXT NULL
);
""");
dbContext.Database.ExecuteSqlRaw(
"""
CREATE TABLE IF NOT EXISTS WF_TASK_EVENTS (
WF_TASK_EVENT_PK INTEGER NOT NULL PRIMARY KEY,
WF_TASK_ID TEXT NOT NULL,
EVENT_TYPE TEXT NOT NULL,
ACTOR_ID TEXT NULL,
PAYLOAD_JSON TEXT NOT NULL,
CREATED_ON_UTC TEXT NOT NULL
);
""");
}
private static string ReadString(object? value)
{
return value switch
{
string text => text,
JsonElement jsonElement => jsonElement.Get<string>(),
_ => throw new AssertionException("Value is not a string."),
};
}
private static long ReadLong(object? value)
{
return value switch
{
long number => number,
int number => number,
JsonElement jsonElement when jsonElement.TryGetInt64(out var number) => number,
_ => throw new AssertionException("Value is not an integer."),
};
}
private sealed class TransactionalSignalRuntimeOrchestrator : IWorkflowRuntimeOrchestrator
{
public Task<WorkflowRuntimeExecutionResult> StartAsync(
WorkflowRegistration registration,
WorkflowDefinitionDescriptor definition,
WorkflowBusinessReference? businessReference,
StartWorkflowRequest request,
object startRequest,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new WorkflowRuntimeExecutionResult
{
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
RuntimeInstanceId = "wf-transaction",
RuntimeStatus = "WaitingForSignal",
InstanceStatus = WorkflowInstanceStatuses.Open,
BusinessReference = businessReference,
WorkflowState = new Dictionary<string, JsonElement>
{
["phase"] = JsonSerializer.SerializeToElement("waiting"),
},
RuntimeState = CreateRuntimeState(
version: 1,
phase: "waiting",
waitingToken: "external-wait"),
});
}
public Task<WorkflowRuntimeExecutionResult> CompleteAsync(
WorkflowRegistration registration,
WorkflowDefinitionDescriptor definition,
WorkflowTaskExecutionContext context,
CancellationToken cancellationToken = default)
{
throw new NotSupportedException();
}
public Task<WorkflowRuntimeExecutionResult> ResumeAsync(
WorkflowRegistration registration,
WorkflowDefinitionDescriptor definition,
WorkflowSignalExecutionContext context,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new WorkflowRuntimeExecutionResult
{
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
RuntimeInstanceId = context.RuntimeState.RuntimeInstanceId,
RuntimeStatus = "WaitingForTask",
InstanceStatus = WorkflowInstanceStatuses.Open,
BusinessReference = context.RuntimeState.BusinessReference,
WorkflowState = new Dictionary<string, JsonElement>
{
["phase"] = JsonSerializer.SerializeToElement("resumed"),
},
RuntimeState = CreateRuntimeState(
version: 2,
phase: "resumed"),
Tasks =
[
new WorkflowExecutionTaskPlan
{
TaskName = "Review Documents",
TaskType = "ReviewDocuments",
Route = "workflow/review",
},
],
});
}
private static Dictionary<string, object?> CreateRuntimeState(
long version,
string phase,
string? waitingToken = null)
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["engineSchemaVersion"] = 1,
["version"] = version,
["status"] = WorkflowInstanceStatuses.Open,
["workflowState"] = new Dictionary<string, object?>
{
["phase"] = phase,
},
["waiting"] = waitingToken is null
? null
: new Dictionary<string, object?>
{
["kind"] = "Signal",
["signalType"] = WorkflowSignalTypes.ExternalSignal,
["token"] = waitingToken,
["resumeState"] = new Dictionary<string, object?>(),
},
};
}
}
private sealed class ThrowOnSecondVersionRuntimeStateStore : IWorkflowRuntimeStateStore
{
private WorkflowRuntimeStateRecord? state;
public Task UpsertAsync(
WorkflowRuntimeStateRecord newState,
CancellationToken cancellationToken = default)
{
if (newState.Version > 1)
{
throw new WorkflowRuntimeStateConcurrencyException(
newState.WorkflowInstanceId,
newState.Version,
state?.Version ?? 0);
}
state = newState;
return Task.CompletedTask;
}
public Task<WorkflowRuntimeStateRecord?> GetAsync(
string workflowInstanceId,
CancellationToken cancellationToken = default)
{
return Task.FromResult(
state is not null && string.Equals(state.WorkflowInstanceId, workflowInstanceId, StringComparison.OrdinalIgnoreCase)
? state
: null);
}
public Task<IReadOnlyCollection<WorkflowRuntimeStateRecord>> GetManyAsync(
IReadOnlyCollection<string> workflowInstanceIds,
CancellationToken cancellationToken = default)
{
if (state is null || !workflowInstanceIds.Contains(state.WorkflowInstanceId, StringComparer.OrdinalIgnoreCase))
{
return Task.FromResult<IReadOnlyCollection<WorkflowRuntimeStateRecord>>([]);
}
return Task.FromResult<IReadOnlyCollection<WorkflowRuntimeStateRecord>>([state]);
}
public Task<int> MarkStaleAsync(
IReadOnlyCollection<string> workflowInstanceIds,
DateTime updatedOnUtc,
CancellationToken cancellationToken = default)
{
return Task.FromResult(0);
}
public Task<int> DeleteAsync(
IReadOnlyCollection<string> workflowInstanceIds,
CancellationToken cancellationToken = default)
{
if (state is not null && workflowInstanceIds.Contains(state.WorkflowInstanceId, StringComparer.OrdinalIgnoreCase))
{
state = null;
return Task.FromResult(1);
}
return Task.FromResult(0);
}
}
private sealed class NoopWorkflowSignalBus : IWorkflowSignalBus
{
public Task PublishAsync(WorkflowSignalEnvelope envelope, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task PublishDeadLetterAsync(WorkflowSignalEnvelope envelope, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task<IWorkflowSignalLease?> ReceiveAsync(string consumerName, CancellationToken cancellationToken = default)
{
return Task.FromResult<IWorkflowSignalLease?>(null);
}
}
private sealed class NoopWorkflowScheduleBus : IWorkflowScheduleBus
{
public Task ScheduleAsync(WorkflowSignalEnvelope envelope, DateTime dueAtUtc, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
private sealed record TransactionalSignalStartRequest
{
[WorkflowBusinessId]
[WorkflowBusinessReferencePart("policyId")]
public long SrPolicyId { get; init; }
}
private sealed class TransactionalSignalWorkflow : ISerdicaWorkflow<TransactionalSignalStartRequest>
{
public const string WorkflowNameValue = "TransactionalSignalWorkflow";
public string WorkflowName => WorkflowNameValue;
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Transactional Signal Workflow";
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => [];
}
}

View File

@@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Scheduling;
using StellaOps.Workflow.Engine.Signaling;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowSignalBridgeTests
{
[Test]
public async Task SignalBusBridge_WhenDriverIsNativeTransactional_ShouldPersistAndNotifyDriver()
{
var store = new RecordingSignalStore();
var driver = new RecordingSignalDriver(WorkflowSignalDriverDispatchMode.NativeTransactional);
var wakeOutbox = new RecordingWakeOutbox();
var mutationScopeAccessor = new RecordingMutationScopeAccessor();
var bus = new WorkflowSignalBusBridge(store, driver, wakeOutbox, mutationScopeAccessor);
var envelope = CreateEnvelope("native-1");
await bus.PublishAsync(envelope);
store.Published.Should().ContainSingle().Which.SignalId.Should().Be(envelope.SignalId);
driver.Notifications.Should().ContainSingle().Which.SignalId.Should().Be(envelope.SignalId);
wakeOutbox.Notifications.Should().BeEmpty();
}
[Test]
public async Task SignalBusBridge_WhenDriverUsesPostCommitNotification_ShouldPersistAndRegisterPostCommitWake()
{
var store = new RecordingSignalStore();
var driver = new RecordingSignalDriver(WorkflowSignalDriverDispatchMode.PostCommitNotification);
var wakeOutbox = new RecordingWakeOutbox();
var mutationScope = new RecordingMutationScope();
var mutationScopeAccessor = new RecordingMutationScopeAccessor
{
Current = mutationScope,
};
var bus = new WorkflowSignalBusBridge(store, driver, wakeOutbox, mutationScopeAccessor);
var envelope = CreateEnvelope("post-commit-1");
await bus.PublishAsync(envelope);
store.Published.Should().ContainSingle().Which.SignalId.Should().Be(envelope.SignalId);
driver.Notifications.Should().BeEmpty();
wakeOutbox.Notifications.Should().BeEmpty();
mutationScope.PostCommitActions.Should().ContainSingle();
await mutationScope.CommitAsync();
driver.Notifications.Should().ContainSingle().Which.SignalId.Should().Be(envelope.SignalId);
}
[Test]
public async Task SignalBusBridge_WhenDriverUsesWakeOutbox_ShouldPersistAndEnqueueWakeNotification()
{
var store = new RecordingSignalStore();
var driver = new RecordingSignalDriver(WorkflowSignalDriverDispatchMode.WakeOutbox);
var wakeOutbox = new RecordingWakeOutbox();
var mutationScopeAccessor = new RecordingMutationScopeAccessor();
var bus = new WorkflowSignalBusBridge(store, driver, wakeOutbox, mutationScopeAccessor);
var envelope = CreateEnvelope("outbox-1") with
{
DueAtUtc = DateTime.UtcNow.AddMinutes(1),
};
await bus.PublishAsync(envelope);
store.Published.Should().ContainSingle().Which.SignalId.Should().Be(envelope.SignalId);
driver.Notifications.Should().BeEmpty();
wakeOutbox.Notifications.Should().ContainSingle().Which.DueAtUtc.Should().Be(envelope.DueAtUtc);
}
[Test]
public async Task SignalBusBridge_PublishDeadLetterAsync_ShouldDelegateToSignalStore()
{
var store = new RecordingSignalStore();
var driver = new RecordingSignalDriver(WorkflowSignalDriverDispatchMode.NativeTransactional);
var wakeOutbox = new RecordingWakeOutbox();
var mutationScopeAccessor = new RecordingMutationScopeAccessor();
var bus = new WorkflowSignalBusBridge(store, driver, wakeOutbox, mutationScopeAccessor);
var envelope = CreateEnvelope("dead-letter-1");
await bus.PublishDeadLetterAsync(envelope);
store.DeadLetters.Should().ContainSingle().Which.SignalId.Should().Be(envelope.SignalId);
driver.Notifications.Should().BeEmpty();
wakeOutbox.Notifications.Should().BeEmpty();
}
[Test]
public async Task SignalBusBridge_ReceiveAsync_ShouldDelegateToDriver()
{
var store = new RecordingSignalStore();
var expectedLease = new RecordingSignalLease(CreateEnvelope("receive-1"));
var driver = new RecordingSignalDriver(WorkflowSignalDriverDispatchMode.NativeTransactional)
{
NextLease = expectedLease,
};
var wakeOutbox = new RecordingWakeOutbox();
var mutationScopeAccessor = new RecordingMutationScopeAccessor();
var bus = new WorkflowSignalBusBridge(store, driver, wakeOutbox, mutationScopeAccessor);
await using var lease = await bus.ReceiveAsync("consumer-a");
lease.Should().BeSameAs(expectedLease);
driver.ConsumerNames.Should().ContainSingle().Which.Should().Be("consumer-a");
}
[Test]
public async Task ScheduleBusBridge_ShouldDelegateToSignalScheduler()
{
var scheduler = new RecordingSignalScheduler();
var bus = new WorkflowScheduleBusBridge(scheduler);
var envelope = CreateEnvelope("schedule-1");
var dueAtUtc = DateTime.UtcNow.AddSeconds(30);
await bus.ScheduleAsync(envelope, dueAtUtc);
scheduler.Scheduled.Should().ContainSingle();
scheduler.Scheduled[0].Envelope.SignalId.Should().Be(envelope.SignalId);
scheduler.Scheduled[0].DueAtUtc.Should().Be(dueAtUtc);
}
private static WorkflowSignalEnvelope CreateEnvelope(string signalId)
{
return new WorkflowSignalEnvelope
{
SignalId = signalId,
WorkflowInstanceId = $"wf-{signalId}",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.ExternalSignal,
ExpectedVersion = 1,
WaitingToken = "wait-1",
Payload = new Dictionary<string, JsonElement>
{
["name"] = JsonSerializer.SerializeToElement("documents-uploaded"),
},
};
}
private sealed class RecordingSignalStore : IWorkflowSignalStore
{
public List<WorkflowSignalEnvelope> Published { get; } = [];
public List<WorkflowSignalEnvelope> DeadLetters { get; } = [];
public Task PublishAsync(
WorkflowSignalEnvelope envelope,
CancellationToken cancellationToken = default)
{
Published.Add(envelope);
return Task.CompletedTask;
}
public Task PublishDeadLetterAsync(
WorkflowSignalEnvelope envelope,
CancellationToken cancellationToken = default)
{
DeadLetters.Add(envelope);
return Task.CompletedTask;
}
}
private sealed class RecordingSignalDriver(WorkflowSignalDriverDispatchMode dispatchMode) : IWorkflowSignalDriver
{
public string DriverName => "Recording";
public WorkflowSignalDriverDispatchMode DispatchMode => dispatchMode;
public List<WorkflowSignalWakeNotification> Notifications { get; } = [];
public List<string> ConsumerNames { get; } = [];
public IWorkflowSignalLease? NextLease { get; init; }
public Task NotifySignalAvailableAsync(
WorkflowSignalWakeNotification notification,
CancellationToken cancellationToken = default)
{
Notifications.Add(notification);
return Task.CompletedTask;
}
public Task<IWorkflowSignalLease?> ReceiveAsync(
string consumerName,
CancellationToken cancellationToken = default)
{
ConsumerNames.Add(consumerName);
return Task.FromResult(NextLease);
}
}
private sealed class RecordingWakeOutbox : IWorkflowWakeOutbox
{
public List<WorkflowSignalWakeNotification> Notifications { get; } = [];
public Task EnqueueAsync(
WorkflowSignalWakeNotification notification,
CancellationToken cancellationToken = default)
{
Notifications.Add(notification);
return Task.CompletedTask;
}
}
private sealed class RecordingMutationScopeAccessor : IWorkflowMutationScopeAccessor
{
public IWorkflowMutationScope? Current { get; set; }
}
private sealed class RecordingMutationScope : IWorkflowMutationScope
{
public List<Func<CancellationToken, Task>> PostCommitActions { get; } = [];
public void RegisterPostCommitAction(Func<CancellationToken, Task> action)
{
PostCommitActions.Add(action);
}
public async Task CommitAsync(CancellationToken cancellationToken = default)
{
foreach (var action in PostCommitActions)
{
await action(cancellationToken);
}
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}
private sealed class RecordingSignalScheduler : IWorkflowSignalScheduler
{
public List<(WorkflowSignalEnvelope Envelope, DateTime DueAtUtc)> Scheduled { get; } = [];
public Task ScheduleAsync(
WorkflowSignalEnvelope envelope,
DateTime dueAtUtc,
CancellationToken cancellationToken = default)
{
Scheduled.Add((envelope, dueAtUtc));
return Task.CompletedTask;
}
}
private sealed class RecordingSignalLease(WorkflowSignalEnvelope envelope) : IWorkflowSignalLease
{
public WorkflowSignalEnvelope Envelope { get; } = envelope;
public int DeliveryCount => 1;
public Task CompleteAsync(CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task AbandonAsync(CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task DeadLetterAsync(CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}
}

View File

@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Signaling;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowSignalEnvelopeSerializerTests
{
[Test]
public void SerializeAndDeserialize_WhenEnvelopeContainsPayload_ShouldRoundTrip()
{
var serializer = new WorkflowSignalEnvelopeSerializer();
var envelope = new WorkflowSignalEnvelope
{
SignalId = "sig-1",
WorkflowInstanceId = "wf-1",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.TaskCompleted,
ExpectedVersion = 4,
WaitingToken = "wait-1",
OccurredAtUtc = new DateTime(2026, 3, 16, 10, 0, 0, DateTimeKind.Utc),
DueAtUtc = new DateTime(2026, 3, 16, 10, 5, 0, DateTimeKind.Utc),
Payload = new Dictionary<string, JsonElement>
{
["approved"] = JsonSerializer.SerializeToElement(true),
["actorId"] = JsonSerializer.SerializeToElement("user-1"),
},
};
var bytes = serializer.Serialize(envelope);
var roundTrip = serializer.Deserialize(bytes);
roundTrip.SignalId.Should().Be(envelope.SignalId);
roundTrip.WorkflowInstanceId.Should().Be(envelope.WorkflowInstanceId);
roundTrip.RuntimeProvider.Should().Be(envelope.RuntimeProvider);
roundTrip.SignalType.Should().Be(envelope.SignalType);
roundTrip.ExpectedVersion.Should().Be(envelope.ExpectedVersion);
roundTrip.WaitingToken.Should().Be(envelope.WaitingToken);
roundTrip.OccurredAtUtc.Should().Be(envelope.OccurredAtUtc);
roundTrip.DueAtUtc.Should().Be(envelope.DueAtUtc);
roundTrip.Payload.Should().ContainKey("approved");
roundTrip.Payload["actorId"].GetString().Should().Be("user-1");
}
[Test]
public void Deserialize_WhenPayloadIsInvalid_ShouldThrow()
{
var serializer = new WorkflowSignalEnvelopeSerializer();
var act = () => serializer.Deserialize(global::System.Text.Encoding.UTF8.GetBytes("{\"broken\":"));
act.Should().Throw<Exception>();
}
}

View File

@@ -0,0 +1,353 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Engine.Hosting;
using StellaOps.Workflow.Engine.Signaling;
using StellaOps.Workflow.Signaling.OracleAq;
using WorkflowSignalEnvelopeSerializer = StellaOps.Workflow.Engine.Signaling.WorkflowSignalEnvelopeSerializer;
using OracleAqSerializer = StellaOps.Workflow.Signaling.OracleAq.WorkflowSignalEnvelopeSerializer;
using OracleAqOptions = StellaOps.Workflow.Signaling.OracleAq.WorkflowAqOptions;
using StellaOps.Workflow.Engine.Services;
using Microsoft.Extensions.Options;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowSignalOperationalTests
{
[Test]
public async Task WorkflowSignalDeadLetterService_GetMessagesAsync_ShouldReturnReadableAndUnreadableMessages()
{
var serializer = new WorkflowSignalEnvelopeSerializer();
var transport = new FakeOracleAqTransport
{
BrowseMessages =
[
new OracleAqDequeuedMessage
{
Payload = serializer.Serialize(new WorkflowSignalEnvelope
{
SignalId = "sig-readable",
WorkflowInstanceId = "wf-1",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.ExternalSignal,
ExpectedVersion = 3,
WaitingToken = "wait-1",
Payload = new Dictionary<string, JsonElement>
{
["signalName"] = JsonSerializer.SerializeToElement("documents-uploaded"),
},
}),
Correlation = "sig-readable",
DequeueAttempts = 4,
EnqueueTimeUtc = new DateTime(2026, 3, 16, 10, 0, 0, DateTimeKind.Utc),
},
new OracleAqDequeuedMessage
{
Payload = global::System.Text.Encoding.UTF8.GetBytes("{\"broken\":"),
Correlation = "sig-broken",
DequeueAttempts = 7,
EnqueueTimeUtc = new DateTime(2026, 3, 16, 11, 0, 0, DateTimeKind.Utc),
},
],
};
using var provider = CreateProvider(transport);
var service = provider.GetRequiredService<WorkflowSignalDeadLetterService>();
var response = await service.GetMessagesAsync(new WorkflowSignalDeadLettersGetRequest
{
MaxMessages = 10,
IncludeRawPayload = true,
});
response.Messages.Should().HaveCount(2);
response.Messages.Should().Contain(x =>
x.SignalId == "sig-readable"
&& x.WorkflowInstanceId == "wf-1"
&& x.SignalType == WorkflowSignalTypes.ExternalSignal
&& x.IsEnvelopeReadable
&& x.DeliveryCount == 4);
response.Messages.Should().Contain(x =>
x.SignalId == "sig-broken"
&& !x.IsEnvelopeReadable
&& x.ReadError != null
&& x.RawPayloadBase64 != null
&& x.DeliveryCount == 7);
}
[Test]
public async Task WorkflowSignalDeadLetterService_GetMessagesAsync_WhenWorkflowFilterProvided_ShouldReturnMatchingMessagesOnly()
{
var serializer = new WorkflowSignalEnvelopeSerializer();
var transport = new FakeOracleAqTransport
{
BrowseMessages =
[
BuildDeadLetter(serializer, "sig-1", "wf-left", WorkflowSignalTypes.ExternalSignal),
BuildDeadLetter(serializer, "sig-2", "wf-right", WorkflowSignalTypes.TimerDue),
],
};
using var provider = CreateProvider(transport);
var service = provider.GetRequiredService<WorkflowSignalDeadLetterService>();
var response = await service.GetMessagesAsync(new WorkflowSignalDeadLettersGetRequest
{
WorkflowInstanceId = "wf-right",
SignalType = WorkflowSignalTypes.TimerDue,
});
response.Messages.Should().ContainSingle();
response.Messages.Single().SignalId.Should().Be("sig-2");
}
[Test]
public async Task WorkflowSignalDeadLetterService_ReplayAsync_ShouldRequeueMessageAndCommitDeadLetterLease()
{
var serializer = new WorkflowSignalEnvelopeSerializer();
var replayMessage = BuildDeadLetter(serializer, "sig-replay", "wf-replay", WorkflowSignalTypes.SubWorkflowCompleted);
var transport = new FakeOracleAqTransport
{
ReplayLease = new FakeOracleAqMessageLease(replayMessage),
};
using var provider = CreateProvider(transport);
var service = provider.GetRequiredService<WorkflowSignalDeadLetterService>();
var response = await service.ReplayAsync(new WorkflowSignalDeadLetterReplayRequest
{
SignalId = "sig-replay",
});
response.Replayed.Should().BeTrue();
response.SignalId.Should().Be("sig-replay");
response.WorkflowInstanceId.Should().Be("wf-replay");
response.SignalType.Should().Be(WorkflowSignalTypes.SubWorkflowCompleted);
response.WasEnvelopeReadable.Should().BeTrue();
transport.EnqueueRequests.Should().ContainSingle();
transport.EnqueueRequests[0].QueueName.Should().Be("WF_SIGNAL_Q");
transport.EnqueueRequests[0].ExceptionQueueName.Should().Be("WF_DLQ_Q");
transport.EnqueueRequests[0].Correlation.Should().Be("sig-replay");
transport.ReplayLease!.CommitCalls.Should().Be(1);
transport.LastDequeueRequest!.Correlation.Should().Be("sig-replay");
}
[Test]
public async Task WorkflowSignalPumpTelemetryService_GetStats_ShouldAggregateCountersBySignalType()
{
var telemetry = new WorkflowSignalPumpTelemetryService();
var externalSignal = BuildEnvelope("sig-1", "wf-1", WorkflowSignalTypes.ExternalSignal);
var timerSignal = BuildEnvelope("sig-2", "wf-2", WorkflowSignalTypes.TimerDue);
telemetry.RecordEmptyPoll("workflow-service");
telemetry.RecordProcessed("workflow-service", externalSignal, 1, TimeSpan.FromMilliseconds(12));
telemetry.RecordFailure("workflow-service", externalSignal, 2, TimeSpan.FromMilliseconds(18), new InvalidOperationException("boom"));
telemetry.RecordDeadLetter("workflow-service", timerSignal, 5, TimeSpan.FromMilliseconds(22), "poison");
var response = telemetry.GetStats();
response.Stats.EmptyPollCount.Should().Be(1);
response.Stats.ProcessedCount.Should().Be(1);
response.Stats.FailureCount.Should().Be(1);
response.Stats.DeadLetterCount.Should().Be(1);
response.Stats.SignalsByType.Should().Contain(x =>
x.SignalType == WorkflowSignalTypes.ExternalSignal
&& x.ProcessedCount == 1
&& x.FailureCount == 1
&& x.DeadLetterCount == 0);
response.Stats.SignalsByType.Should().Contain(x =>
x.SignalType == WorkflowSignalTypes.TimerDue
&& x.ProcessedCount == 0
&& x.FailureCount == 0
&& x.DeadLetterCount == 1);
response.Stats.LastSuccess!.SignalId.Should().Be("sig-1");
response.Stats.LastFailure!.SignalId.Should().Be("sig-1");
response.Stats.LastDeadLetter!.SignalId.Should().Be("sig-2");
}
// TODO: Requires Serdica endpoints (WorkflowSignalDeadLettersGetEndpoint, WorkflowSignalDeadLetterReplayEndpoint, WorkflowSignalPumpStatsGetEndpoint)
// These endpoint classes were replaced by ASP.NET minimal API endpoints in StellaOps.Workflow.WebService.
// [Test]
// public async Task WorkflowSignalOperationalEndpoints_ShouldReturnDeadLettersReplayAndStats()
// {
// var serializer = new WorkflowSignalEnvelopeSerializer();
// var transport = new FakeOracleAqTransport
// {
// BrowseMessages =
// [
// BuildDeadLetter(serializer, "sig-endpoint", "wf-endpoint", WorkflowSignalTypes.ExternalSignal),
// ],
// ReplayLease = new FakeOracleAqMessageLease(
// BuildDeadLetter(serializer, "sig-endpoint", "wf-endpoint", WorkflowSignalTypes.ExternalSignal)),
// };
// using var provider = CreateProvider(transport);
// var deadLetterService = provider.GetRequiredService<WorkflowSignalDeadLetterService>();
// var telemetryService = provider.GetRequiredService<WorkflowSignalPumpTelemetryService>();
// telemetryService.RecordProcessed(
// "workflow-service",
// BuildEnvelope("sig-stats", "wf-stats", WorkflowSignalTypes.TimerDue),
// 1,
// TimeSpan.FromMilliseconds(5));
//
// var listEndpoint = new WorkflowSignalDeadLettersGetEndpoint(deadLetterService);
// var replayEndpoint = new WorkflowSignalDeadLetterReplayEndpoint(deadLetterService);
// var statsEndpoint = new WorkflowSignalPumpStatsGetEndpoint(telemetryService);
//
// var listResponse = await listEndpoint.ConsumeAsync(new WorkflowSignalDeadLettersGetRequest());
// var replayResponse = await replayEndpoint.ConsumeAsync(new WorkflowSignalDeadLetterReplayRequest
// {
// SignalId = "sig-endpoint",
// });
// var statsResponse = await statsEndpoint.ConsumeAsync();
//
// listResponse.Messages.Should().ContainSingle(x => x.SignalId == "sig-endpoint");
// replayResponse.Replayed.Should().BeTrue();
// statsResponse.Stats.ProcessedCount.Should().Be(1);
// statsResponse.Stats.LastSuccess!.SignalId.Should().Be("sig-stats");
// }
private static ServiceProvider CreateProvider(FakeOracleAqTransport transport)
{
var services = new ServiceCollection();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["WorkflowAq:SignalQueueName"] = "WF_SIGNAL_Q",
["WorkflowAq:DeadLetterQueueName"] = "WF_DLQ_Q",
["WorkflowAq:ConsumerName"] = "workflow-service",
})
.Build();
services.AddLogging();
services.AddWorkflowEngineCoreServices(configuration);
services.Configure<OracleAqOptions>(configuration.GetSection(OracleAqOptions.SectionName));
services.Replace(ServiceDescriptor.Scoped<IOracleAqTransport>(_ => transport));
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalDeadLetterStore>(sp =>
new OracleAqWorkflowSignalDeadLetterStore(
sp.GetRequiredService<IOracleAqTransport>(),
new OracleAqSerializer(),
sp.GetRequiredService<IOptions<OracleAqOptions>>())));
return services.BuildServiceProvider();
}
private static OracleAqDequeuedMessage BuildDeadLetter(
WorkflowSignalEnvelopeSerializer serializer,
string signalId,
string workflowInstanceId,
string signalType)
{
return new OracleAqDequeuedMessage
{
Payload = serializer.Serialize(BuildEnvelope(signalId, workflowInstanceId, signalType)),
Correlation = signalId,
DequeueAttempts = 3,
EnqueueTimeUtc = new DateTime(2026, 3, 16, 12, 0, 0, DateTimeKind.Utc),
};
}
private static WorkflowSignalEnvelope BuildEnvelope(
string signalId,
string workflowInstanceId,
string signalType)
{
return new WorkflowSignalEnvelope
{
SignalId = signalId,
WorkflowInstanceId = workflowInstanceId,
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = signalType,
ExpectedVersion = 2,
WaitingToken = "wait-1",
Payload = new Dictionary<string, JsonElement>
{
["signalName"] = JsonSerializer.SerializeToElement("documents-uploaded"),
},
};
}
private sealed class FakeOracleAqTransport : IOracleAqTransport
{
public IReadOnlyCollection<OracleAqDequeuedMessage> BrowseMessages { get; init; } = [];
public FakeOracleAqMessageLease? ReplayLease { get; init; }
public OracleAqDequeueRequest? LastDequeueRequest { get; private set; }
public List<OracleAqEnqueueRequest> EnqueueRequests { get; } = [];
public Task EnqueueAsync(OracleAqEnqueueRequest request, CancellationToken cancellationToken = default)
{
EnqueueRequests.Add(request);
return Task.CompletedTask;
}
public Task<IReadOnlyCollection<OracleAqDequeuedMessage>> BrowseAsync(
OracleAqBrowseRequest request,
CancellationToken cancellationToken = default)
{
var filtered = BrowseMessages
.Where(x => string.IsNullOrWhiteSpace(request.Correlation)
|| string.Equals(x.Correlation, request.Correlation, StringComparison.OrdinalIgnoreCase))
.Take(request.MaxMessages)
.ToArray();
return Task.FromResult<IReadOnlyCollection<OracleAqDequeuedMessage>>(filtered);
}
public Task<IOracleAqMessageLease?> DequeueAsync(
OracleAqDequeueRequest request,
CancellationToken cancellationToken = default)
{
LastDequeueRequest = request;
return Task.FromResult<IOracleAqMessageLease?>(
ReplayLease is not null
&& (string.IsNullOrWhiteSpace(request.Correlation)
|| string.Equals(ReplayLease.Message.Correlation, request.Correlation, StringComparison.OrdinalIgnoreCase))
? ReplayLease
: null);
}
}
private sealed class FakeOracleAqMessageLease(OracleAqDequeuedMessage message) : IOracleAqMessageLease
{
public OracleAqDequeuedMessage Message { get; } = message;
public int CommitCalls { get; private set; }
public int RollbackCalls { get; private set; }
public int DeadLetterCalls { get; private set; }
public Task CommitAsync(CancellationToken cancellationToken = default)
{
CommitCalls++;
return Task.CompletedTask;
}
public Task RollbackAsync(CancellationToken cancellationToken = default)
{
RollbackCalls++;
return Task.CompletedTask;
}
public Task DeadLetterAsync(string queueName, CancellationToken cancellationToken = default)
{
DeadLetterCalls++;
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}
}

View File

@@ -0,0 +1,178 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Engine.Signaling;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowSignalProcessorTests
{
[Test]
public async Task ProcessAsync_WhenInternalContinueSignalProvided_ShouldDispatchStartWorkflowRequest()
{
var dispatcher = new FakeWorkflowSignalCommandDispatcher();
var processor = new WorkflowSignalProcessor(dispatcher);
var request = new StartWorkflowRequest
{
WorkflowName = "ApproveApplication",
WorkflowVersion = "1.0.0",
Payload = new Dictionary<string, object?>
{
["srPolicyId"] = 1200345M,
},
};
await processor.ProcessAsync(
new WorkflowSignalEnvelope
{
SignalId = "sig-1",
WorkflowInstanceId = "wf-1",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.InternalContinue,
ExpectedVersion = 0,
Payload = new Dictionary<string, JsonElement>
{
[WorkflowSignalPayloadKeys.StartWorkflowRequestPayloadKey] = JsonSerializer.SerializeToElement(request),
},
},
CancellationToken.None);
dispatcher.Requests.Should().ContainSingle();
dispatcher.Requests[0].WorkflowName.Should().Be("ApproveApplication");
dispatcher.Requests[0].Payload.Should().ContainKey("srPolicyId");
}
[Test]
public async Task ProcessAsync_WhenContinuationPayloadMissing_ShouldThrow()
{
var processor = new WorkflowSignalProcessor(new FakeWorkflowSignalCommandDispatcher());
var act = async () => await processor.ProcessAsync(
new WorkflowSignalEnvelope
{
SignalId = "sig-1",
WorkflowInstanceId = "wf-1",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.InternalContinue,
ExpectedVersion = 0,
},
CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
}
[Test]
public async Task ProcessAsync_WhenSignalTypeIsUnsupported_ShouldThrow()
{
var processor = new WorkflowSignalProcessor(new FakeWorkflowSignalCommandDispatcher());
var act = async () => await processor.ProcessAsync(
new WorkflowSignalEnvelope
{
SignalId = "sig-1",
WorkflowInstanceId = "wf-1",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = "UnsupportedSignal",
ExpectedVersion = 0,
},
CancellationToken.None);
await act.Should().ThrowAsync<NotSupportedException>();
}
[Test]
public async Task ProcessAsync_WhenTimerDueSignalProvided_ShouldDispatchResumeWorkflow()
{
var dispatcher = new FakeWorkflowSignalCommandDispatcher();
var processor = new WorkflowSignalProcessor(dispatcher);
var signal = new WorkflowSignalEnvelope
{
SignalId = "sig-1",
WorkflowInstanceId = "wf-1",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.TimerDue,
ExpectedVersion = 2,
WaitingToken = "timer-1",
};
await processor.ProcessAsync(signal, CancellationToken.None);
dispatcher.ResumedSignals.Should().ContainSingle();
dispatcher.ResumedSignals[0].WaitingToken.Should().Be("timer-1");
}
[Test]
public async Task ProcessAsync_WhenExternalSignalProvided_ShouldDispatchResumeWorkflow()
{
var dispatcher = new FakeWorkflowSignalCommandDispatcher();
var processor = new WorkflowSignalProcessor(dispatcher);
var signal = new WorkflowSignalEnvelope
{
SignalId = "sig-2",
WorkflowInstanceId = "wf-2",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.ExternalSignal,
ExpectedVersion = 3,
WaitingToken = "signal-1",
Payload = new Dictionary<string, JsonElement>
{
[WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey] = JsonSerializer.SerializeToElement("documents-uploaded"),
},
};
await processor.ProcessAsync(signal, CancellationToken.None);
dispatcher.ResumedSignals.Should().ContainSingle();
dispatcher.ResumedSignals[0].SignalType.Should().Be(WorkflowSignalTypes.ExternalSignal);
dispatcher.ResumedSignals[0].Payload.Should().ContainKey(WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey);
}
[Test]
public async Task ProcessAsync_WhenSubWorkflowCompletedSignalProvided_ShouldDispatchResumeWorkflow()
{
var dispatcher = new FakeWorkflowSignalCommandDispatcher();
var processor = new WorkflowSignalProcessor(dispatcher);
var signal = new WorkflowSignalEnvelope
{
SignalId = "sig-3",
WorkflowInstanceId = "wf-3",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.SubWorkflowCompleted,
ExpectedVersion = 4,
WaitingToken = "subworkflow-1",
};
await processor.ProcessAsync(signal, CancellationToken.None);
dispatcher.ResumedSignals.Should().ContainSingle();
dispatcher.ResumedSignals[0].SignalType.Should().Be(WorkflowSignalTypes.SubWorkflowCompleted);
dispatcher.ResumedSignals[0].WaitingToken.Should().Be("subworkflow-1");
}
private sealed class FakeWorkflowSignalCommandDispatcher : IWorkflowSignalCommandDispatcher
{
public List<StartWorkflowRequest> Requests { get; } = [];
public List<WorkflowSignalEnvelope> ResumedSignals { get; } = [];
public Task DispatchStartWorkflowAsync(StartWorkflowRequest request, CancellationToken cancellationToken = default)
{
Requests.Add(request);
return Task.CompletedTask;
}
public Task DispatchResumeWorkflowAsync(WorkflowSignalEnvelope envelope, CancellationToken cancellationToken = default)
{
ResumedSignals.Add(envelope);
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,197 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Hosting;
using StellaOps.Workflow.Engine.HostedServices;
using StellaOps.Workflow.Engine.Services;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowSignalPumpWorkerTests
{
[Test]
public async Task RunOnceAsync_WhenNoMessageAvailable_ShouldReturnFalse()
{
using var provider = CreateProvider(new FakeWorkflowSignalBus(), new FakeWorkflowSignalProcessor());
var worker = CreateWorker(provider);
var telemetry = provider.GetRequiredService<WorkflowSignalPumpTelemetryService>();
var processed = await worker.RunOnceAsync("workflow-service", CancellationToken.None);
processed.Should().BeFalse();
telemetry.GetStats().Stats.EmptyPollCount.Should().Be(1);
}
[Test]
public async Task RunOnceAsync_WhenProcessorSucceeds_ShouldCompleteLease()
{
var lease = new FakeWorkflowSignalLease();
var signalBus = new FakeWorkflowSignalBus(lease);
var processor = new FakeWorkflowSignalProcessor();
using var provider = CreateProvider(signalBus, processor);
var worker = CreateWorker(provider);
var telemetry = provider.GetRequiredService<WorkflowSignalPumpTelemetryService>();
var processed = await worker.RunOnceAsync("workflow-service", CancellationToken.None);
processed.Should().BeTrue();
processor.ProcessedSignals.Should().ContainSingle();
lease.CompleteCalls.Should().Be(1);
lease.AbandonCalls.Should().Be(0);
signalBus.ConsumerNames.Should().ContainSingle().Which.Should().Be("workflow-service");
telemetry.GetStats().Stats.ProcessedCount.Should().Be(1);
telemetry.GetStats().Stats.LastSuccess.Should().NotBeNull();
}
[Test]
public async Task RunOnceAsync_WhenProcessorFails_ShouldAbandonLeaseAndRethrow()
{
var lease = new FakeWorkflowSignalLease();
using var provider = CreateProvider(
new FakeWorkflowSignalBus(lease),
new FakeWorkflowSignalProcessor(new InvalidOperationException("boom")));
var worker = CreateWorker(provider);
var telemetry = provider.GetRequiredService<WorkflowSignalPumpTelemetryService>();
var act = async () => await worker.RunOnceAsync("workflow-service", CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
lease.AbandonCalls.Should().Be(1);
lease.CompleteCalls.Should().Be(0);
lease.DeadLetterCalls.Should().Be(0);
telemetry.GetStats().Stats.FailureCount.Should().Be(1);
telemetry.GetStats().Stats.LastFailure.Should().NotBeNull();
}
[Test]
public async Task RunOnceAsync_WhenProcessorFailsAtMaxDeliveryAttempts_ShouldDeadLetterLeaseAndSuppressRetry()
{
var lease = new FakeWorkflowSignalLease(deliveryCount: 3);
using var provider = CreateProvider(
new FakeWorkflowSignalBus(lease),
new FakeWorkflowSignalProcessor(new InvalidOperationException("boom")));
var worker = CreateWorker(provider, maxDeliveryAttempts: 3);
var telemetry = provider.GetRequiredService<WorkflowSignalPumpTelemetryService>();
var processed = await worker.RunOnceAsync("workflow-service", CancellationToken.None);
processed.Should().BeTrue();
lease.DeadLetterCalls.Should().Be(1);
lease.CompleteCalls.Should().Be(0);
lease.AbandonCalls.Should().Be(0);
telemetry.GetStats().Stats.DeadLetterCount.Should().Be(1);
telemetry.GetStats().Stats.LastDeadLetter.Should().NotBeNull();
}
private static ServiceProvider CreateProvider(
FakeWorkflowSignalBus signalBus,
FakeWorkflowSignalProcessor processor)
{
var services = new ServiceCollection();
services.AddScoped<IWorkflowSignalBus>(_ => signalBus);
services.AddScoped<IWorkflowSignalProcessor>(_ => processor);
services.AddSingleton<WorkflowSignalPumpTelemetryService>();
return services.BuildServiceProvider();
}
private static WorkflowSignalPumpWorker CreateWorker(ServiceProvider provider, int maxDeliveryAttempts = 10)
{
return new WorkflowSignalPumpWorker(
provider.GetRequiredService<IServiceScopeFactory>(),
Options.Create(new WorkflowAqOptions
{
MaxDeliveryAttempts = maxDeliveryAttempts,
}),
provider.GetRequiredService<WorkflowSignalPumpTelemetryService>(),
NullLogger<WorkflowSignalPumpWorker>.Instance);
}
private sealed class FakeWorkflowSignalBus(FakeWorkflowSignalLease? lease = null) : IWorkflowSignalBus
{
public List<string> ConsumerNames { get; } = [];
public Task PublishAsync(WorkflowSignalEnvelope envelope, CancellationToken cancellationToken = default)
{
throw new NotSupportedException();
}
public Task PublishDeadLetterAsync(WorkflowSignalEnvelope envelope, CancellationToken cancellationToken = default)
{
throw new NotSupportedException();
}
public Task<IWorkflowSignalLease?> ReceiveAsync(string consumerName, CancellationToken cancellationToken = default)
{
ConsumerNames.Add(consumerName);
return Task.FromResult<IWorkflowSignalLease?>(lease);
}
}
private sealed class FakeWorkflowSignalProcessor(Exception? exception = null) : IWorkflowSignalProcessor
{
public List<WorkflowSignalEnvelope> ProcessedSignals { get; } = [];
public Task ProcessAsync(WorkflowSignalEnvelope envelope, CancellationToken cancellationToken = default)
{
if (exception is not null)
{
throw exception;
}
ProcessedSignals.Add(envelope);
return Task.CompletedTask;
}
}
private sealed class FakeWorkflowSignalLease(int deliveryCount = 1) : IWorkflowSignalLease
{
public WorkflowSignalEnvelope Envelope { get; } = new()
{
SignalId = "sig-1",
WorkflowInstanceId = "wf-1",
RuntimeProvider = WorkflowRuntimeProviderNames.Engine,
SignalType = WorkflowSignalTypes.InternalContinue,
ExpectedVersion = 0,
};
public int DeliveryCount { get; } = deliveryCount;
public int CompleteCalls { get; private set; }
public int AbandonCalls { get; private set; }
public int DeadLetterCalls { get; private set; }
public Task CompleteAsync(CancellationToken cancellationToken = default)
{
CompleteCalls++;
return Task.CompletedTask;
}
public Task AbandonAsync(CancellationToken cancellationToken = default)
{
AbandonCalls++;
return Task.CompletedTask;
}
public Task DeadLetterAsync(CancellationToken cancellationToken = default)
{
DeadLetterCalls++;
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}
}

View File

@@ -0,0 +1,257 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.DataStore.Oracle;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Engine.Services;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests;
[TestFixture]
public class WorkflowVersioningTests
{
[Test]
public async Task StartWorkflowAsync_WhenMultipleVersionsExist_ShouldUseLatestByDefaultAndSpecificVersionOnRequest()
{
using var provider = CreateServiceProvider();
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var latestResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "VersionedWorkflow",
Payload = new Dictionary<string, object?>
{
["businessKey"] = "B-100",
},
});
var explicitV1Response = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "VersionedWorkflow",
WorkflowVersion = "1.0.0",
Payload = new Dictionary<string, object?>
{
["businessKey"] = "B-101",
},
});
latestResponse.WorkflowVersion.Should().Be("2.0.0");
explicitV1Response.WorkflowVersion.Should().Be("1.0.0");
var v1Tasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowName = "VersionedWorkflow",
WorkflowVersion = "1.0.0",
});
var v2Instances = await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest
{
WorkflowName = "VersionedWorkflow",
WorkflowVersion = "2.0.0",
});
v1Tasks.Tasks.Should().ContainSingle();
v1Tasks.Tasks.Single().WorkflowVersion.Should().Be("1.0.0");
v2Instances.Instances.Should().ContainSingle();
v2Instances.Instances.Single().WorkflowVersion.Should().Be("2.0.0");
}
[Test]
public async Task CompleteTaskAsync_WhenOlderVersionInstanceIsRunning_ShouldResolveMatchingHandlerVersion()
{
using var provider = CreateServiceProvider();
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
var response = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
{
WorkflowName = "VersionedWorkflow",
WorkflowVersion = "1.0.0",
Payload = new Dictionary<string, object?>
{
["businessKey"] = "B-200",
},
});
var initialTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = response.WorkflowInstanceId,
})).Tasks.Single();
await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
{
WorkflowTaskId = initialTask.WorkflowTaskId,
ActorId = "user-1",
ActorRoles = ["WF_USER"],
Payload = new Dictionary<string, object?>
{
["mode"] = "loop",
},
});
var tasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = response.WorkflowInstanceId,
});
tasks.Tasks.Should().Contain(x => x.TaskName == "Follow Up V1" && x.WorkflowVersion == "1.0.0");
tasks.Tasks.Should().NotContain(x => x.TaskName == "Follow Up V2");
}
private static ServiceProvider CreateServiceProvider()
{
var services = new ServiceCollection();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["WorkflowRetention:OpenStaleAfterDays"] = "30",
["WorkflowRetention:CompletedPurgeAfterDays"] = "180",
})
.Build();
services.AddLogging();
services.AddWorkflowRegistration<VersionedWorkflowV1, VersionedStartRequest, VersionedWorkflowHandlerV1>();
services.AddWorkflowRegistration<VersionedWorkflowV2, VersionedStartRequest, VersionedWorkflowHandlerV2>();
services.AddWorkflowEngineCoreServices(configuration);
services.AddDbContext<WorkflowDbContext>(options =>
options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
var provider = services.BuildServiceProvider();
// ServiceProviderAccessor.Initialize(provider);
return provider;
}
private sealed record VersionedStartRequest
{
[WorkflowBusinessId]
[WorkflowBusinessReferencePart("policyId")]
public required string BusinessKey { get; init; }
}
private sealed class VersionedWorkflowV1 : ISerdicaWorkflow<VersionedStartRequest>
{
public string WorkflowName => "VersionedWorkflow";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Versioned Workflow V1";
public IReadOnlyCollection<string> WorkflowRoles => ["WF_USER"];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks =>
[
new WorkflowTaskDescriptor
{
TaskName = "Review V1",
TaskType = "ReviewV1",
Route = "versioning/v1",
},
];
}
private sealed class VersionedWorkflowV2 : ISerdicaWorkflow<VersionedStartRequest>
{
public string WorkflowName => "VersionedWorkflow";
public string WorkflowVersion => "2.0.0";
public string DisplayName => "Versioned Workflow V2";
public IReadOnlyCollection<string> WorkflowRoles => ["WF_USER"];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks =>
[
new WorkflowTaskDescriptor
{
TaskName = "Review V2",
TaskType = "ReviewV2",
Route = "versioning/v2",
},
];
}
private sealed class VersionedWorkflowHandlerV1 : VersionedWorkflowHandlerBase
{
protected override string ReviewTaskName => "Review V1";
protected override string ReviewTaskType => "ReviewV1";
protected override string ReviewRoute => "versioning/v1";
protected override string FollowUpTaskName => "Follow Up V1";
}
private sealed class VersionedWorkflowHandlerV2 : VersionedWorkflowHandlerBase
{
protected override string ReviewTaskName => "Review V2";
protected override string ReviewTaskType => "ReviewV2";
protected override string ReviewRoute => "versioning/v2";
protected override string FollowUpTaskName => "Follow Up V2";
}
private abstract class VersionedWorkflowHandlerBase : IWorkflowExecutionHandler
{
protected abstract string ReviewTaskName { get; }
protected abstract string ReviewTaskType { get; }
protected abstract string ReviewRoute { get; }
protected abstract string FollowUpTaskName { get; }
public Task<WorkflowStartExecutionPlan> StartAsync(
WorkflowStartExecutionContext context,
CancellationToken cancellationToken = default)
{
var startRequest = context.GetRequiredStartRequest<VersionedStartRequest>();
return Task.FromResult(new WorkflowStartExecutionPlan
{
WorkflowState = new Dictionary<string, JsonElement>
{
["businessId"] = JsonSerializer.SerializeToElement(startRequest.BusinessKey),
},
Tasks =
[
new WorkflowExecutionTaskPlan
{
TaskName = ReviewTaskName,
TaskType = ReviewTaskType,
Route = ReviewRoute,
},
],
});
}
public Task<WorkflowTaskCompletionPlan> CompleteTaskAsync(
WorkflowTaskExecutionContext context,
CancellationToken cancellationToken = default)
{
var mode = context.Payload.TryGetValue("mode", out var modeValue)
? modeValue.GetString()
: null;
if (!string.Equals(mode, "loop", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(new WorkflowTaskCompletionPlan
{
InstanceStatus = "Completed",
WorkflowState = context.WorkflowState,
});
}
return Task.FromResult(new WorkflowTaskCompletionPlan
{
InstanceStatus = "Open",
WorkflowState = context.WorkflowState,
NextTasks =
[
new WorkflowExecutionTaskPlan
{
TaskName = FollowUpTaskName,
TaskType = FollowUpTaskName.Replace(" ", string.Empty),
Route = ReviewRoute,
},
],
});
}
}
}

View File

@@ -0,0 +1 @@
global using StellaOps.Workflow.IntegrationTests.Shared.Performance;

View File

@@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace StellaOps.Workflow.IntegrationTests.Shared.Performance;
internal static class WorkflowEnginePerformanceSupport
{
public static async Task<IAsyncDisposable> StartHostedServicesAsync(
IServiceProvider provider,
CancellationToken cancellationToken = default)
{
var services = provider.GetServices<IHostedService>().ToArray();
foreach (var service in services)
{
await service.StartAsync(cancellationToken);
}
return new HostedServicesHandle(services);
}
public static async Task<T> WithRuntimeServiceAsync<T>(
IServiceProvider provider,
Func<WorkflowRuntimeService, Task<T>> action)
{
using var scope = provider.CreateScope();
var runtimeService = scope.ServiceProvider.GetRequiredService<WorkflowRuntimeService>();
return await action(runtimeService);
}
public static async Task WithRuntimeServiceAsync(
IServiceProvider provider,
Func<WorkflowRuntimeService, Task> action)
{
using var scope = provider.CreateScope();
var runtimeService = scope.ServiceProvider.GetRequiredService<WorkflowRuntimeService>();
await action(runtimeService);
}
public static async Task<IReadOnlyList<TResult>> RunConcurrentAsync<TInput, TResult>(
IEnumerable<TInput> items,
int concurrency,
Func<TInput, Task<TResult>> action)
{
ArgumentOutOfRangeException.ThrowIfLessThan(concurrency, 1);
using var semaphore = new SemaphoreSlim(concurrency);
var tasks = items.Select(async item =>
{
await semaphore.WaitAsync();
try
{
return await action(item);
}
finally
{
semaphore.Release();
}
});
return await Task.WhenAll(tasks);
}
private sealed class HostedServicesHandle(IReadOnlyList<IHostedService> services) : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
for (var index = services.Count - 1; index >= 0; index--)
{
await services[index].StopAsync(CancellationToken.None);
}
}
}
}

View File

@@ -0,0 +1,483 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using NUnit.Framework;
namespace StellaOps.Workflow.IntegrationTests.Shared.Performance;
public static class WorkflowPerformanceCategories
{
public const string Latency = "WorkflowPerfLatency";
public const string Throughput = "WorkflowPerfThroughput";
public const string Smoke = "WorkflowPerfSmoke";
public const string Nightly = "WorkflowPerfNightly";
public const string Soak = "WorkflowPerfSoak";
public const string Capacity = "WorkflowPerfCapacity";
public const string Comparison = "WorkflowPerfComparison";
}
public sealed record WorkflowPerformanceRunResult
{
public required string ScenarioName { get; init; }
public required string Tier { get; init; }
public required string EnvironmentName { get; init; }
public required DateTime StartedAtUtc { get; init; }
public required DateTime CompletedAtUtc { get; init; }
public required int OperationCount { get; init; }
public required int Concurrency { get; init; }
public required WorkflowPerformanceCounters Counters { get; init; }
public required WorkflowPerformanceResourceSnapshot ResourceSnapshot { get; init; }
public required Dictionary<string, string> Metadata { get; init; }
public WorkflowPerformanceLatencySummary? LatencySummary { get; init; }
public Dictionary<string, WorkflowPerformanceLatencySummary>? PhaseLatencySummaries { get; init; }
public WorkflowPerformanceBaselineComparison? BaselineComparison { get; init; }
public WorkflowPerformanceBackendMetrics? BackendMetrics { get; init; }
public double DurationMilliseconds =>
(CompletedAtUtc - StartedAtUtc).TotalMilliseconds;
public double ThroughputPerSecond =>
DurationMilliseconds <= 0
? 0
: OperationCount / (DurationMilliseconds / 1000d);
}
public sealed record WorkflowPerformanceCounters
{
public int WorkflowsStarted { get; init; }
public int TasksActivated { get; init; }
public int TasksCompleted { get; init; }
public int SignalsPublished { get; init; }
public int SignalsProcessed { get; init; }
public int SignalsIgnored { get; init; }
public int DeadLetteredSignals { get; init; }
public int RuntimeConflicts { get; init; }
public int Failures { get; init; }
public int StuckInstances { get; init; }
}
public sealed record WorkflowPerformanceLatencySummary
{
public required int SampleCount { get; init; }
public required double AverageMilliseconds { get; init; }
public required double P50Milliseconds { get; init; }
public required double P95Milliseconds { get; init; }
public required double P99Milliseconds { get; init; }
public required double MaxMilliseconds { get; init; }
public static WorkflowPerformanceLatencySummary? FromSamples(IEnumerable<TimeSpan> samples)
{
var ordered = samples
.Select(sample => sample.TotalMilliseconds)
.OrderBy(value => value)
.ToArray();
if (ordered.Length == 0)
{
return null;
}
return new WorkflowPerformanceLatencySummary
{
SampleCount = ordered.Length,
AverageMilliseconds = ordered.Average(),
P50Milliseconds = Percentile(ordered, 0.50),
P95Milliseconds = Percentile(ordered, 0.95),
P99Milliseconds = Percentile(ordered, 0.99),
MaxMilliseconds = ordered[^1],
};
}
private static double Percentile(double[] ordered, double percentile)
{
if (ordered.Length == 1)
{
return ordered[0];
}
var position = (ordered.Length - 1) * percentile;
var lowerIndex = (int)Math.Floor(position);
var upperIndex = (int)Math.Ceiling(position);
if (lowerIndex == upperIndex)
{
return ordered[lowerIndex];
}
var weight = position - lowerIndex;
return ordered[lowerIndex] + ((ordered[upperIndex] - ordered[lowerIndex]) * weight);
}
}
public sealed record WorkflowPerformanceResourceSnapshot
{
public required long WorkingSetBytes { get; init; }
public required long PrivateMemoryBytes { get; init; }
public required string MachineName { get; init; }
public required string FrameworkDescription { get; init; }
public required string OsDescription { get; init; }
public static WorkflowPerformanceResourceSnapshot CaptureCurrent()
{
var process = Process.GetCurrentProcess();
return new WorkflowPerformanceResourceSnapshot
{
WorkingSetBytes = process.WorkingSet64,
PrivateMemoryBytes = process.PrivateMemorySize64,
MachineName = Environment.MachineName,
FrameworkDescription = RuntimeInformation.FrameworkDescription,
OsDescription = RuntimeInformation.OSDescription,
};
}
}
public sealed record WorkflowPerformanceBaselineComparison
{
public required string Status { get; init; }
public string? BaselineJsonPath { get; init; }
public double? ThroughputDeltaPercent { get; init; }
public double? AverageLatencyDeltaPercent { get; init; }
public double? P95LatencyDeltaPercent { get; init; }
public double? MaxLatencyDeltaPercent { get; init; }
public static WorkflowPerformanceBaselineComparison Missing()
{
return new WorkflowPerformanceBaselineComparison
{
Status = "Missing",
};
}
public static WorkflowPerformanceBaselineComparison Compare(
string baselineJsonPath,
WorkflowPerformanceRunResult baseline,
WorkflowPerformanceRunResult current)
{
return new WorkflowPerformanceBaselineComparison
{
Status = "Compared",
BaselineJsonPath = baselineJsonPath,
ThroughputDeltaPercent = CalculateDeltaPercent(baseline.ThroughputPerSecond, current.ThroughputPerSecond),
AverageLatencyDeltaPercent = CalculateDeltaPercent(
baseline.LatencySummary?.AverageMilliseconds,
current.LatencySummary?.AverageMilliseconds),
P95LatencyDeltaPercent = CalculateDeltaPercent(
baseline.LatencySummary?.P95Milliseconds,
current.LatencySummary?.P95Milliseconds),
MaxLatencyDeltaPercent = CalculateDeltaPercent(
baseline.LatencySummary?.MaxMilliseconds,
current.LatencySummary?.MaxMilliseconds),
};
}
private static double? CalculateDeltaPercent(double? baseline, double? current)
{
if (!baseline.HasValue || !current.HasValue)
{
return null;
}
if (Math.Abs(baseline.Value) < 0.0001d)
{
return null;
}
return ((current.Value - baseline.Value) / baseline.Value) * 100d;
}
}
public sealed record WorkflowPerformanceBackendMetrics
{
public required string BackendName { get; init; }
public string? InstanceName { get; init; }
public string? HostName { get; init; }
public string? Version { get; init; }
public Dictionary<string, long> CounterDeltas { get; init; } = [];
public Dictionary<string, long> DurationDeltas { get; init; } = [];
public Dictionary<string, string> Metadata { get; init; } = [];
public IReadOnlyList<WorkflowPerformanceWaitMetric> TopWaitDeltas { get; init; } = [];
}
public sealed record WorkflowPerformanceWaitMetric
{
public required string Name { get; init; }
public required long TotalCount { get; init; }
public required long DurationMicroseconds { get; init; }
}
public static class WorkflowPerformanceArtifactWriter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
};
public static WorkflowPerformanceArtifactPaths Write(WorkflowPerformanceRunResult result)
{
var baseDirectory = Path.Combine(
TestContext.CurrentContext.WorkDirectory,
"TestResults",
"workflow-performance",
SanitizePathSegment(result.Tier));
Directory.CreateDirectory(baseDirectory);
var resultWithComparison = result with
{
BaselineComparison = LoadBaselineComparison(baseDirectory, result),
};
var fileStem = $"{result.StartedAtUtc:yyyyMMddTHHmmssfff}-{SanitizePathSegment(result.ScenarioName)}";
var jsonPath = Path.Combine(baseDirectory, $"{fileStem}.json");
var markdownPath = Path.Combine(baseDirectory, $"{fileStem}.md");
File.WriteAllText(jsonPath, JsonSerializer.Serialize(resultWithComparison, JsonOptions));
File.WriteAllText(markdownPath, BuildMarkdown(resultWithComparison));
TestContext.AddTestAttachment(jsonPath);
TestContext.AddTestAttachment(markdownPath);
TestContext.Progress.WriteLine($"Performance artifacts: {jsonPath}");
return new WorkflowPerformanceArtifactPaths
{
JsonPath = jsonPath,
MarkdownPath = markdownPath,
};
}
private static string BuildMarkdown(WorkflowPerformanceRunResult result)
{
var builder = new StringBuilder();
builder.AppendLine($"# {result.ScenarioName}");
builder.AppendLine();
builder.AppendLine($"- Tier: `{result.Tier}`");
builder.AppendLine($"- Environment: `{result.EnvironmentName}`");
builder.AppendLine($"- Started: `{result.StartedAtUtc:O}`");
builder.AppendLine($"- Completed: `{result.CompletedAtUtc:O}`");
builder.AppendLine($"- Duration ms: `{result.DurationMilliseconds.ToString("F2", CultureInfo.InvariantCulture)}`");
builder.AppendLine($"- Operations: `{result.OperationCount}`");
builder.AppendLine($"- Concurrency: `{result.Concurrency}`");
builder.AppendLine($"- Throughput/sec: `{result.ThroughputPerSecond.ToString("F2", CultureInfo.InvariantCulture)}`");
if (result.BaselineComparison is not null)
{
builder.AppendLine();
builder.AppendLine("## Baseline Comparison");
builder.AppendLine();
builder.AppendLine($"- Status: `{result.BaselineComparison.Status}`");
if (!string.IsNullOrWhiteSpace(result.BaselineComparison.BaselineJsonPath))
{
builder.AppendLine($"- BaselineJsonPath: `{result.BaselineComparison.BaselineJsonPath}`");
}
if (result.BaselineComparison.ThroughputDeltaPercent.HasValue)
{
builder.AppendLine($"- Throughput delta %: `{result.BaselineComparison.ThroughputDeltaPercent.Value.ToString("F2", CultureInfo.InvariantCulture)}`");
}
if (result.BaselineComparison.AverageLatencyDeltaPercent.HasValue)
{
builder.AppendLine($"- Avg latency delta %: `{result.BaselineComparison.AverageLatencyDeltaPercent.Value.ToString("F2", CultureInfo.InvariantCulture)}`");
}
if (result.BaselineComparison.P95LatencyDeltaPercent.HasValue)
{
builder.AppendLine($"- P95 latency delta %: `{result.BaselineComparison.P95LatencyDeltaPercent.Value.ToString("F2", CultureInfo.InvariantCulture)}`");
}
if (result.BaselineComparison.MaxLatencyDeltaPercent.HasValue)
{
builder.AppendLine($"- Max latency delta %: `{result.BaselineComparison.MaxLatencyDeltaPercent.Value.ToString("F2", CultureInfo.InvariantCulture)}`");
}
}
builder.AppendLine();
builder.AppendLine("## Counters");
builder.AppendLine();
builder.AppendLine($"- WorkflowsStarted: `{result.Counters.WorkflowsStarted}`");
builder.AppendLine($"- TasksActivated: `{result.Counters.TasksActivated}`");
builder.AppendLine($"- TasksCompleted: `{result.Counters.TasksCompleted}`");
builder.AppendLine($"- SignalsPublished: `{result.Counters.SignalsPublished}`");
builder.AppendLine($"- SignalsProcessed: `{result.Counters.SignalsProcessed}`");
builder.AppendLine($"- SignalsIgnored: `{result.Counters.SignalsIgnored}`");
builder.AppendLine($"- DeadLetteredSignals: `{result.Counters.DeadLetteredSignals}`");
builder.AppendLine($"- RuntimeConflicts: `{result.Counters.RuntimeConflicts}`");
builder.AppendLine($"- Failures: `{result.Counters.Failures}`");
builder.AppendLine($"- StuckInstances: `{result.Counters.StuckInstances}`");
if (result.LatencySummary is not null)
{
builder.AppendLine();
builder.AppendLine("## Latency");
builder.AppendLine();
builder.AppendLine($"- Samples: `{result.LatencySummary.SampleCount}`");
builder.AppendLine($"- Avg ms: `{result.LatencySummary.AverageMilliseconds.ToString("F2", CultureInfo.InvariantCulture)}`");
builder.AppendLine($"- P50 ms: `{result.LatencySummary.P50Milliseconds.ToString("F2", CultureInfo.InvariantCulture)}`");
builder.AppendLine($"- P95 ms: `{result.LatencySummary.P95Milliseconds.ToString("F2", CultureInfo.InvariantCulture)}`");
builder.AppendLine($"- P99 ms: `{result.LatencySummary.P99Milliseconds.ToString("F2", CultureInfo.InvariantCulture)}`");
builder.AppendLine($"- Max ms: `{result.LatencySummary.MaxMilliseconds.ToString("F2", CultureInfo.InvariantCulture)}`");
}
if (result.PhaseLatencySummaries is not null && result.PhaseLatencySummaries.Count > 0)
{
builder.AppendLine();
builder.AppendLine("## Phase Latency");
builder.AppendLine();
foreach (var phase in result.PhaseLatencySummaries.OrderBy(item => item.Key, StringComparer.Ordinal))
{
builder.AppendLine($"### {phase.Key}");
builder.AppendLine();
builder.AppendLine($"- Samples: `{phase.Value.SampleCount}`");
builder.AppendLine($"- Average ms: `{phase.Value.AverageMilliseconds.ToString("F2", CultureInfo.InvariantCulture)}`");
builder.AppendLine($"- P50 ms: `{phase.Value.P50Milliseconds.ToString("F2", CultureInfo.InvariantCulture)}`");
builder.AppendLine($"- P95 ms: `{phase.Value.P95Milliseconds.ToString("F2", CultureInfo.InvariantCulture)}`");
builder.AppendLine($"- P99 ms: `{phase.Value.P99Milliseconds.ToString("F2", CultureInfo.InvariantCulture)}`");
builder.AppendLine($"- Max ms: `{phase.Value.MaxMilliseconds.ToString("F2", CultureInfo.InvariantCulture)}`");
builder.AppendLine();
}
}
builder.AppendLine();
builder.AppendLine("## Resources");
builder.AppendLine();
builder.AppendLine($"- WorkingSetBytes: `{result.ResourceSnapshot.WorkingSetBytes}`");
builder.AppendLine($"- PrivateMemoryBytes: `{result.ResourceSnapshot.PrivateMemoryBytes}`");
builder.AppendLine($"- MachineName: `{result.ResourceSnapshot.MachineName}`");
builder.AppendLine($"- Framework: `{result.ResourceSnapshot.FrameworkDescription}`");
builder.AppendLine($"- OS: `{result.ResourceSnapshot.OsDescription}`");
if (result.BackendMetrics is not null)
{
builder.AppendLine();
builder.AppendLine("## Backend Metrics");
builder.AppendLine();
builder.AppendLine($"- BackendName: `{result.BackendMetrics.BackendName}`");
if (!string.IsNullOrWhiteSpace(result.BackendMetrics.InstanceName))
{
builder.AppendLine($"- InstanceName: `{result.BackendMetrics.InstanceName}`");
}
if (!string.IsNullOrWhiteSpace(result.BackendMetrics.HostName))
{
builder.AppendLine($"- HostName: `{result.BackendMetrics.HostName}`");
}
if (!string.IsNullOrWhiteSpace(result.BackendMetrics.Version))
{
builder.AppendLine($"- Version: `{result.BackendMetrics.Version}`");
}
if (result.BackendMetrics.CounterDeltas.Count > 0)
{
builder.AppendLine();
builder.AppendLine("### Counter Deltas");
builder.AppendLine();
foreach (var pair in result.BackendMetrics.CounterDeltas.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase))
{
builder.AppendLine($"- {pair.Key}: `{pair.Value}`");
}
}
if (result.BackendMetrics.DurationDeltas.Count > 0)
{
builder.AppendLine();
builder.AppendLine("### Duration Deltas");
builder.AppendLine();
foreach (var pair in result.BackendMetrics.DurationDeltas.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase))
{
builder.AppendLine($"- {pair.Key}: `{pair.Value}`");
}
}
if (result.BackendMetrics.TopWaitDeltas.Count > 0)
{
builder.AppendLine();
builder.AppendLine("### Top Wait Deltas");
builder.AppendLine();
foreach (var wait in result.BackendMetrics.TopWaitDeltas)
{
builder.AppendLine($"- {wait.Name}: count=`{wait.TotalCount}`, duration_micro=`{wait.DurationMicroseconds}`");
}
}
if (result.BackendMetrics.Metadata.Count > 0)
{
builder.AppendLine();
builder.AppendLine("### Backend Metadata");
builder.AppendLine();
foreach (var pair in result.BackendMetrics.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder.AppendLine($"- {pair.Key}: `{pair.Value}`");
}
}
}
if (result.Metadata.Count > 0)
{
builder.AppendLine();
builder.AppendLine("## Metadata");
builder.AppendLine();
foreach (var pair in result.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder.AppendLine($"- {pair.Key}: `{pair.Value}`");
}
}
return builder.ToString();
}
private static string SanitizePathSegment(string value)
{
var invalidCharacters = Path.GetInvalidFileNameChars();
var sanitized = new string(value
.Select(character => invalidCharacters.Contains(character) ? '-' : character)
.ToArray());
return sanitized.Replace(' ', '-');
}
private static WorkflowPerformanceBaselineComparison LoadBaselineComparison(
string baseDirectory,
WorkflowPerformanceRunResult current)
{
var scenarioSuffix = $"-{SanitizePathSegment(current.ScenarioName)}.json";
var baselinePath = Directory
.EnumerateFiles(baseDirectory, "*.json", SearchOption.TopDirectoryOnly)
.Where(path => path.EndsWith(scenarioSuffix, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(path => path, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
if (baselinePath is null)
{
return WorkflowPerformanceBaselineComparison.Missing();
}
try
{
var baseline = JsonSerializer.Deserialize<WorkflowPerformanceRunResult>(
File.ReadAllText(baselinePath),
JsonOptions);
return baseline is null
? WorkflowPerformanceBaselineComparison.Missing()
: WorkflowPerformanceBaselineComparison.Compare(baselinePath, baseline, current);
}
catch
{
return WorkflowPerformanceBaselineComparison.Missing();
}
}
}
public sealed record WorkflowPerformanceArtifactPaths
{
public required string JsonPath { get; init; }
public required string MarkdownPath { get; init; }
}

View File

@@ -0,0 +1,340 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using NUnit.Framework;
namespace StellaOps.Workflow.IntegrationTests.Shared.Performance;
internal sealed record WorkflowPerformanceComparisonMatrixResult
{
public required string MatrixName { get; init; }
public required DateTime GeneratedAtUtc { get; init; }
public required IReadOnlyList<string> Columns { get; init; }
public required IReadOnlyList<WorkflowPerformanceComparisonMatrixRow> Rows { get; init; }
public required IReadOnlyList<WorkflowPerformanceComparisonSource> Sources { get; init; }
public required IReadOnlyList<WorkflowPerformanceComparisonIntegrityCheck> IntegrityChecks { get; init; }
}
internal sealed record WorkflowPerformanceComparisonMatrixRow
{
public required string Section { get; init; }
public required string Metric { get; init; }
public required string Unit { get; init; }
public required Dictionary<string, double> Values { get; init; }
}
internal sealed record WorkflowPerformanceComparisonSource
{
public required string Column { get; init; }
public required string LatencyScenarioName { get; init; }
public required string LatencyArtifactPath { get; init; }
public required string ThroughputScenarioName { get; init; }
public required string ThroughputArtifactPath { get; init; }
}
internal sealed record WorkflowPerformanceComparisonIntegrityCheck
{
public required string Column { get; init; }
public required bool Passed { get; init; }
public required IReadOnlyList<string> Checks { get; init; }
}
internal sealed record WorkflowPerformanceComparisonArtifactPaths
{
public required string JsonPath { get; init; }
public required string MarkdownPath { get; init; }
}
internal static class WorkflowPerformanceComparisonMatrixWriter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
};
private static readonly ComparisonProfile[] Profiles =
[
new("Oracle", "oracle-aq-signal-roundtrip-latency-serial", "oracle-aq-signal-roundtrip-throughput-parallel"),
new("PostgreSQL", "postgres-signal-roundtrip-latency-serial", "postgres-signal-roundtrip-throughput-parallel"),
new("Mongo", "mongo-signal-roundtrip-latency-serial", "mongo-signal-roundtrip-throughput-parallel"),
new("Oracle+Redis", "oracle-redis-signal-roundtrip-latency-serial", "oracle-redis-signal-roundtrip-throughput-parallel"),
new("PostgreSQL+Redis", "postgres-redis-signal-roundtrip-latency-serial", "postgres-redis-signal-roundtrip-throughput-parallel"),
new("Mongo+Redis", "mongo-redis-signal-roundtrip-latency-serial", "mongo-redis-signal-roundtrip-throughput-parallel"),
];
public static WorkflowPerformanceComparisonMatrixResult BuildSixProfileSignalMatrix()
{
var scenarios = Profiles
.Select(profile => LoadScenarioPair(profile))
.ToArray();
return new WorkflowPerformanceComparisonMatrixResult
{
MatrixName = "workflow-backend-signal-roundtrip-six-profile-matrix",
GeneratedAtUtc = DateTime.UtcNow,
Columns = Profiles.Select(profile => profile.ColumnName).ToArray(),
Rows =
[
CreateRow("Serial Latency", "End-to-end avg", "ms", scenarios, scenario => scenario.Latency.LatencySummary?.AverageMilliseconds),
CreateRow("Serial Latency", "End-to-end p95", "ms", scenarios, scenario => scenario.Latency.LatencySummary?.P95Milliseconds),
CreateRow("Serial Latency", "Start avg", "ms", scenarios, scenario => GetPhaseAverage(scenario.Latency, "start")),
CreateRow("Serial Latency", "Signal publish avg", "ms", scenarios, scenario => GetPhaseAverage(scenario.Latency, "signalPublish")),
CreateRow("Serial Latency", "Signal to first completion avg", "ms", scenarios, scenario => GetPhaseAverage(scenario.Latency, "signalToFirstCompletion")),
CreateRow("Serial Latency", "Signal to completion avg", "ms", scenarios, scenario => GetPhaseAverage(scenario.Latency, "signalToCompletion")),
CreateRow("Serial Latency", "Drain-to-idle overhang avg", "ms", scenarios, scenario => GetPhaseAverage(scenario.Latency, "drainToIdleOverhang")),
CreateRow("Parallel Throughput", "Throughput", "ops/s", scenarios, scenario => scenario.Throughput.ThroughputPerSecond),
CreateRow("Parallel Throughput", "End-to-end avg", "ms", scenarios, scenario => scenario.Throughput.LatencySummary?.AverageMilliseconds),
CreateRow("Parallel Throughput", "End-to-end p95", "ms", scenarios, scenario => scenario.Throughput.LatencySummary?.P95Milliseconds),
CreateRow("Parallel Throughput", "Start avg", "ms", scenarios, scenario => GetPhaseAverage(scenario.Throughput, "start")),
CreateRow("Parallel Throughput", "Signal publish avg", "ms", scenarios, scenario => GetPhaseAverage(scenario.Throughput, "signalPublish")),
CreateRow("Parallel Throughput", "Signal to completion avg", "ms", scenarios, scenario => GetPhaseAverage(scenario.Throughput, "signalToCompletion")),
],
Sources = scenarios.Select(scenario => new WorkflowPerformanceComparisonSource
{
Column = scenario.Profile.ColumnName,
LatencyScenarioName = scenario.Profile.LatencyScenarioName,
LatencyArtifactPath = scenario.LatencyPath,
ThroughputScenarioName = scenario.Profile.ThroughputScenarioName,
ThroughputArtifactPath = scenario.ThroughputPath,
}).ToArray(),
IntegrityChecks = scenarios.Select(ValidateScenarioPair).ToArray(),
};
}
public static WorkflowPerformanceComparisonArtifactPaths Write(WorkflowPerformanceComparisonMatrixResult result)
{
var baseDirectory = Path.Combine(
TestContext.CurrentContext.WorkDirectory,
"TestResults",
"workflow-performance",
WorkflowPerformanceCategories.Comparison);
Directory.CreateDirectory(baseDirectory);
var fileStem = $"{result.GeneratedAtUtc:yyyyMMddTHHmmssfff}-{SanitizePathSegment(result.MatrixName)}";
var jsonPath = Path.Combine(baseDirectory, $"{fileStem}.json");
var markdownPath = Path.Combine(baseDirectory, $"{fileStem}.md");
File.WriteAllText(jsonPath, JsonSerializer.Serialize(result, JsonOptions));
File.WriteAllText(markdownPath, BuildMarkdown(result));
TestContext.AddTestAttachment(jsonPath);
TestContext.AddTestAttachment(markdownPath);
return new WorkflowPerformanceComparisonArtifactPaths
{
JsonPath = jsonPath,
MarkdownPath = markdownPath,
};
}
private static WorkflowPerformanceComparisonIntegrityCheck ValidateScenarioPair(LoadedScenarioPair pair)
{
var checks = new List<string>();
checks.Add(BuildIntegrityCheck("latency.failures", pair.Latency.Counters.Failures == 0, pair.Latency.Counters.Failures));
checks.Add(BuildIntegrityCheck("latency.deadLetteredSignals", pair.Latency.Counters.DeadLetteredSignals == 0, pair.Latency.Counters.DeadLetteredSignals));
checks.Add(BuildIntegrityCheck("latency.runtimeConflicts", pair.Latency.Counters.RuntimeConflicts == 0, pair.Latency.Counters.RuntimeConflicts));
checks.Add(BuildIntegrityCheck("latency.stuckInstances", pair.Latency.Counters.StuckInstances == 0, pair.Latency.Counters.StuckInstances));
checks.Add(BuildIntegrityCheck("latency.workflowsStarted", pair.Latency.Counters.WorkflowsStarted == pair.Latency.OperationCount, pair.Latency.Counters.WorkflowsStarted));
checks.Add(BuildIntegrityCheck("latency.signalsPublished", pair.Latency.Counters.SignalsPublished == pair.Latency.OperationCount, pair.Latency.Counters.SignalsPublished));
checks.Add(BuildIntegrityCheck("latency.signalsProcessed", pair.Latency.Counters.SignalsProcessed == pair.Latency.OperationCount, pair.Latency.Counters.SignalsProcessed));
checks.Add(BuildIntegrityCheck("throughput.failures", pair.Throughput.Counters.Failures == 0, pair.Throughput.Counters.Failures));
checks.Add(BuildIntegrityCheck("throughput.deadLetteredSignals", pair.Throughput.Counters.DeadLetteredSignals == 0, pair.Throughput.Counters.DeadLetteredSignals));
checks.Add(BuildIntegrityCheck("throughput.runtimeConflicts", pair.Throughput.Counters.RuntimeConflicts == 0, pair.Throughput.Counters.RuntimeConflicts));
checks.Add(BuildIntegrityCheck("throughput.stuckInstances", pair.Throughput.Counters.StuckInstances == 0, pair.Throughput.Counters.StuckInstances));
checks.Add(BuildIntegrityCheck("throughput.workflowsStarted", pair.Throughput.Counters.WorkflowsStarted == pair.Throughput.OperationCount, pair.Throughput.Counters.WorkflowsStarted));
checks.Add(BuildIntegrityCheck("throughput.signalsPublished", pair.Throughput.Counters.SignalsPublished == pair.Throughput.OperationCount, pair.Throughput.Counters.SignalsPublished));
checks.Add(BuildIntegrityCheck("throughput.signalsProcessed", pair.Throughput.Counters.SignalsProcessed == pair.Throughput.OperationCount, pair.Throughput.Counters.SignalsProcessed));
return new WorkflowPerformanceComparisonIntegrityCheck
{
Column = pair.Profile.ColumnName,
Passed = checks.All(check => check.EndsWith(":passed", StringComparison.Ordinal)),
Checks = checks,
};
}
private static string BuildIntegrityCheck(string name, bool success, int actualValue)
{
return $"{name}:{actualValue}:{(success ? "passed" : "failed")}";
}
private static WorkflowPerformanceComparisonMatrixRow CreateRow(
string section,
string metric,
string unit,
IReadOnlyList<LoadedScenarioPair> scenarios,
Func<LoadedScenarioPair, double?> valueSelector)
{
var values = new Dictionary<string, double>(StringComparer.Ordinal);
foreach (var scenario in scenarios)
{
var value = valueSelector(scenario)
?? throw new InvalidOperationException(
$"Matrix metric '{metric}' for column '{scenario.Profile.ColumnName}' is missing from source artifact.");
values[scenario.Profile.ColumnName] = value;
}
return new WorkflowPerformanceComparisonMatrixRow
{
Section = section,
Metric = metric,
Unit = unit,
Values = values,
};
}
private static LoadedScenarioPair LoadScenarioPair(ComparisonProfile profile)
{
var latencyPath = FindLatestArtifactPath(profile.LatencyScenarioName);
var throughputPath = FindLatestArtifactPath(profile.ThroughputScenarioName);
var latency = JsonSerializer.Deserialize<WorkflowPerformanceRunResult>(File.ReadAllText(latencyPath), JsonOptions)
?? throw new InvalidOperationException($"Unable to deserialize performance artifact '{latencyPath}'.");
var throughput = JsonSerializer.Deserialize<WorkflowPerformanceRunResult>(File.ReadAllText(throughputPath), JsonOptions)
?? throw new InvalidOperationException($"Unable to deserialize performance artifact '{throughputPath}'.");
if (!string.Equals(latency.ScenarioName, profile.LatencyScenarioName, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Artifact '{latencyPath}' contained scenario '{latency.ScenarioName}' instead of expected '{profile.LatencyScenarioName}'.");
}
if (!string.Equals(throughput.ScenarioName, profile.ThroughputScenarioName, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Artifact '{throughputPath}' contained scenario '{throughput.ScenarioName}' instead of expected '{profile.ThroughputScenarioName}'.");
}
return new LoadedScenarioPair(profile, latency, latencyPath, throughput, throughputPath);
}
private static string FindLatestArtifactPath(string scenarioName)
{
var baseDirectory = Path.Combine(
TestContext.CurrentContext.WorkDirectory,
"TestResults",
"workflow-performance");
var fileSuffix = $"-{SanitizePathSegment(scenarioName)}.json";
var path = Directory
.EnumerateFiles(baseDirectory, "*.json", SearchOption.AllDirectories)
.Where(filePath => filePath.EndsWith(fileSuffix, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(filePath => File.GetLastWriteTimeUtc(filePath))
.FirstOrDefault();
return path ?? throw new InvalidOperationException(
$"Required performance artifact for scenario '{scenarioName}' was not found under '{baseDirectory}'.");
}
private static double? GetPhaseAverage(WorkflowPerformanceRunResult result, string phaseName)
{
return result.PhaseLatencySummaries is not null
&& result.PhaseLatencySummaries.TryGetValue(phaseName, out var summary)
? summary.AverageMilliseconds
: null;
}
private static string BuildMarkdown(WorkflowPerformanceComparisonMatrixResult result)
{
var builder = new StringBuilder();
builder.AppendLine($"# {result.MatrixName}");
builder.AppendLine();
builder.AppendLine($"- GeneratedAtUtc: `{result.GeneratedAtUtc:O}`");
builder.AppendLine("- SourcePolicy: `artifact-driven-only`");
builder.AppendLine("- Guarantee: every matrix cell is read from a measured JSON artifact; no hand-entered metric values are allowed.");
builder.AppendLine();
foreach (var sectionGroup in result.Rows.GroupBy(row => row.Section, StringComparer.Ordinal))
{
builder.AppendLine($"## {sectionGroup.Key}");
builder.AppendLine();
builder.Append("| Metric | Unit |");
foreach (var column in result.Columns)
{
builder.Append(' ').Append(column).Append(" |");
}
builder.AppendLine();
builder.Append("| --- | --- |");
foreach (var _ in result.Columns)
{
builder.Append(" ---: |");
}
builder.AppendLine();
foreach (var row in sectionGroup)
{
builder.Append("| ").Append(row.Metric).Append(" | ").Append(row.Unit).Append(" |");
foreach (var column in result.Columns)
{
builder.Append(' ')
.Append(row.Values[column].ToString("F2", CultureInfo.InvariantCulture))
.Append(" |");
}
builder.AppendLine();
}
builder.AppendLine();
}
builder.AppendLine("## Integrity");
builder.AppendLine();
foreach (var check in result.IntegrityChecks)
{
builder.AppendLine($"### {check.Column}");
builder.AppendLine();
builder.AppendLine($"- Passed: `{check.Passed}`");
foreach (var item in check.Checks)
{
builder.AppendLine($"- {item}");
}
builder.AppendLine();
}
builder.AppendLine("## Sources");
builder.AppendLine();
foreach (var source in result.Sources)
{
builder.AppendLine($"### {source.Column}");
builder.AppendLine();
builder.AppendLine($"- Latency scenario: `{source.LatencyScenarioName}`");
builder.AppendLine($"- Latency artifact: `{source.LatencyArtifactPath}`");
builder.AppendLine($"- Throughput scenario: `{source.ThroughputScenarioName}`");
builder.AppendLine($"- Throughput artifact: `{source.ThroughputArtifactPath}`");
builder.AppendLine();
}
return builder.ToString();
}
private static string SanitizePathSegment(string value)
{
var invalidCharacters = Path.GetInvalidFileNameChars();
var sanitized = new string(value
.Select(character => invalidCharacters.Contains(character) ? '-' : character)
.ToArray());
return sanitized.Replace(' ', '-');
}
private sealed record ComparisonProfile(
string ColumnName,
string LatencyScenarioName,
string ThroughputScenarioName);
private sealed record LoadedScenarioPair(
ComparisonProfile Profile,
WorkflowPerformanceRunResult Latency,
string LatencyPath,
WorkflowPerformanceRunResult Throughput,
string ThroughputPath);
}

View File

@@ -0,0 +1,35 @@
using System.Linq;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.IntegrationTests.Shared.Performance;
[TestFixture]
[Category("Integration")]
[NonParallelizable]
public class WorkflowPerformanceComparisonMatrixTests
{
[Test]
[Category(WorkflowPerformanceCategories.Comparison)]
public void WorkflowBackendSignalRoundTripMatrix_WhenLatestArtifactsAreLoaded_ShouldWriteSixProfileComparison()
{
var result = WorkflowPerformanceComparisonMatrixWriter.BuildSixProfileSignalMatrix();
var artifacts = WorkflowPerformanceComparisonMatrixWriter.Write(result);
result.Columns.Should().ContainInOrder(
"Oracle",
"PostgreSQL",
"Mongo",
"Oracle+Redis",
"PostgreSQL+Redis",
"Mongo+Redis");
result.Rows.Should().NotBeEmpty();
result.Rows.Select(row => row.Section).Should().Contain("Serial Latency");
result.Rows.Select(row => row.Section).Should().Contain("Parallel Throughput");
result.IntegrityChecks.Should().OnlyContain(check => check.Passed);
artifacts.JsonPath.Should().NotBeNullOrWhiteSpace();
artifacts.MarkdownPath.Should().NotBeNullOrWhiteSpace();
}
}

View File

@@ -0,0 +1,32 @@
using System;
namespace StellaOps.Workflow.IntegrationTests.Shared.Performance;
public sealed record WorkflowSignalDrainTelemetry
{
public required int ProcessedCount { get; init; }
public required int TotalRounds { get; init; }
public required int IdleEmptyRounds { get; init; }
public required DateTime StartedAtUtc { get; init; }
public required DateTime CompletedAtUtc { get; init; }
public DateTime? FirstProcessedAtUtc { get; init; }
public DateTime? LastProcessedAtUtc { get; init; }
public double DurationMilliseconds =>
(CompletedAtUtc - StartedAtUtc).TotalMilliseconds;
public double? TimeToFirstProcessedMilliseconds =>
FirstProcessedAtUtc.HasValue
? (FirstProcessedAtUtc.Value - StartedAtUtc).TotalMilliseconds
: null;
public double? TimeToLastProcessedMilliseconds =>
LastProcessedAtUtc.HasValue
? (LastProcessedAtUtc.Value - StartedAtUtc).TotalMilliseconds
: null;
public double? DrainToIdleOverhangMilliseconds =>
LastProcessedAtUtc.HasValue
? (CompletedAtUtc - LastProcessedAtUtc.Value).TotalMilliseconds
: null;
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<NoWarn>CS8601;CS8602;CS8604;NU1015</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NUnit" Version="4.2.2" />
</ItemGroup>
<!-- TODO: These test files reference Serdica platform-specific bootstrap types (AddWorkflowPlatformServices,
Engine.Studio, IAuthorizationService, HealthCheckService, RedisDockerFixture) that are not in this repository.
Re-enable when those platform services are ported to Stella. -->
<ItemGroup>
<Compile Remove="WorkflowPlatformBootstrapTests.cs" />
<Compile Remove="WorkflowPlatformRedisSignalDriverBootstrapTests.cs" />
</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.DataStore.MongoDB\StellaOps.Workflow.DataStore.MongoDB.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.DataStore.PostgreSQL\StellaOps.Workflow.DataStore.PostgreSQL.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.DataStore.Oracle\StellaOps.Workflow.DataStore.Oracle.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Signaling.Redis\StellaOps.Workflow.Signaling.Redis.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Signaling.OracleAq\StellaOps.Workflow.Signaling.OracleAq.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,132 @@
using System.Collections.Generic;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.IntegrationTests.Shared.TransportProbes;
public sealed class TransportProbeWorkflowRequest
{
[WorkflowBusinessId]
public required string ProbeKey { get; init; }
}
public sealed class LegacyRabbitOutcomeProbeWorkflow
: IDeclarativeWorkflow<TransportProbeWorkflowRequest>
{
private static readonly LegacyRabbitAddress ProbeAddress = new("integration.legacy.probe");
public string WorkflowName => "IntegrationLegacyRabbitOutcomeProbe";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Integration Legacy Rabbit Outcome Probe";
public IReadOnlyCollection<string> WorkflowRoles => [];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<TransportProbeWorkflowRequest> Spec { get; } = WorkflowSpec.For<TransportProbeWorkflowRequest>()
.InitializeState(startRequest => new
{
probeKey = startRequest.ProbeKey,
outcome = "pending",
})
.StartWith(flow => flow
.Call(
"Invoke Legacy Rabbit",
ProbeAddress,
context => new { probeKey = context.StateValues["probeKey"].Get<string>() },
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete)
.Set("outcome", _ => "success")
.Complete())
.Build();
}
public sealed class MicroserviceOutcomeProbeWorkflow
: IDeclarativeWorkflow<TransportProbeWorkflowRequest>
{
private static readonly Address ProbeAddress = new("integration-probe", "probe.microservice");
public string WorkflowName => "IntegrationMicroserviceOutcomeProbe";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Integration Microservice Outcome Probe";
public IReadOnlyCollection<string> WorkflowRoles => [];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<TransportProbeWorkflowRequest> Spec { get; } = WorkflowSpec.For<TransportProbeWorkflowRequest>()
.InitializeState(startRequest => new
{
probeKey = startRequest.ProbeKey,
outcome = "pending",
})
.StartWith(flow => flow
.Call(
"Invoke Microservice",
ProbeAddress,
context => new { probeKey = context.StateValues["probeKey"].Get<string>() },
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete)
.Set("outcome", _ => "success")
.Complete())
.Build();
}
public sealed class GraphqlOutcomeProbeWorkflow
: IDeclarativeWorkflow<TransportProbeWorkflowRequest>
{
private static readonly GraphqlAddress ProbeAddress = new(
"stella",
"query IntegrationProbe($probeKey: String!) { probe(probeKey: $probeKey) { ok } }",
"IntegrationProbe");
public string WorkflowName => "IntegrationGraphqlOutcomeProbe";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Integration Graphql Outcome Probe";
public IReadOnlyCollection<string> WorkflowRoles => [];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<TransportProbeWorkflowRequest> Spec { get; } = WorkflowSpec.For<TransportProbeWorkflowRequest>()
.InitializeState(startRequest => new
{
probeKey = startRequest.ProbeKey,
outcome = "pending",
})
.StartWith(flow => flow
.QueryGraphql(
"Invoke Graphql",
ProbeAddress,
context => new { probeKey = context.StateValues["probeKey"].Get<string>() },
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete)
.Set("outcome", _ => "success")
.Complete())
.Build();
}
public sealed class HttpOutcomeProbeWorkflow
: IDeclarativeWorkflow<TransportProbeWorkflowRequest>
{
private static readonly HttpAddress ProbeAddress = new("integration-probe", "/probe/http");
public string WorkflowName => "IntegrationHttpOutcomeProbe";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Integration Http Outcome Probe";
public IReadOnlyCollection<string> WorkflowRoles => [];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<TransportProbeWorkflowRequest> Spec { get; } = WorkflowSpec.For<TransportProbeWorkflowRequest>()
.InitializeState(startRequest => new
{
probeKey = startRequest.ProbeKey,
outcome = "pending",
})
.StartWith(flow => flow
.Call(
"Invoke Http",
ProbeAddress,
context => new { probeKey = context.StateValues["probeKey"].Get<string>() },
WorkflowHandledBranchAction.Complete,
WorkflowHandledBranchAction.Complete)
.Set("outcome", _ => "success")
.Complete())
.Build();
}

View File

@@ -0,0 +1,236 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.IntegrationTests.Shared.TransportProbes;
internal static class TransportUnhandledProbeWorkflowSupport
{
public const string ProbeRoute = "integration/probes";
public const string ProbeTaskName = "Execute Probe";
public const string ProbeTaskType = "IntegrationProbe";
public static readonly string[] ProbeTaskRoles = ["INTEGRATION"];
public static Dictionary<string, JsonElement> BuildInitialState(
TransportProbeWorkflowRequest startRequest)
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["probeKey"] = startRequest.ProbeKey,
["outcome"] = "pending",
}.AsWorkflowJsonDictionary();
}
public static WorkflowHumanTaskDefinition<TransportProbeWorkflowRequest> CreateProbeTask(
Action<WorkflowFlowBuilder<TransportProbeWorkflowRequest>> onComplete)
{
return WorkflowHumanTask.For<TransportProbeWorkflowRequest>(
ProbeTaskName,
ProbeTaskType,
ProbeRoute,
ProbeTaskRoles)
.WithPayload(context => new
{
probeKey = context.StateValues["probeKey"].Get<string>(),
})
.OnComplete(onComplete);
}
}
public sealed class LegacyRabbitUnhandledStartProbeWorkflow
: IDeclarativeWorkflow<TransportProbeWorkflowRequest>
{
private static readonly LegacyRabbitAddress ProbeAddress = new("integration.legacy.probe");
public string WorkflowName => "IntegrationLegacyRabbitUnhandledStartProbe";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Integration Legacy Rabbit Unhandled Start Probe";
public IReadOnlyCollection<string> WorkflowRoles => [];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<TransportProbeWorkflowRequest> Spec { get; } = WorkflowSpec.For<TransportProbeWorkflowRequest>()
.InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState)
.StartWith(flow => flow
.Call(
"Invoke Legacy Rabbit",
ProbeAddress,
context => new { probeKey = context.StateValues["probeKey"].Get<string>() })
.Set("outcome", _ => "success")
.Complete())
.Build();
}
public sealed class LegacyRabbitUnhandledCompletionProbeWorkflow
: IDeclarativeWorkflow<TransportProbeWorkflowRequest>
{
private static readonly LegacyRabbitAddress ProbeAddress = new("integration.legacy.probe");
public string WorkflowName => "IntegrationLegacyRabbitUnhandledCompletionProbe";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Integration Legacy Rabbit Unhandled Completion Probe";
public IReadOnlyCollection<string> WorkflowRoles => [];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<TransportProbeWorkflowRequest> Spec { get; } = WorkflowSpec.For<TransportProbeWorkflowRequest>()
.InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState)
.AddTask(TransportUnhandledProbeWorkflowSupport.CreateProbeTask(flow => flow
.Call(
"Invoke Legacy Rabbit",
ProbeAddress,
context => new { probeKey = context.StateValues["probeKey"].Get<string>() })
.Set("outcome", _ => "success")
.Complete()))
.StartWith(flow => flow.ActivateTask(TransportUnhandledProbeWorkflowSupport.ProbeTaskName))
.Build();
}
public sealed class MicroserviceUnhandledStartProbeWorkflow
: IDeclarativeWorkflow<TransportProbeWorkflowRequest>
{
private static readonly Address ProbeAddress = new("integration-probe", "probe.microservice");
public string WorkflowName => "IntegrationMicroserviceUnhandledStartProbe";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Integration Microservice Unhandled Start Probe";
public IReadOnlyCollection<string> WorkflowRoles => [];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<TransportProbeWorkflowRequest> Spec { get; } = WorkflowSpec.For<TransportProbeWorkflowRequest>()
.InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState)
.StartWith(flow => flow
.Call(
"Invoke Microservice",
ProbeAddress,
context => new { probeKey = context.StateValues["probeKey"].Get<string>() })
.Set("outcome", _ => "success")
.Complete())
.Build();
}
public sealed class MicroserviceUnhandledCompletionProbeWorkflow
: IDeclarativeWorkflow<TransportProbeWorkflowRequest>
{
private static readonly Address ProbeAddress = new("integration-probe", "probe.microservice");
public string WorkflowName => "IntegrationMicroserviceUnhandledCompletionProbe";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Integration Microservice Unhandled Completion Probe";
public IReadOnlyCollection<string> WorkflowRoles => [];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<TransportProbeWorkflowRequest> Spec { get; } = WorkflowSpec.For<TransportProbeWorkflowRequest>()
.InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState)
.AddTask(TransportUnhandledProbeWorkflowSupport.CreateProbeTask(flow => flow
.Call(
"Invoke Microservice",
ProbeAddress,
context => new { probeKey = context.StateValues["probeKey"].Get<string>() })
.Set("outcome", _ => "success")
.Complete()))
.StartWith(flow => flow.ActivateTask(TransportUnhandledProbeWorkflowSupport.ProbeTaskName))
.Build();
}
public sealed class GraphqlUnhandledStartProbeWorkflow
: IDeclarativeWorkflow<TransportProbeWorkflowRequest>
{
private static readonly GraphqlAddress ProbeAddress = new(
"stella",
"query IntegrationProbe($probeKey: String!) { probe(probeKey: $probeKey) { ok } }",
"IntegrationProbe");
public string WorkflowName => "IntegrationGraphqlUnhandledStartProbe";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Integration Graphql Unhandled Start Probe";
public IReadOnlyCollection<string> WorkflowRoles => [];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<TransportProbeWorkflowRequest> Spec { get; } = WorkflowSpec.For<TransportProbeWorkflowRequest>()
.InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState)
.StartWith(flow => flow
.QueryGraphql(
"Invoke Graphql",
ProbeAddress,
context => new { probeKey = context.StateValues["probeKey"].Get<string>() })
.Set("outcome", _ => "success")
.Complete())
.Build();
}
public sealed class GraphqlUnhandledCompletionProbeWorkflow
: IDeclarativeWorkflow<TransportProbeWorkflowRequest>
{
private static readonly GraphqlAddress ProbeAddress = new(
"stella",
"query IntegrationProbe($probeKey: String!) { probe(probeKey: $probeKey) { ok } }",
"IntegrationProbe");
public string WorkflowName => "IntegrationGraphqlUnhandledCompletionProbe";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Integration Graphql Unhandled Completion Probe";
public IReadOnlyCollection<string> WorkflowRoles => [];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<TransportProbeWorkflowRequest> Spec { get; } = WorkflowSpec.For<TransportProbeWorkflowRequest>()
.InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState)
.AddTask(TransportUnhandledProbeWorkflowSupport.CreateProbeTask(flow => flow
.QueryGraphql(
"Invoke Graphql",
ProbeAddress,
context => new { probeKey = context.StateValues["probeKey"].Get<string>() })
.Set("outcome", _ => "success")
.Complete()))
.StartWith(flow => flow.ActivateTask(TransportUnhandledProbeWorkflowSupport.ProbeTaskName))
.Build();
}
public sealed class HttpUnhandledStartProbeWorkflow
: IDeclarativeWorkflow<TransportProbeWorkflowRequest>
{
private static readonly HttpAddress ProbeAddress = new("integration-probe", "/probe/http");
public string WorkflowName => "IntegrationHttpUnhandledStartProbe";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Integration Http Unhandled Start Probe";
public IReadOnlyCollection<string> WorkflowRoles => [];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<TransportProbeWorkflowRequest> Spec { get; } = WorkflowSpec.For<TransportProbeWorkflowRequest>()
.InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState)
.StartWith(flow => flow
.Call(
"Invoke Http",
ProbeAddress,
context => new { probeKey = context.StateValues["probeKey"].Get<string>() })
.Set("outcome", _ => "success")
.Complete())
.Build();
}
public sealed class HttpUnhandledCompletionProbeWorkflow
: IDeclarativeWorkflow<TransportProbeWorkflowRequest>
{
private static readonly HttpAddress ProbeAddress = new("integration-probe", "/probe/http");
public string WorkflowName => "IntegrationHttpUnhandledCompletionProbe";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Integration Http Unhandled Completion Probe";
public IReadOnlyCollection<string> WorkflowRoles => [];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
public WorkflowSpec<TransportProbeWorkflowRequest> Spec { get; } = WorkflowSpec.For<TransportProbeWorkflowRequest>()
.InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState)
.AddTask(TransportUnhandledProbeWorkflowSupport.CreateProbeTask(flow => flow
.Call(
"Invoke Http",
ProbeAddress,
context => new { probeKey = context.StateValues["probeKey"].Get<string>() })
.Set("outcome", _ => "success")
.Complete()))
.StartWith(flow => flow.ActivateTask(TransportUnhandledProbeWorkflowSupport.ProbeTaskName))
.Build();
}

View File

@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Workflow.Engine.Exceptions;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Engine.Constants;
using StellaOps.Workflow.Engine.Services;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.IntegrationTests.Shared;
public static class WorkflowIntegrationAssertions
{
public static async Task<StartWorkflowResponse> StartWorkflowOrFailWithInnerAsync(
WorkflowRuntimeService runtimeService,
StartWorkflowRequest request)
{
try
{
return await runtimeService.StartWorkflowAsync(request);
}
catch (BaseResultException exception) when (exception.InnerException is not null)
{
Assert.Fail(exception.InnerException.ToString());
throw;
}
}
public static async Task<WorkflowTaskSummary> GetSingleOpenTaskAsync(
WorkflowRuntimeService runtimeService,
string workflowInstanceId,
string? actorId = null,
IReadOnlyCollection<string>? actorRoles = null)
{
return (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = workflowInstanceId,
ActorId = actorId,
ActorRoles = actorRoles ?? Array.Empty<string>(),
Status = "Open",
})).Tasks.Should().ContainSingle().Subject;
}
public static async Task<WorkflowInstanceGetResponse> GetInstanceAsync(
WorkflowRuntimeService runtimeService,
string workflowInstanceId,
string? actorId = null,
IReadOnlyCollection<string>? actorRoles = null)
{
return await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = workflowInstanceId,
ActorId = actorId,
ActorRoles = actorRoles ?? Array.Empty<string>(),
});
}
public static async Task<WorkflowInstanceSummary> GetSingleInstanceSummaryAsync(
WorkflowRuntimeService runtimeService,
string workflowName,
string? businessReferenceKey = null,
string? status = null)
{
return (await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest
{
WorkflowName = workflowName,
BusinessReferenceKey = businessReferenceKey,
Status = status,
})).Instances.Should().ContainSingle().Subject;
}
public static async Task<WorkflowRuntimeStateRecord> GetRuntimeRecordAsync(
IWorkflowRuntimeStateStore runtimeStateStore,
string workflowInstanceId)
{
var runtimeRecord = await runtimeStateStore.GetAsync(workflowInstanceId);
runtimeRecord.Should().NotBeNull();
return runtimeRecord!;
}
public static async Task<WorkflowTaskSummary> GetSingleTaskByNameAsync(
WorkflowRuntimeService runtimeService,
string workflowInstanceId,
string taskName,
string? actorId = null,
IReadOnlyCollection<string>? actorRoles = null,
string? status = "Open")
{
return (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowInstanceId = workflowInstanceId,
ActorId = actorId,
ActorRoles = actorRoles ?? Array.Empty<string>(),
Status = status,
})).Tasks.Should().ContainSingle(x => string.Equals(x.TaskName, taskName, StringComparison.Ordinal)).Subject;
}
public static void AssertCompleted(WorkflowInstanceGetResponse instance)
{
instance.Instance.Status.Should().Be("Completed");
instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine);
instance.Instance.RuntimeStatus.Should().Be("Finished");
instance.RuntimeState.Should().NotBeNull();
instance.RuntimeState!.RuntimeStatus.Should().Be("Finished");
}
public static void AssertRunning(WorkflowInstanceGetResponse instance)
{
instance.Instance.Status.Should().Be("Open");
instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine);
instance.RuntimeState.Should().NotBeNull();
instance.RuntimeState!.RuntimeStatus.Should().NotBe("Finished");
}
public static void AssertContinuationInstance(
WorkflowInstanceSummary instance,
string workflowName,
string expectedStatus)
{
instance.WorkflowName.Should().Be(workflowName);
instance.Status.Should().Be(expectedStatus);
instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine);
}
public static void AssertTaskNames(
WorkflowInstanceGetResponse instance,
params string[] taskNames)
{
instance.Tasks.Select(x => x.TaskName).Should().BeEquivalentTo(taskNames);
}
public static void AssertTransportFailure(BaseResultException exception)
{
exception.MessageId.Should().Be(MessageKeys.WorkflowTransportFailed);
exception.InnerException.Should().BeNull();
}
public static void AssertRuntimeTimeout(BaseResultException exception)
{
exception.MessageId.Should().Be(MessageKeys.WorkflowRuntimeFailed);
exception.InnerException.Should().BeOfType<TimeoutException>();
}
}

View File

@@ -0,0 +1,253 @@
using System.Collections.Generic;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Execution;
using StellaOps.Workflow.Engine.Hosting;
using StellaOps.Workflow.Engine.Scheduling;
using StellaOps.Workflow.Engine.Signaling;
using StellaOps.Workflow.DataStore.MongoDB;
using StellaOps.Workflow.Engine.Services;
using StellaOps.Workflow.Engine.Studio;
using StellaOps.Workflow.DataStore.PostgreSQL;
using FluentAssertions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using NUnit.Framework;
namespace StellaOps.Workflow.IntegrationTests.Shared;
[TestFixture]
[Category("Integration")]
public class WorkflowPlatformBootstrapTests
{
[Test]
public void AddWorkflowPlatformServices_WhenMinimumConfigurationProvided_ShouldBuildServiceProvider()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["RabbitConfig:HostName"] = "localhost",
["RabbitConfig:UserName"] = "guest",
["RabbitConfig:Password"] = "guest",
["RabbitConfig:Port"] = "5672",
["RabbitConfig:Exchange"] = "workflow",
["RabbitConfig:RequestQueueName"] = "workflow.request",
["MicroserviceConfig:SectionName"] = "Workflow",
["MicroserviceConfig:ExchangeName"] = "workflow",
["ConnectionStrings:DefaultConnection"] = "DATA SOURCE=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=localhost)(PORT=1521))(CONNECT_DATA=(SID=orcl1)));USER ID=srd_wfklw;PASSWORD=srd_wfklw",
["PluginsConfig:PluginsPrefix"] = "StellaOps.Workflow",
["PluginsConfig:PluginsDirectory"] = @"..\..\StellaOps.Workflow\PluginBinaries",
["PluginsConfig:PluginsOrder:0"] = "StellaOps.Workflow.Engine.AssignPermissions.Generic",
["PluginsConfig:PluginsOrder:1"] = "StellaOps.Workflow.DataStore.Oracle",
["PluginsConfig:PluginsOrder:2"] = "StellaOps.Workflow.Engine.Transport.Microservice",
["PluginsConfig:PluginsOrder:3"] = "StellaOps.Workflow.Engine.Transport.LegacyRabbit",
["PluginsConfig:PluginsOrder:4"] = "StellaOps.Workflow.Engine.Transport.GraphQL",
["PluginsConfig:PluginsOrder:5"] = "StellaOps.Workflow.Engine.Transport.Http",
["PluginsConfig:PluginsOrder:6"] = "StellaOps.Workflow.Engine.Workflows.Bulstrad",
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddWorkflowPlatformServices(configuration);
services.Replace(ServiceDescriptor.Scoped<IWorkflowMicroserviceTransport, NullWorkflowMicroserviceTransport>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowLegacyRabbitTransport, NullWorkflowLegacyRabbitTransport>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowGraphqlTransport, NullWorkflowGraphqlTransport>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowHttpTransport, NullWorkflowHttpTransport>());
using var provider = services.BuildServiceProvider();
provider.GetRequiredService<ISerdicaWorkflowCatalog>().Should().NotBeNull();
provider
.GetRequiredService<ISerdicaWorkflowCatalog>()
.GetDefinition("ApproveApplication", "1.0.0")
.Should()
.NotBeNull();
provider.GetRequiredService<WorkflowRuntimeService>().Should().NotBeNull();
provider.GetRequiredService<WorkflowDiagramService>().Should().NotBeNull();
provider.GetRequiredService<IWorkflowRuntimeOrchestrator>().Should().BeOfType<ConfiguredWorkflowRuntimeOrchestrator>();
provider.GetRequiredService<IOptions<WorkflowRuntimeOptions>>().Value.DefaultProvider.Should().Be(WorkflowRuntimeProviderNames.Engine);
provider.GetRequiredService<IWorkflowSignalBus>().Should().BeOfType<WorkflowSignalBusBridge>();
provider.GetRequiredService<IWorkflowScheduleBus>().Should().BeOfType<WorkflowScheduleBusBridge>();
provider.GetRequiredService<IWorkflowSignalStore>().Should().BeOfType<OracleAqWorkflowSignalBus>();
provider.GetRequiredService<IWorkflowSignalDriver>().Should().BeOfType<OracleAqWorkflowSignalBus>();
provider.GetRequiredService<IWorkflowSignalScheduler>().Should().BeOfType<OracleAqWorkflowScheduleBus>();
var runtimeStateStore = provider.GetRequiredService<IWorkflowRuntimeStateStore>();
runtimeStateStore.GetType().FullName.Should().Be("StellaOps.Workflow.DataStore.Oracle.OracleWorkflowRuntimeStateStore");
runtimeStateStore.GetType().Assembly.GetName().Name.Should().Be("StellaOps.Workflow.DataStore.Oracle");
var hostedJobLockService = provider.GetRequiredService<IWorkflowHostedJobLockService>();
hostedJobLockService.GetType().FullName.Should().Be("StellaOps.Workflow.DataStore.Oracle.OracleWorkflowHostedJobLockService");
hostedJobLockService.GetType().Assembly.GetName().Name.Should().Be("StellaOps.Workflow.DataStore.Oracle");
provider.GetRequiredService<IAuthorizationService>().Should().NotBeNull();
provider.GetRequiredService<HealthCheckService>().Should().NotBeNull();
}
[Test]
public void WorkflowDefinitionCatalog_ShouldContainApproveApplicationVersionedDefinition()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["RabbitConfig:HostName"] = "localhost",
["RabbitConfig:UserName"] = "guest",
["RabbitConfig:Password"] = "guest",
["RabbitConfig:Port"] = "5672",
["RabbitConfig:Exchange"] = "workflow",
["RabbitConfig:RequestQueueName"] = "workflow.request",
["MicroserviceConfig:SectionName"] = "Workflow",
["MicroserviceConfig:ExchangeName"] = "workflow",
["ConnectionStrings:DefaultConnection"] = "DATA SOURCE=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=localhost)(PORT=1521))(CONNECT_DATA=(SID=orcl1)));USER ID=srd_wfklw;PASSWORD=srd_wfklw",
["PluginsConfig:PluginsPrefix"] = "StellaOps.Workflow",
["PluginsConfig:PluginsDirectory"] = @"..\..\StellaOps.Workflow\PluginBinaries",
["PluginsConfig:PluginsOrder:0"] = "StellaOps.Workflow.Engine.AssignPermissions.Generic",
["PluginsConfig:PluginsOrder:1"] = "StellaOps.Workflow.DataStore.Oracle",
["PluginsConfig:PluginsOrder:2"] = "StellaOps.Workflow.Engine.Transport.Microservice",
["PluginsConfig:PluginsOrder:3"] = "StellaOps.Workflow.Engine.Transport.LegacyRabbit",
["PluginsConfig:PluginsOrder:4"] = "StellaOps.Workflow.Engine.Transport.GraphQL",
["PluginsConfig:PluginsOrder:5"] = "StellaOps.Workflow.Engine.Transport.Http",
["PluginsConfig:PluginsOrder:6"] = "StellaOps.Workflow.Engine.Workflows.Bulstrad",
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddWorkflowPlatformServices(configuration);
services.Replace(ServiceDescriptor.Scoped<IWorkflowMicroserviceTransport, NullWorkflowMicroserviceTransport>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowLegacyRabbitTransport, NullWorkflowLegacyRabbitTransport>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowGraphqlTransport, NullWorkflowGraphqlTransport>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowHttpTransport, NullWorkflowHttpTransport>());
using var provider = services.BuildServiceProvider();
provider
.GetRequiredService<ISerdicaWorkflowCatalog>()
.GetDefinition("ApproveApplication", "1.0.0")
.Should()
.NotBeNull();
}
[Test]
public void AddWorkflowPlatformServices_WhenPostgresBackendPluginIsSelected_ShouldBuildBackendNeutralProvider()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["WorkflowBackend:Provider"] = WorkflowBackendNames.Postgres,
["WorkflowBackend:Postgres:ConnectionStringName"] = "WorkflowPostgres",
["WorkflowBackend:Postgres:SchemaName"] = "wf_bootstrap_test",
["ConnectionStrings:WorkflowPostgres"] = "Host=localhost;Port=5432;Database=workflow;Username=postgres;Password=postgres",
["RabbitConfig:HostName"] = "localhost",
["RabbitConfig:UserName"] = "guest",
["RabbitConfig:Password"] = "guest",
["RabbitConfig:Port"] = "5672",
["RabbitConfig:Exchange"] = "workflow",
["RabbitConfig:RequestQueueName"] = "workflow.request",
["MicroserviceConfig:SectionName"] = "Workflow",
["MicroserviceConfig:ExchangeName"] = "workflow",
["ConnectionStrings:DefaultConnection"] = "DATA SOURCE=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=localhost)(PORT=1521))(CONNECT_DATA=(SID=orcl1)));USER ID=srd_wfklw;PASSWORD=srd_wfklw",
["PluginsConfig:PluginsPrefix"] = "StellaOps.Workflow",
["PluginsConfig:PluginsDirectory"] = @"..\..\StellaOps.Workflow\PluginBinaries",
["PluginsConfig:PluginsOrder:0"] = "StellaOps.Workflow.Engine.AssignPermissions.Generic",
["PluginsConfig:PluginsOrder:1"] = "StellaOps.Workflow.DataStore.PostgreSQL",
["PluginsConfig:PluginsOrder:2"] = "StellaOps.Workflow.Engine.Transport.Microservice",
["PluginsConfig:PluginsOrder:3"] = "StellaOps.Workflow.Engine.Transport.LegacyRabbit",
["PluginsConfig:PluginsOrder:4"] = "StellaOps.Workflow.Engine.Transport.GraphQL",
["PluginsConfig:PluginsOrder:5"] = "StellaOps.Workflow.Engine.Transport.Http",
["PluginsConfig:PluginsOrder:6"] = "StellaOps.Workflow.Engine.Workflows.Bulstrad",
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddWorkflowPlatformServices(configuration);
services.Replace(ServiceDescriptor.Scoped<IWorkflowMicroserviceTransport, NullWorkflowMicroserviceTransport>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowLegacyRabbitTransport, NullWorkflowLegacyRabbitTransport>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowGraphqlTransport, NullWorkflowGraphqlTransport>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowHttpTransport, NullWorkflowHttpTransport>());
using var provider = services.BuildServiceProvider();
provider.GetRequiredService<IOptions<WorkflowBackendOptions>>().Value.Provider.Should().Be(WorkflowBackendNames.Postgres);
var runtimeStateStore = provider.GetRequiredService<IWorkflowRuntimeStateStore>();
runtimeStateStore.GetType().FullName.Should().Be(typeof(PostgresWorkflowRuntimeStateStore).FullName);
runtimeStateStore.GetType().Assembly.GetName().Name.Should().Be("StellaOps.Workflow.DataStore.PostgreSQL");
var hostedJobLockService = provider.GetRequiredService<IWorkflowHostedJobLockService>();
hostedJobLockService.GetType().FullName.Should().Be(typeof(PostgresWorkflowHostedJobLockService).FullName);
hostedJobLockService.GetType().Assembly.GetName().Name.Should().Be("StellaOps.Workflow.DataStore.PostgreSQL");
provider.GetRequiredService<IWorkflowProjectionStore>().GetType().FullName.Should().Be(typeof(PostgresWorkflowProjectionStore).FullName);
provider.GetRequiredService<IWorkflowProjectionRetentionStore>().GetType().FullName.Should().Be(typeof(PostgresWorkflowProjectionRetentionStore).FullName);
provider.GetRequiredService<IWorkflowMutationCoordinator>().GetType().FullName.Should().Be(typeof(PostgresWorkflowMutationCoordinator).FullName);
provider.GetRequiredService<IWorkflowSignalBus>().Should().BeOfType<WorkflowSignalBusBridge>();
provider.GetRequiredService<IWorkflowScheduleBus>().Should().BeOfType<WorkflowScheduleBusBridge>();
provider.GetRequiredService<IWorkflowSignalStore>().GetType().FullName.Should().Be(typeof(PostgresWorkflowSignalStore).FullName);
provider.GetRequiredService<IWorkflowSignalDriver>().GetType().FullName.Should().Be(typeof(PostgresWorkflowSignalBus).FullName);
provider.GetRequiredService<IWorkflowSignalScheduler>().GetType().FullName.Should().Be(typeof(PostgresWorkflowScheduleBus).FullName);
provider.GetRequiredService<HealthCheckService>().Should().NotBeNull();
}
[Test]
public void AddWorkflowPlatformServices_WhenMongoBackendPluginIsSelected_ShouldBuildBackendNeutralProvider()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["WorkflowBackend:Provider"] = WorkflowBackendNames.Mongo,
[$"{WorkflowStoreMongoOptions.SectionName}:ConnectionStringName"] = "WorkflowMongo",
[$"{WorkflowStoreMongoOptions.SectionName}:DatabaseName"] = "wf_bootstrap_test",
["ConnectionStrings:WorkflowMongo"] = "mongodb://127.0.0.1:27017/?replicaSet=rs0&directConnection=true",
["RabbitConfig:HostName"] = "localhost",
["RabbitConfig:UserName"] = "guest",
["RabbitConfig:Password"] = "guest",
["RabbitConfig:Port"] = "5672",
["RabbitConfig:Exchange"] = "workflow",
["RabbitConfig:RequestQueueName"] = "workflow.request",
["MicroserviceConfig:SectionName"] = "Workflow",
["MicroserviceConfig:ExchangeName"] = "workflow",
["ConnectionStrings:DefaultConnection"] = "DATA SOURCE=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=localhost)(PORT=1521))(CONNECT_DATA=(SID=orcl1)));USER ID=srd_wfklw;PASSWORD=srd_wfklw",
["PluginsConfig:PluginsPrefix"] = "StellaOps.Workflow",
["PluginsConfig:PluginsDirectory"] = @"..\..\StellaOps.Workflow\PluginBinaries",
["PluginsConfig:PluginsOrder:0"] = "StellaOps.Workflow.Engine.AssignPermissions.Generic",
["PluginsConfig:PluginsOrder:1"] = "StellaOps.Workflow.DataStore.MongoDB",
["PluginsConfig:PluginsOrder:2"] = "StellaOps.Workflow.Engine.Transport.Microservice",
["PluginsConfig:PluginsOrder:3"] = "StellaOps.Workflow.Engine.Transport.LegacyRabbit",
["PluginsConfig:PluginsOrder:4"] = "StellaOps.Workflow.Engine.Transport.GraphQL",
["PluginsConfig:PluginsOrder:5"] = "StellaOps.Workflow.Engine.Transport.Http",
["PluginsConfig:PluginsOrder:6"] = "StellaOps.Workflow.Engine.Workflows.Bulstrad",
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddWorkflowPlatformServices(configuration);
services.Replace(ServiceDescriptor.Scoped<IWorkflowMicroserviceTransport, NullWorkflowMicroserviceTransport>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowLegacyRabbitTransport, NullWorkflowLegacyRabbitTransport>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowGraphqlTransport, NullWorkflowGraphqlTransport>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowHttpTransport, NullWorkflowHttpTransport>());
using var provider = services.BuildServiceProvider();
provider.GetRequiredService<IOptions<WorkflowBackendOptions>>().Value.Provider.Should().Be(WorkflowBackendNames.Mongo);
provider.GetRequiredService<IWorkflowRuntimeStateStore>().GetType().FullName.Should().Be(typeof(MongoWorkflowRuntimeStateStore).FullName);
provider.GetRequiredService<IWorkflowHostedJobLockService>().GetType().FullName.Should().Be(typeof(MongoWorkflowHostedJobLockService).FullName);
provider.GetRequiredService<IWorkflowProjectionStore>().GetType().FullName.Should().Be(typeof(MongoWorkflowProjectionStore).FullName);
provider.GetRequiredService<IWorkflowProjectionRetentionStore>().GetType().FullName.Should().Be(typeof(MongoWorkflowProjectionRetentionStore).FullName);
provider.GetRequiredService<IWorkflowMutationCoordinator>().GetType().FullName.Should().Be(typeof(MongoWorkflowMutationCoordinator).FullName);
provider.GetRequiredService<IWorkflowSignalBus>().Should().BeOfType<WorkflowSignalBusBridge>();
provider.GetRequiredService<IWorkflowScheduleBus>().Should().BeOfType<WorkflowScheduleBusBridge>();
provider.GetRequiredService<IWorkflowSignalStore>().GetType().FullName.Should().Be(typeof(MongoWorkflowSignalStore).FullName);
provider.GetRequiredService<IWorkflowSignalDriver>().GetType().FullName.Should().Be(typeof(MongoWorkflowSignalBus).FullName);
provider.GetRequiredService<IWorkflowSignalScheduler>().GetType().FullName.Should().Be(typeof(MongoWorkflowScheduleBus).FullName);
provider.GetRequiredService<HealthCheckService>().Should().NotBeNull();
}
}

View File

@@ -0,0 +1,137 @@
using System.Collections.Generic;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Scheduling;
using StellaOps.Workflow.Engine.Signaling;
using StellaOps.Workflow.Signaling.Redis;
using StellaOps.Workflow.DataStore.Oracle;
using StellaOps.Workflow.DataStore.PostgreSQL;
using StellaOps.Workflow.Engine.Services;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NUnit.Framework;
namespace StellaOps.Workflow.IntegrationTests.Shared;
[TestFixture]
[Category("Integration")]
public class WorkflowPlatformRedisSignalDriverBootstrapTests
{
private RedisDockerFixture? redisFixture;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
redisFixture = new RedisDockerFixture();
await redisFixture.StartOrIgnoreAsync();
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
redisFixture?.Dispose();
}
[Test]
public void AddWorkflowPlatformServices_WhenPostgresAndRedisSignalDriverAreSelected_ShouldBuildProvider()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["WorkflowBackend:Provider"] = WorkflowBackendNames.Postgres,
["WorkflowBackend:Postgres:ConnectionStringName"] = "WorkflowPostgres",
["WorkflowBackend:Postgres:SchemaName"] = "wf_bootstrap_test",
["WorkflowSignalDriver:Provider"] = WorkflowSignalDriverNames.Redis,
["WorkflowSignalDriver:Redis:ChannelName"] = "stella:test:bootstrap",
["RedisConfig:ServerUrl"] = redisFixture!.ConnectionString,
["ConnectionStrings:WorkflowPostgres"] = "Host=localhost;Port=5432;Database=workflow;Username=postgres;Password=postgres",
["RabbitConfig:HostName"] = "localhost",
["RabbitConfig:UserName"] = "guest",
["RabbitConfig:Password"] = "guest",
["RabbitConfig:Port"] = "5672",
["RabbitConfig:Exchange"] = "workflow",
["RabbitConfig:RequestQueueName"] = "workflow.request",
["MicroserviceConfig:SectionName"] = "Workflow",
["MicroserviceConfig:ExchangeName"] = "workflow",
["ConnectionStrings:DefaultConnection"] = "DATA SOURCE=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=localhost)(PORT=1521))(CONNECT_DATA=(SID=orcl1)));USER ID=srd_wfklw;PASSWORD=srd_wfklw",
["PluginsConfig:PluginsPrefix"] = "StellaOps.Workflow",
["PluginsConfig:PluginsDirectory"] = @"..\..\StellaOps.Workflow\PluginBinaries",
["PluginsConfig:PluginsOrder:0"] = "StellaOps.Workflow.Engine.AssignPermissions.Generic",
["PluginsConfig:PluginsOrder:1"] = "StellaOps.Workflow.DataStore.PostgreSQL",
["PluginsConfig:PluginsOrder:2"] = "StellaOps.Workflow.Signaling.Redis",
["PluginsConfig:PluginsOrder:3"] = "StellaOps.Workflow.Engine.Transport.Microservice",
["PluginsConfig:PluginsOrder:4"] = "StellaOps.Workflow.Engine.Transport.LegacyRabbit",
["PluginsConfig:PluginsOrder:5"] = "StellaOps.Workflow.Engine.Transport.GraphQL",
["PluginsConfig:PluginsOrder:6"] = "StellaOps.Workflow.Engine.Transport.Http",
["PluginsConfig:PluginsOrder:7"] = "StellaOps.Workflow.Engine.Workflows.Bulstrad",
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddWorkflowPlatformServices(configuration);
using var provider = services.BuildServiceProvider();
provider.GetRequiredService<IWorkflowSignalBus>().Should().BeOfType<WorkflowSignalBusBridge>();
provider.GetRequiredService<IWorkflowScheduleBus>().Should().BeOfType<WorkflowScheduleBusBridge>();
provider.GetRequiredService<IWorkflowSignalStore>().GetType().FullName.Should().Be(typeof(PostgresWorkflowSignalStore).FullName);
provider.GetRequiredService<IWorkflowSignalClaimStore>().GetType().FullName.Should().Be(typeof(PostgresWorkflowSignalStore).FullName);
provider.GetRequiredService<IWorkflowSignalDriver>().GetType().FullName.Should().Be(typeof(RedisWorkflowSignalDriver).FullName);
provider.GetRequiredService<IWorkflowWakeOutbox>().Should().BeOfType<NullWorkflowWakeOutbox>();
provider.GetRequiredService<IWorkflowWakeOutboxReceiver>().Should().BeOfType<NullWorkflowWakeOutboxReceiver>();
provider.GetServices<IHostedService>()
.Should()
.NotContain(service => service.GetType().FullName == typeof(RedisWorkflowWakeOutboxPublisherHostedService).FullName);
}
[Test]
public void AddWorkflowPlatformServices_WhenOracleAndRedisSignalDriverAreSelected_ShouldBuildProvider()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["WorkflowBackend:Provider"] = WorkflowBackendNames.Oracle,
["WorkflowSignalDriver:Provider"] = WorkflowSignalDriverNames.Redis,
["WorkflowSignalDriver:Redis:ChannelName"] = "stella:test:oracle:bootstrap",
["RedisConfig:ServerUrl"] = redisFixture!.ConnectionString,
["RabbitConfig:HostName"] = "localhost",
["RabbitConfig:UserName"] = "guest",
["RabbitConfig:Password"] = "guest",
["RabbitConfig:Port"] = "5672",
["RabbitConfig:Exchange"] = "workflow",
["RabbitConfig:RequestQueueName"] = "workflow.request",
["MicroserviceConfig:SectionName"] = "Workflow",
["MicroserviceConfig:ExchangeName"] = "workflow",
["ConnectionStrings:DefaultConnection"] = "DATA SOURCE=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=localhost)(PORT=1521))(CONNECT_DATA=(SID=orcl1)));USER ID=srd_wfklw;PASSWORD=srd_wfklw",
["PluginsConfig:PluginsPrefix"] = "StellaOps.Workflow",
["PluginsConfig:PluginsDirectory"] = @"..\..\StellaOps.Workflow\PluginBinaries",
["PluginsConfig:PluginsOrder:0"] = "StellaOps.Workflow.Engine.AssignPermissions.Generic",
["PluginsConfig:PluginsOrder:1"] = "StellaOps.Workflow.DataStore.Oracle",
["PluginsConfig:PluginsOrder:2"] = "StellaOps.Workflow.Signaling.Redis",
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddWorkflowPlatformServices(configuration);
using var provider = services.BuildServiceProvider();
provider.GetRequiredService<IWorkflowSignalBus>().Should().BeOfType<WorkflowSignalBusBridge>();
provider.GetRequiredService<IWorkflowScheduleBus>().Should().BeOfType<WorkflowScheduleBusBridge>();
provider.GetRequiredService<IWorkflowSignalStore>().GetType().FullName.Should().Be(typeof(OracleAqWorkflowSignalBus).FullName);
provider.GetRequiredService<IWorkflowSignalClaimStore>().GetType().FullName.Should().Be(typeof(OracleAqWorkflowSignalBus).FullName);
provider.GetRequiredService<IWorkflowSignalDriver>().GetType().FullName.Should().Be(typeof(RedisWorkflowSignalDriver).FullName);
provider.GetRequiredService<IWorkflowWakeOutbox>().Should().BeOfType<NullWorkflowWakeOutbox>();
provider.GetRequiredService<IWorkflowWakeOutboxReceiver>().Should().BeOfType<NullWorkflowWakeOutboxReceiver>();
provider.GetServices<IHostedService>()
.Should()
.NotContain(service => service.GetType().FullName == typeof(RedisWorkflowWakeOutboxPublisherHostedService).FullName);
}
}

View File

@@ -0,0 +1,411 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.IntegrationTests.Shared;
public sealed class WorkflowTransportScripts
{
public ScriptedWorkflowLegacyRabbitTransport LegacyRabbit { get; } = new();
public ScriptedWorkflowMicroserviceTransport Microservice { get; } = new();
public ScriptedWorkflowGraphqlTransport Graphql { get; } = new();
public ScriptedWorkflowHttpTransport Http { get; } = new();
}
public sealed class ScriptedWorkflowLegacyRabbitTransport
: ScriptedTransportBase<WorkflowLegacyRabbitRequest, WorkflowMicroserviceResponse>,
IWorkflowLegacyRabbitTransport
{
public ScriptedWorkflowLegacyRabbitTransport Respond(
string command,
object? payload,
WorkflowLegacyRabbitMode mode = WorkflowLegacyRabbitMode.Envelope)
{
EnqueueResponse(BuildKey(command, mode), new WorkflowMicroserviceResponse
{
Succeeded = true,
Payload = payload,
});
return this;
}
public ScriptedWorkflowLegacyRabbitTransport Fail(
string command,
string error,
WorkflowLegacyRabbitMode mode = WorkflowLegacyRabbitMode.Envelope,
object? payload = null)
{
EnqueueResponse(BuildKey(command, mode), new WorkflowMicroserviceResponse
{
Succeeded = false,
Error = error,
Payload = payload,
});
return this;
}
public ScriptedWorkflowLegacyRabbitTransport Throw(
string command,
Exception exception,
WorkflowLegacyRabbitMode mode = WorkflowLegacyRabbitMode.Envelope)
{
EnqueueException(BuildKey(command, mode), exception);
return this;
}
public ScriptedWorkflowLegacyRabbitTransport Timeout(
string command,
WorkflowLegacyRabbitMode mode = WorkflowLegacyRabbitMode.Envelope)
{
return Throw(
command,
new TimeoutException($"Timeout for legacy Rabbit command '{command}' in mode '{mode}'."),
mode);
}
public Task<WorkflowMicroserviceResponse> ExecuteAsync(
WorkflowLegacyRabbitRequest request,
CancellationToken cancellationToken = default)
{
return ExecuteAsync(request, BuildKey(request.Command, request.Mode));
}
protected override WorkflowMicroserviceResponse BuildMissingResponse(WorkflowLegacyRabbitRequest request, string key)
{
return new WorkflowMicroserviceResponse
{
Succeeded = false,
Error = $"No scripted legacy Rabbit response configured for {key}.",
};
}
private static string BuildKey(string command, WorkflowLegacyRabbitMode mode)
{
return $"{mode}:{command}";
}
}
public sealed class ScriptedWorkflowMicroserviceTransport
: ScriptedTransportBase<WorkflowMicroserviceRequest, WorkflowMicroserviceResponse>,
IWorkflowMicroserviceTransport
{
public ScriptedWorkflowMicroserviceTransport Respond(
string microserviceName,
string command,
object? payload)
{
EnqueueResponse(BuildKey(microserviceName, command), new WorkflowMicroserviceResponse
{
Succeeded = true,
Payload = payload,
});
return this;
}
public ScriptedWorkflowMicroserviceTransport Fail(
string microserviceName,
string command,
string error,
object? payload = null)
{
EnqueueResponse(BuildKey(microserviceName, command), new WorkflowMicroserviceResponse
{
Succeeded = false,
Error = error,
Payload = payload,
});
return this;
}
public ScriptedWorkflowMicroserviceTransport Throw(
string microserviceName,
string command,
Exception exception)
{
EnqueueException(BuildKey(microserviceName, command), exception);
return this;
}
public ScriptedWorkflowMicroserviceTransport Timeout(string microserviceName, string command)
{
return Throw(
microserviceName,
command,
new TimeoutException($"Timeout for microservice command '{microserviceName}.{command}'."));
}
public Task<WorkflowMicroserviceResponse> ExecuteAsync(
WorkflowMicroserviceRequest request,
CancellationToken cancellationToken = default)
{
return ExecuteAsync(request, BuildKey(request.MicroserviceName, request.Command));
}
protected override WorkflowMicroserviceResponse BuildMissingResponse(WorkflowMicroserviceRequest request, string key)
{
return new WorkflowMicroserviceResponse
{
Succeeded = false,
Error = $"No scripted microservice response configured for {key}.",
};
}
private static string BuildKey(string microserviceName, string command)
{
return $"{microserviceName}:{command}";
}
}
public sealed class ScriptedWorkflowGraphqlTransport
: ScriptedTransportBase<WorkflowGraphqlRequest, WorkflowGraphqlResponse>,
IWorkflowGraphqlTransport
{
public ScriptedWorkflowGraphqlTransport Respond(
string target,
string query,
object? payload,
string? operationName = null)
{
EnqueueResponse(BuildKey(target, query, operationName), new WorkflowGraphqlResponse
{
Succeeded = true,
JsonPayload = payload is null ? null : JsonSerializer.Serialize(payload),
});
return this;
}
public ScriptedWorkflowGraphqlTransport Fail(
string target,
string query,
string error,
string? operationName = null,
object? payload = null)
{
EnqueueResponse(BuildKey(target, query, operationName), new WorkflowGraphqlResponse
{
Succeeded = false,
Error = error,
JsonPayload = payload is null ? null : JsonSerializer.Serialize(payload),
});
return this;
}
public ScriptedWorkflowGraphqlTransport Throw(
string target,
string query,
Exception exception,
string? operationName = null)
{
EnqueueException(BuildKey(target, query, operationName), exception);
return this;
}
public ScriptedWorkflowGraphqlTransport Timeout(
string target,
string query,
string? operationName = null)
{
return Throw(
target,
query,
new TimeoutException($"Timeout for GraphQL request '{target}:{operationName ?? "<none>"}'."),
operationName);
}
public Task<WorkflowGraphqlResponse> ExecuteAsync(
WorkflowGraphqlRequest request,
CancellationToken cancellationToken = default)
{
return ExecuteAsync(request, BuildKey(request.Target, request.Query, request.OperationName));
}
protected override WorkflowGraphqlResponse BuildMissingResponse(WorkflowGraphqlRequest request, string key)
{
return new WorkflowGraphqlResponse
{
Succeeded = false,
Error = $"No scripted GraphQL response configured for {key}.",
};
}
private static string BuildKey(string target, string query, string? operationName)
{
return $"{target}:{operationName ?? "<none>"}:{query}";
}
}
public sealed class ScriptedWorkflowHttpTransport
: ScriptedTransportBase<WorkflowHttpRequest, WorkflowHttpResponse>,
IWorkflowHttpTransport
{
public ScriptedWorkflowHttpTransport Respond(
string target,
string path,
object? payload,
string method = "POST",
int statusCode = 200)
{
EnqueueResponse(BuildKey(target, method, path), new WorkflowHttpResponse
{
Succeeded = true,
StatusCode = statusCode,
JsonPayload = payload is null ? null : JsonSerializer.Serialize(payload),
});
return this;
}
public ScriptedWorkflowHttpTransport Fail(
string target,
string path,
string error,
string method = "POST",
int statusCode = 500,
object? payload = null)
{
EnqueueResponse(BuildKey(target, method, path), new WorkflowHttpResponse
{
Succeeded = false,
StatusCode = statusCode,
Error = error,
JsonPayload = payload is null ? null : JsonSerializer.Serialize(payload),
});
return this;
}
public ScriptedWorkflowHttpTransport Throw(
string target,
string path,
Exception exception,
string method = "POST")
{
EnqueueException(BuildKey(target, method, path), exception);
return this;
}
public ScriptedWorkflowHttpTransport Timeout(
string target,
string path,
string method = "POST")
{
return Throw(
target,
path,
new TimeoutException($"Timeout for HTTP request '{method} {target}:{path}'."),
method);
}
public Task<WorkflowHttpResponse> ExecuteAsync(
WorkflowHttpRequest request,
CancellationToken cancellationToken = default)
{
return ExecuteAsync(request, BuildKey(request.Target, request.Method, request.Path));
}
protected override WorkflowHttpResponse BuildMissingResponse(WorkflowHttpRequest request, string key)
{
return new WorkflowHttpResponse
{
Succeeded = false,
Error = $"No scripted HTTP response configured for {key}.",
};
}
private static string BuildKey(string target, string method, string path)
{
return $"{method.Trim().ToUpperInvariant()}:{target}:{path}";
}
}
public abstract class ScriptedTransportBase<TRequest, TResponse>
{
private readonly Dictionary<string, ScriptedCallSequence<TResponse>> scripts = new(StringComparer.OrdinalIgnoreCase);
private readonly object invocationSync = new();
public List<TRequest> Invocations { get; } = [];
protected void EnqueueResponse(string key, TResponse response)
{
GetSequence(key).EnqueueResponse(response);
}
protected void EnqueueException(string key, Exception exception)
{
GetSequence(key).EnqueueException(exception);
}
protected Task<TResponse> ExecuteAsync(TRequest request, string key)
{
lock (invocationSync)
{
Invocations.Add(request);
}
try
{
if (scripts.TryGetValue(key, out var sequence))
{
return Task.FromResult(sequence.ResolveNext());
}
return Task.FromResult(BuildMissingResponse(request, key));
}
catch (Exception exception)
{
return Task.FromException<TResponse>(exception);
}
}
protected abstract TResponse BuildMissingResponse(TRequest request, string key);
private ScriptedCallSequence<TResponse> GetSequence(string key)
{
if (!scripts.TryGetValue(key, out var sequence))
{
sequence = new ScriptedCallSequence<TResponse>();
scripts[key] = sequence;
}
return sequence;
}
}
internal sealed class ScriptedCallSequence<TResponse>
{
private readonly Queue<Func<TResponse>> outcomes = [];
private readonly object sync = new();
public void EnqueueResponse(TResponse response)
{
lock (sync)
{
outcomes.Enqueue(() => response);
}
}
public void EnqueueException(Exception exception)
{
lock (sync)
{
outcomes.Enqueue(() => throw exception);
}
}
public TResponse ResolveNext()
{
lock (sync)
{
if (outcomes.Count == 0)
{
throw new InvalidOperationException("No scripted transport outcomes are available.");
}
var next = outcomes.Count == 1 ? outcomes.Peek() : outcomes.Dequeue();
return next();
}
}
}

Some files were not shown because too many files have changed in this diff Show More