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:
@@ -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()!),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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()!),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<>));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 => [];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using StellaOps.Workflow.IntegrationTests.Shared.Performance;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user