From f5b5f24d95d7a3fa018a89482f30c4ca6a63fcf8 Mon Sep 17 00:00:00 2001 From: master <> Date: Fri, 20 Mar 2026 19:14:44 +0200 Subject: [PATCH] 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) --- docs/workflow/ENGINE.md | 1023 ++++ .../engine/01-requirements-and-principles.md | 291 ++ .../02-runtime-and-component-architecture.md | 397 ++ .../engine/03-canonical-execution-model.md | 377 ++ ...04-persistence-signaling-and-scheduling.md | 403 ++ ...-service-surface-hosting-and-operations.md | 425 ++ .../engine/06-implementation-structure.md | 285 + docs/workflow/engine/07-sprint-plan.md | 676 +++ .../engine/08-load-and-performance-plan.md | 544 ++ .../engine/09-backend-portability-plan.md | 806 +++ ...racle-performance-baseline-2026-03-17.json | 1855 +++++++ ...-oracle-performance-baseline-2026-03-17.md | 200 + ...tgres-performance-baseline-2026-03-17.json | 213 + ...ostgres-performance-baseline-2026-03-17.md | 191 + ...mongo-performance-baseline-2026-03-17.json | 211 + ...2-mongo-performance-baseline-2026-03-17.md | 195 + .../13-backend-comparison-2026-03-17.json | 125 + .../13-backend-comparison-2026-03-17.md | 167 + ...gnal-driver-backend-matrix-2026-03-17.json | 137 + ...signal-driver-backend-matrix-2026-03-17.md | 76 + .../15-backend-and-signal-driver-usage.md | 493 ++ docs/workflow/engine/index.md | 85 + .../tutorials/01-hello-world/README.md | 30 + .../01-hello-world/csharp/GreetingWorkflow.cs | 58 + .../json/greeting-workflow.definition.json | 56 + .../tutorials/02-service-tasks/README.md | 29 + .../csharp/ServiceTaskWorkflow.cs | 83 + .../service-task-workflow.definition.json | 89 + .../workflow/tutorials/03-decisions/README.md | 28 + .../03-decisions/csharp/DecisionWorkflow.cs | 72 + .../json/decision-workflow.definition.json | 83 + .../tutorials/04-human-tasks/README.md | 34 + .../04-human-tasks/csharp/ApprovalWorkflow.cs | 101 + .../json/approval-workflow.definition.json | 144 + .../tutorials/05-sub-workflows/README.md | 22 + .../csharp/SubWorkflowExample.cs | 61 + .../json/sub-workflow.definition.json | 57 + .../tutorials/06-advanced-patterns/README.md | 22 + .../csharp/AdvancedPatternsWorkflow.cs | 92 + .../json/advanced-patterns.definition.json | 127 + .../tutorials/07-shared-helpers/README.md | 24 + .../csharp/PolicyWorkflowSupport.cs | 170 + .../tutorials/08-expressions/README.md | 36 + .../csharp/ExpressionExamples.cs | 131 + .../json/expression-examples.json | 166 + docs/workflow/tutorials/09-testing/README.md | 29 + .../09-testing/csharp/WorkflowTests.cs | 196 + docs/workflow/tutorials/README.md | 32 + .../Endpoints/WorkflowEndpoints.cs | 436 ++ .../StellaOps.Workflow.WebService/Program.cs | 35 + .../Properties/launchSettings.json | 12 + .../StellaOps.Workflow.WebService.csproj | 27 + .../appsettings.json | 33 + src/Workflow/StellaOps.Workflow.slnx | 28 + .../IWorkflowCanonicalDefinitionApi.cs | 20 + .../IWorkflowDefinitionDeploymentApi.cs | 37 + .../IWorkflowDefinitionQueryApi.cs | 16 + .../IWorkflowDiagramApi.cs | 16 + .../IWorkflowFunctionCatalogApi.cs | 16 + .../IWorkflowRetentionApi.cs | 16 + .../IWorkflowRuntimeApi.cs | 49 + .../IWorkflowServiceMetadataApi.cs | 16 + .../IWorkflowSignalDeadLetterApi.cs | 20 + .../IWorkflowSignalPumpTelemetryApi.cs | 16 + .../StellaOps.Workflow.Abstractions.csproj | 18 + .../WorkflowAuthorizationAbstractions.cs | 34 + .../WorkflowBackendConfigurationExtensions.cs | 18 + .../WorkflowBackendModuleAbstractions.cs | 8 + .../WorkflowBackendNames.cs | 8 + .../WorkflowBackendOptions.cs | 8 + .../WorkflowBusinessReferenceExtensions.cs | 185 + .../WorkflowCanonicalDecompiler.cs | 984 ++++ ...flowCanonicalDefinitionCompiler.Helpers.cs | 533 ++ .../WorkflowCanonicalDefinitionCompiler.cs | 398 ++ .../WorkflowCanonicalDefinitionValidator.cs | 1046 ++++ .../WorkflowCanonicalEvaluationContext.cs | 68 + .../WorkflowCanonicalExpressionBuilder.cs | 223 + .../WorkflowCanonicalExpressionRuntime.cs | 637 +++ .../WorkflowCanonicalImportValidator.cs | 139 + .../WorkflowCanonicalJsonSchema.cs | 63 + .../WorkflowCanonicalTemplateLoader.cs | 92 + .../WorkflowContentHasher.cs | 23 + .../WorkflowCoreFunctionProvider.cs | 290 ++ .../WorkflowDeclarativeAbstractions.cs | 2449 +++++++++ ...WorkflowDeclarativeAdvancedAbstractions.cs | 218 + .../WorkflowDefinitionAbstractions.cs | 9 + .../WorkflowDefinitionStoreAbstractions.cs | 109 + .../WorkflowExecutionAbstractions.cs | 211 + .../WorkflowFunctionCatalog.cs | 109 + .../WorkflowFunctionRuntime.cs | 51 + .../WorkflowHostedJobLockAbstractions.cs | 20 + .../WorkflowJsonExtensions.cs | 432 ++ .../WorkflowModuleCatalog.cs | 50 + .../WorkflowModuleVersionExpression.cs | 151 + .../WorkflowModuleVersioning.cs | 115 + .../WorkflowMutationAbstractions.cs | 22 + .../WorkflowProjectionStoreAbstractions.cs | 71 + .../WorkflowRegistrationAbstractions.cs | 295 ++ .../WorkflowRenderingAbstractions.cs | 167 + .../WorkflowRetentionAbstractions.cs | 24 + .../WorkflowRuntimeDefinitionAbstractions.cs | 37 + .../WorkflowRuntimeEngineAbstractions.cs | 76 + .../WorkflowRuntimePayloadKeys.cs | 8 + .../WorkflowRuntimeProviderNames.cs | 7 + ...orkflowRuntimeStateConcurrencyException.cs | 21 + .../WorkflowSignalDeadLetterAbstractions.cs | 17 + .../WorkflowSignalDriverAbstractions.cs | 92 + ...flowSignalDriverConfigurationExtensions.cs | 18 + .../WorkflowSignalDriverNames.cs | 7 + .../WorkflowSignalDriverOptions.cs | 8 + .../WorkflowSignalPayloadKeys.cs | 7 + .../WorkflowSpecExecutionContextContracts.cs | 163 + .../WorkflowStepIdentityAssigner.cs | 96 + .../WorkflowTimeoutDefaults.cs | 20 + .../WorkflowTransportAbstractions.cs | 41 + .../WorkflowValueNotFoundException.cs | 9 + .../WorkflowVersioning.cs | 91 + ...owWebserviceServiceCollectionExtensions.cs | 132 + .../StellaOps.Workflow.Contracts.csproj | 8 + .../WorkflowBusinessReferenceContracts.cs | 9 + .../WorkflowCanonicalDefinitionContracts.cs | 359 ++ .../WorkflowDefinitionDeploymentContracts.cs | 176 + .../WorkflowDefinitionsContracts.cs | 34 + .../WorkflowDiagramContracts.cs | 75 + .../WorkflowFunctionCatalogContracts.cs | 44 + .../WorkflowInstanceContracts.cs | 78 + .../WorkflowOperationalContracts.cs | 162 + .../WorkflowProjectionContracts.cs | 18 + .../WorkflowSignalContracts.cs | 18 + .../WorkflowStartContracts.cs | 19 + .../WorkflowTaskContracts.cs | 121 + .../WorkflowTransportContracts.cs | 75 + .../MongoWorkflowDataStoreExtensions.cs | 69 + .../MongoWorkflowDatabase.cs | 280 + .../MongoWorkflowHostedJobLockService.cs | 109 + .../MongoWorkflowJson.cs | 150 + .../MongoWorkflowMutationCoordinator.cs | 15 + .../MongoWorkflowProjectionRetentionStore.cs | 234 + .../MongoWorkflowProjectionStore.cs | 1031 ++++ .../MongoWorkflowRuntimeStateStore.cs | 261 + .../MongoWorkflowScheduleBus.cs | 19 + .../MongoWorkflowSignalBus.cs | 41 + .../MongoWorkflowSignalDeadLetterStore.cs | 25 + .../MongoWorkflowSignalStore.cs | 503 ++ .../MongoWorkflowWakeOutbox.cs | 216 + ...tellaOps.Workflow.DataStore.MongoDB.csproj | 19 + .../WorkflowStoreMongoOptions.cs | 21 + .../Entities/WorkflowHostedJobLockEntity.cs | 11 + .../Entities/WorkflowInstanceProjection.cs | 21 + .../Entities/WorkflowRuntimeStateEntity.cs | 22 + .../Entities/WorkflowTaskEvent.cs | 14 + .../Entities/WorkflowTaskProjection.cs | 31 + ...izedWorkflowDbContextAssemblyAttributes.cs | 8 + .../OptimizedWorkflowDbContextModel.cs | 47 + .../OptimizedWorkflowDbContextModelBuilder.cs | 68 + .../WorkflowHostedJobLockEntityEntityType.cs | 87 + .../WorkflowInstanceProjectionEntityType.cs | 185 + .../WorkflowRuntimeStateEntityEntityType.cs | 205 + .../WorkflowTaskEventEntityType.cs | 143 + .../WorkflowTaskProjectionEntityType.cs | 300 ++ .../OptimizedWorkflowDbContext.cs | 16 + .../OptimizedWorkflowDbContextFactory.cs | 14 + .../OracleWorkflowDataStoreExtensions.cs | 19 + .../OracleWorkflowHostedJobLockService.cs | 71 + .../OracleWorkflowRuntimeStateStore.cs | 254 + ...StellaOps.Workflow.DataStore.Oracle.csproj | 23 + .../WorkflowDbContext.cs | 146 + .../WorkflowDbContextFactory.cs | 14 + ...26-03-10-workflow-pilot-initial-schema.sql | 142 + ...6-03-11-workflow-pilot-runtime-columns.sql | 20 + ...orkflow-runtime-state-locks-and-semver.sql | 42 + ...13-workflow-business-reference-columns.sql | 29 + .../PostgresWorkflowBackendOptions.cs | 24 + .../PostgresWorkflowDataStoreExtensions.cs | 61 + .../PostgresWorkflowDatabase.cs | 247 + .../PostgresWorkflowHostedJobLockService.cs | 108 + .../PostgresWorkflowJson.cs | 160 + .../PostgresWorkflowMutationCoordinator.cs | 14 + ...ostgresWorkflowProjectionRetentionStore.cs | 208 + .../PostgresWorkflowProjectionStore.cs | 1397 +++++ .../PostgresWorkflowRuntimeStateStore.cs | 397 ++ .../PostgresWorkflowScheduleBus.cs | 22 + .../PostgresWorkflowSignalBus.cs | 46 + .../PostgresWorkflowSignalDeadLetterStore.cs | 24 + .../PostgresWorkflowSignalStore.cs | 653 +++ .../PostgresWorkflowSqlBuilder.cs | 242 + .../PostgresWorkflowWakeOutbox.cs | 237 + ...laOps.Workflow.DataStore.PostgreSQL.csproj | 16 + .../WorkflowTaskAuthorizationService.cs | 9 + .../Constants/ConstantsClass.cs | 7 + .../Constants/MessageKeys.cs | 14 + .../Constants/WorkflowStatuses.cs | 25 + .../WorkflowRuntimeDefinitionStore.cs | 181 + .../WorkflowRuntimeExecutionHandlerFactory.cs | 42 + .../CanonicalWorkflowExecutionHandler.cs | 2504 +++++++++ .../Execution/CanonicalWorkflowForkState.cs | 241 + .../ConfiguredWorkflowRuntimeOrchestrator.cs | 101 + .../WorkflowEngineRuntimeSnapshot.cs | 26 + .../WorkflowEngineRuntimeSnapshotBuilder.cs | 166 + .../WorkflowEngineRuntimeSnapshotParser.cs | 146 + .../Execution/WorkflowSignalResumeState.cs | 38 + .../Engine/Hosting/WorkflowAqOptions.cs | 18 + .../Engine/Hosting/WorkflowEngineOptions.cs | 19 + .../Engine/Hosting/WorkflowRuntimeOptions.cs | 13 + .../Rendering/WorkflowRenderGraphCompiler.cs | 804 +++ .../WorkflowRenderLayoutEngineResolver.cs | 39 + .../Scheduling/NullWorkflowScheduleBus.cs | 19 + .../Scheduling/NullWorkflowSignalScheduler.cs | 26 + .../Scheduling/WorkflowScheduleBusBridge.cs | 20 + .../Engine/Signaling/NullWorkflowSignalBus.cs | 34 + .../Signaling/NullWorkflowSignalClaimStore.cs | 16 + .../NullWorkflowSignalDeadLetterStore.cs | 27 + .../Signaling/NullWorkflowSignalDriver.cs | 27 + .../Signaling/NullWorkflowSignalStore.cs | 34 + .../Signaling/NullWorkflowWakeOutbox.cs | 16 + .../NullWorkflowWakeOutboxReceiver.cs | 16 + .../Signaling/WorkflowSignalBusBridge.cs | 67 + .../WorkflowSignalCommandDispatcher.cs | 37 + .../WorkflowSignalEnvelopeSerializer.cs | 66 + .../Signaling/WorkflowSignalProcessor.cs | 49 + .../Exceptions/BaseResultException.cs | 67 + .../WorkflowRetentionHostedService.cs | 95 + .../WorkflowSignalPumpHostedService.cs | 97 + .../WorkflowSignalPumpWorker.cs | 102 + .../Projections/WorkflowProjectionStore.cs | 1072 ++++ .../DeclarativeWorkflowExecutionHandler.cs | 1617 ++++++ .../DeclarativeWorkflowSubWorkflowRuntime.cs | 125 + ...ltWorkflowAssignmentPermissionEvaluator.cs | 43 + ...ityFrameworkWorkflowMutationCoordinator.cs | 63 + ...ameworkWorkflowProjectionRetentionStore.cs | 102 + .../GenericAssignmentPermissionEvaluator.cs | 79 + .../InMemoryWorkflowRuntimeStateStore.cs | 120 + .../InProcessWorkflowRuntimeOrchestrator.cs | 124 + .../Services/NullWorkflowDefinitionStore.cs | 57 + .../Services/NullWorkflowGraphqlTransport.cs | 19 + .../Services/NullWorkflowHttpTransport.cs | 19 + .../NullWorkflowLegacyRabbitTransport.cs | 19 + .../NullWorkflowMicroserviceTransport.cs | 19 + .../Services/NullWorkflowRabbitTransport.cs | 19 + .../WorkflowCanonicalDefinitionService.cs | 59 + ...WorkflowCoreServiceCollectionExtensions.cs | 157 + .../Services/WorkflowDefinitionCatalog.cs | 36 + .../WorkflowDefinitionDeploymentService.cs | 338 ++ .../WorkflowDefinitionRenderService.cs | 26 + .../Services/WorkflowDiagramService.cs | 238 + .../Services/WorkflowEngineRuntimeProvider.cs | 379 ++ .../WorkflowExecutionHandlerCatalog.cs | 46 + .../WorkflowFunctionCatalogService.cs | 27 + .../Services/WorkflowHostedJobLockServices.cs | 79 + .../Services/WorkflowMutationScopeAccessor.cs | 8 + .../WorkflowMutationTransactionScope.cs | 63 + .../Services/WorkflowRegistrationCatalog.cs | 37 + .../WorkflowRetentionHostedJobOptions.cs | 15 + .../Services/WorkflowRetentionOptions.cs | 9 + .../Services/WorkflowRetentionService.cs | 50 + .../Services/WorkflowRoleResolutionService.cs | 34 + .../Services/WorkflowRuntimeService.cs | 915 ++++ .../WorkflowSignalOperationsService.cs | 224 + .../StellaOps.Workflow.Engine.csproj | 25 + .../ElkJsWorkflowRenderLayoutEngine.cs | 444 ++ .../ElkJsWorkflowRenderLayoutEngineOptions.cs | 18 + .../StellaOps.Workflow.Renderer.ElkJs.csproj | 26 + .../tools/elk-layout/layout.mjs | 15 + .../tools/elk-layout/package-lock.json | 19 + .../tools/elk-layout/package.json | 8 + .../ElkSharpWorkflowRenderLayoutEngine.cs | 172 + ...tellaOps.Workflow.Renderer.ElkSharp.csproj | 12 + .../MsaglWorkflowRenderLayoutEngine.cs | 365 ++ .../StellaOps.Workflow.Renderer.Msagl.csproj | 17 + .../StellaOps.Workflow.Renderer.Svg.csproj | 16 + .../WorkflowRenderPngExporter.cs | 41 + .../WorkflowRenderSvgDocument.cs | 8 + .../WorkflowRenderSvgRenderer.cs | 2310 +++++++++ .../OracleAqTransportContracts.cs | 68 + .../OracleAqWorkflowScheduleBus.cs | 39 + .../OracleAqWorkflowSignalBus.cs | 143 + .../OracleAqWorkflowSignalDeadLetterStore.cs | 184 + .../OracleAqWorkflowSignalingExtensions.cs | 28 + .../OracleManagedAqTransport.cs | 338 ++ ...ellaOps.Workflow.Signaling.OracleAq.csproj | 19 + .../WorkflowAqOptions.cs | 14 + .../WorkflowSignalEnvelopeSerializer.cs | 66 + .../RedisWorkflowSignalDriver.cs | 55 + .../RedisWorkflowSignalDriverOptions.cs | 10 + .../RedisWorkflowSignalingExtensions.cs | 32 + .../RedisWorkflowWakeMessageSerializer.cs | 19 + ...orkflowWakeOutboxPublisherHostedService.cs | 47 + .../RedisWorkflowWakeSubscription.cs | 133 + .../StellaOps.Workflow.Signaling.Redis.csproj | 18 + .../MongoBulstradWorkflowIntegrationTests.cs | 251 + .../MongoDockerFixture.cs | 187 + .../MongoSerializerRegistrationCoordinator.cs | 13 + ...MongoWorkflowProjectionIntegrationTests.cs | 212 + .../MongoWorkflowSignalIntegrationTests.cs | 181 + .../MongoWorkflowStoreIntegrationTests.cs | 234 + ...MongoWorkflowWakeOutboxIntegrationTests.cs | 92 + .../MongoPerformanceCapacityTests.cs | 160 + .../MongoPerformanceLatencyTests.cs | 165 + .../MongoPerformanceMetricsCollector.cs | 245 + .../MongoPerformanceNightlyTests.cs | 401 ++ .../Performance/MongoPerformanceSmokeTests.cs | 238 + .../Performance/MongoPerformanceSoakTests.cs | 171 + .../MongoPerformanceTestSupport.cs | 413 ++ .../MongoPerformanceThroughputTests.cs | 199 + .../MongoRedisSignalDriverPerformanceTests.cs | 340 ++ ...ngoWorkflowPerformanceMetricsExtensions.cs | 34 + ...ps.Workflow.DataStore.MongoDB.Tests.csproj | 51 + ...racleAqBulstradWorkflowIntegrationTests.cs | 1737 +++++++ .../OracleAqIntegrationLifetime.cs | 51 + .../OracleAqIntegrationSuiteFixture.cs | 21 + .../OracleAqPerformanceCapacityTests.cs | 156 + .../OracleAqPerformanceLatencyTests.cs | 138 + .../OracleAqPerformanceNightlyTests.cs | 366 ++ .../OracleAqPerformanceSmokeTests.cs | 332 ++ .../OracleAqPerformanceSoakTests.cs | 142 + .../OracleAqPerformanceTestSupport.cs | 428 ++ .../OracleAqPerformanceThroughputTests.cs | 172 + ...acleAqRedisSignalDriverIntegrationTests.cs | 217 + .../OracleAqRuntimeIntegrationTests.cs | 2027 ++++++++ .../OracleAqWorkflowScheduleBusTests.cs | 110 + .../OracleAqWorkflowSignalBusTests.cs | 286 + .../OracleDockerFixture.cs | 498 ++ .../OraclePerformanceMetricsCollector.cs | 243 + ...OracleRedisSignalDriverPerformanceTests.cs | 316 ++ ...cleWorkflowPerformanceMetricsExtensions.cs | 31 + .../OracleWorkflowRuntimeStateStoreTests.cs | 129 + ...Ops.Workflow.DataStore.Oracle.Tests.csproj | 55 + .../PostgresPerformanceCapacityTests.cs | 187 + .../PostgresPerformanceLatencyTests.cs | 166 + .../PostgresPerformanceMetricsCollector.cs | 210 + .../PostgresPerformanceNightlyTests.cs | 373 ++ .../PostgresPerformanceSmokeTests.cs | 237 + .../PostgresPerformanceSoakTests.cs | 173 + .../PostgresPerformanceTestSupport.cs | 412 ++ .../PostgresPerformanceThroughputTests.cs | 201 + ...stgresRedisSignalDriverPerformanceTests.cs | 339 ++ ...resWorkflowPerformanceMetricsExtensions.cs | 31 + ...ostgresBulstradWorkflowIntegrationTests.cs | 254 + .../PostgresDockerFixture.cs | 173 + ...tgresWorkflowProjectionIntegrationTests.cs | 208 + .../PostgresWorkflowSignalIntegrationTests.cs | 180 + .../PostgresWorkflowStoreIntegrationTests.cs | 237 + ...tgresWorkflowWakeOutboxIntegrationTests.cs | 91 + ...Workflow.DataStore.PostgreSQL.Tests.csproj | 51 + .../CanonicalWorkflowExecutionHandlerTests.cs | 450 ++ ...figuredWorkflowRuntimeOrchestratorTests.cs | 216 + .../InMemoryWorkflowRuntimeStateStoreTests.cs | 70 + .../RecordingWorkflowHttpTransport.cs | 91 + .../RecordingWorkflowLegacyRabbitTransport.cs | 104 + .../StellaOps.Workflow.Engine.Tests.csproj | 54 + .../TechnicalStyleWorkflowTestHelpers.cs | 162 + .../TestWorkflowDefinitions.cs | 369 ++ .../WorkflowCanonicalCompilerImportTests.cs | 742 +++ .../WorkflowCanonicalDefinitionTests.cs | 402 ++ .../WorkflowCanonicalEmbeddedAssetTests.cs | 79 + ...WorkflowCanonicalExpressionRuntimeTests.cs | 614 +++ .../WorkflowCanonicalImportValidationTests.cs | 185 + .../WorkflowCanonicalizationInventoryTests.cs | 101 + .../WorkflowDeclarativeBuilderTests.cs | 539 ++ .../WorkflowDecompilerOutputTests.cs | 91 + .../WorkflowHostedJobLockServiceTests.cs | 54 + .../WorkflowRenderingPipelineTests.cs | 681 +++ .../WorkflowRenderingTestHelpers.cs | 151 + .../WorkflowRetentionServiceTests.cs | 146 + .../WorkflowRoundTripCompilerTests.cs | 305 ++ .../WorkflowRuntimeDefinitionStoreTests.cs | 252 + .../WorkflowRuntimeRecoveryTests.cs | 597 +++ .../WorkflowRuntimeServiceTests.cs | 1162 +++++ .../WorkflowRuntimeServiceTransactionTests.cs | 434 ++ .../WorkflowRuntimeSignalResumeTests.cs | 1155 +++++ .../WorkflowSignalBridgeTests.cs | 281 + .../WorkflowSignalEnvelopeSerializerTests.cs | 62 + .../WorkflowSignalOperationalTests.cs | 353 ++ .../WorkflowSignalProcessorTests.cs | 178 + .../WorkflowSignalPumpWorkerTests.cs | 197 + .../WorkflowVersioningTests.cs | 257 + .../PerformanceCommonGlobalUsings.cs | 1 + .../WorkflowEnginePerformanceSupport.cs | 81 + .../WorkflowPerformanceArtifacts.cs | 483 ++ .../WorkflowPerformanceComparisonMatrix.cs | 340 ++ ...orkflowPerformanceComparisonMatrixTests.cs | 35 + .../WorkflowSignalDrainTelemetry.cs | 32 + ...ps.Workflow.IntegrationTests.Shared.csproj | 35 + .../TransportProbeWorkflows.cs | 132 + .../TransportUnhandledProbeWorkflows.cs | 236 + .../WorkflowIntegrationAssertions.cs | 149 + .../WorkflowPlatformBootstrapTests.cs | 253 + ...PlatformRedisSignalDriverBootstrapTests.cs | 137 + .../WorkflowTransportScripts.cs | 411 ++ ...istantPrintInsisDocumentsRenderingTests.cs | 181 + .../ElkJsWorkflowRenderLayoutEngineTests.cs | 76 + .../ElkSharpSourceAnalyzerTests.cs | 43 + ...ElkSharpWorkflowRenderLayoutEngineTests.cs | 958 ++++ .../MsaglWorkflowRenderLayoutEngineTests.cs | 79 + .../StellaOps.Workflow.Renderer.Tests.csproj | 40 + .../WorkflowRenderSvgRendererTests.cs | 261 + .../WorkflowRenderingBenchmark.cs | 395 ++ .../WorkflowRenderingBenchmarkTests.cs | 58 + .../RedisDockerFixture.cs | 153 + ...disWorkflowSignalDriverIntegrationTests.cs | 137 + ...aOps.Workflow.Signaling.Redis.Tests.csproj | 32 + .../Endpoints/WorkflowE2ETests.cs | 228 + .../InMemoryWorkflowProjectionStore.cs | 574 ++ .../NoopWorkflowMutationCoordinator.cs | 38 + .../NoopWorkflowProjectionRetentionStore.cs | 16 + .../Fixtures/SimpleApprovalWorkflow.cs | 48 + .../Fixtures/WorkflowWebApplicationFactory.cs | 101 + ...StellaOps.Workflow.WebService.Tests.csproj | 34 + .../csharp/ApproveApplication.cs | 117 + .../csharp/AssistantPolicyReinstate.cs | 58 + .../csharp/AssistantPrintInsisDocuments.cs | 51 + .../UpdateSrPolicyIdSrcAndCopyCovers.cs | 45 + .../csharp/UserDataCheckConsistency.cs | 38 + .../json/ApproveApplication.json | 538 ++ .../json/AssistantPolicyReinstate.json | 235 + .../json/AssistantPrintInsisDocuments.json | 162 + .../UpdateSrPolicyIdSrcAndCopyCovers.json | 151 + .../json/UserDataCheckConsistency.json | 110 + .../StellaOps.ElkSharp/ElkModels.cs | 138 + .../ElkSharpLayeredLayoutEngine.cs | 4616 +++++++++++++++++ .../ElkSharpSourceAnalyzer.cs | 85 + .../StellaOps.ElkSharp.csproj | 9 + 422 files changed, 85428 insertions(+) create mode 100644 docs/workflow/ENGINE.md create mode 100644 docs/workflow/engine/01-requirements-and-principles.md create mode 100644 docs/workflow/engine/02-runtime-and-component-architecture.md create mode 100644 docs/workflow/engine/03-canonical-execution-model.md create mode 100644 docs/workflow/engine/04-persistence-signaling-and-scheduling.md create mode 100644 docs/workflow/engine/05-service-surface-hosting-and-operations.md create mode 100644 docs/workflow/engine/06-implementation-structure.md create mode 100644 docs/workflow/engine/07-sprint-plan.md create mode 100644 docs/workflow/engine/08-load-and-performance-plan.md create mode 100644 docs/workflow/engine/09-backend-portability-plan.md create mode 100644 docs/workflow/engine/10-oracle-performance-baseline-2026-03-17.json create mode 100644 docs/workflow/engine/10-oracle-performance-baseline-2026-03-17.md create mode 100644 docs/workflow/engine/11-postgres-performance-baseline-2026-03-17.json create mode 100644 docs/workflow/engine/11-postgres-performance-baseline-2026-03-17.md create mode 100644 docs/workflow/engine/12-mongo-performance-baseline-2026-03-17.json create mode 100644 docs/workflow/engine/12-mongo-performance-baseline-2026-03-17.md create mode 100644 docs/workflow/engine/13-backend-comparison-2026-03-17.json create mode 100644 docs/workflow/engine/13-backend-comparison-2026-03-17.md create mode 100644 docs/workflow/engine/14-signal-driver-backend-matrix-2026-03-17.json create mode 100644 docs/workflow/engine/14-signal-driver-backend-matrix-2026-03-17.md create mode 100644 docs/workflow/engine/15-backend-and-signal-driver-usage.md create mode 100644 docs/workflow/engine/index.md create mode 100644 docs/workflow/tutorials/01-hello-world/README.md create mode 100644 docs/workflow/tutorials/01-hello-world/csharp/GreetingWorkflow.cs create mode 100644 docs/workflow/tutorials/01-hello-world/json/greeting-workflow.definition.json create mode 100644 docs/workflow/tutorials/02-service-tasks/README.md create mode 100644 docs/workflow/tutorials/02-service-tasks/csharp/ServiceTaskWorkflow.cs create mode 100644 docs/workflow/tutorials/02-service-tasks/json/service-task-workflow.definition.json create mode 100644 docs/workflow/tutorials/03-decisions/README.md create mode 100644 docs/workflow/tutorials/03-decisions/csharp/DecisionWorkflow.cs create mode 100644 docs/workflow/tutorials/03-decisions/json/decision-workflow.definition.json create mode 100644 docs/workflow/tutorials/04-human-tasks/README.md create mode 100644 docs/workflow/tutorials/04-human-tasks/csharp/ApprovalWorkflow.cs create mode 100644 docs/workflow/tutorials/04-human-tasks/json/approval-workflow.definition.json create mode 100644 docs/workflow/tutorials/05-sub-workflows/README.md create mode 100644 docs/workflow/tutorials/05-sub-workflows/csharp/SubWorkflowExample.cs create mode 100644 docs/workflow/tutorials/05-sub-workflows/json/sub-workflow.definition.json create mode 100644 docs/workflow/tutorials/06-advanced-patterns/README.md create mode 100644 docs/workflow/tutorials/06-advanced-patterns/csharp/AdvancedPatternsWorkflow.cs create mode 100644 docs/workflow/tutorials/06-advanced-patterns/json/advanced-patterns.definition.json create mode 100644 docs/workflow/tutorials/07-shared-helpers/README.md create mode 100644 docs/workflow/tutorials/07-shared-helpers/csharp/PolicyWorkflowSupport.cs create mode 100644 docs/workflow/tutorials/08-expressions/README.md create mode 100644 docs/workflow/tutorials/08-expressions/csharp/ExpressionExamples.cs create mode 100644 docs/workflow/tutorials/08-expressions/json/expression-examples.json create mode 100644 docs/workflow/tutorials/09-testing/README.md create mode 100644 docs/workflow/tutorials/09-testing/csharp/WorkflowTests.cs create mode 100644 docs/workflow/tutorials/README.md create mode 100644 src/Workflow/StellaOps.Workflow.WebService/Endpoints/WorkflowEndpoints.cs create mode 100644 src/Workflow/StellaOps.Workflow.WebService/Program.cs create mode 100644 src/Workflow/StellaOps.Workflow.WebService/Properties/launchSettings.json create mode 100644 src/Workflow/StellaOps.Workflow.WebService/StellaOps.Workflow.WebService.csproj create mode 100644 src/Workflow/StellaOps.Workflow.WebService/appsettings.json create mode 100644 src/Workflow/StellaOps.Workflow.slnx create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowCanonicalDefinitionApi.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowDefinitionDeploymentApi.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowDefinitionQueryApi.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowDiagramApi.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowFunctionCatalogApi.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowRetentionApi.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowRuntimeApi.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowServiceMetadataApi.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowSignalDeadLetterApi.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowSignalPumpTelemetryApi.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/StellaOps.Workflow.Abstractions.csproj create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowAuthorizationAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendConfigurationExtensions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendModuleAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendNames.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendOptions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBusinessReferenceExtensions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDecompiler.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionCompiler.Helpers.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionCompiler.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionValidator.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalEvaluationContext.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalExpressionBuilder.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalExpressionRuntime.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalImportValidator.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalJsonSchema.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalTemplateLoader.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowContentHasher.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCoreFunctionProvider.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDeclarativeAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDeclarativeAdvancedAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDefinitionAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDefinitionStoreAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowExecutionAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowFunctionCatalog.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowFunctionRuntime.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowHostedJobLockAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowJsonExtensions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowModuleCatalog.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowModuleVersionExpression.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowModuleVersioning.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowMutationAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowProjectionStoreAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRegistrationAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRenderingAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRetentionAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeDefinitionAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeEngineAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimePayloadKeys.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeProviderNames.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeStateConcurrencyException.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDeadLetterAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverConfigurationExtensions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverNames.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverOptions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalPayloadKeys.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSpecExecutionContextContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowStepIdentityAssigner.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowTimeoutDefaults.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowTransportAbstractions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowValueNotFoundException.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowVersioning.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowWebserviceServiceCollectionExtensions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Contracts/StellaOps.Workflow.Contracts.csproj create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowBusinessReferenceContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowCanonicalDefinitionContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowDefinitionDeploymentContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowDefinitionsContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowDiagramContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowFunctionCatalogContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowInstanceContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowOperationalContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowProjectionContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowSignalContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowStartContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowTaskContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowTransportContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowDataStoreExtensions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowDatabase.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowHostedJobLockService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowJson.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowMutationCoordinator.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowProjectionRetentionStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowProjectionStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowRuntimeStateStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowScheduleBus.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowSignalBus.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowSignalDeadLetterStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowSignalStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowWakeOutbox.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/StellaOps.Workflow.DataStore.MongoDB.csproj create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/WorkflowStoreMongoOptions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowHostedJobLockEntity.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowInstanceProjection.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowRuntimeStateEntity.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowTaskEvent.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowTaskProjection.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/OptimizedWorkflowDbContextAssemblyAttributes.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/OptimizedWorkflowDbContextModel.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/OptimizedWorkflowDbContextModelBuilder.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowHostedJobLockEntityEntityType.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowInstanceProjectionEntityType.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowRuntimeStateEntityEntityType.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowTaskEventEntityType.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowTaskProjectionEntityType.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OptimizedWorkflowDbContext.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OptimizedWorkflowDbContextFactory.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowDataStoreExtensions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowHostedJobLockService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowRuntimeStateStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/StellaOps.Workflow.DataStore.Oracle.csproj create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/WorkflowDbContext.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/WorkflowDbContextFactory.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-10-workflow-pilot-initial-schema.sql create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-11-workflow-pilot-runtime-columns.sql create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-12-workflow-runtime-state-locks-and-semver.sql create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-13-workflow-business-reference-columns.sql create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowBackendOptions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDataStoreExtensions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDatabase.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowHostedJobLockService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowJson.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowMutationCoordinator.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowProjectionRetentionStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowProjectionStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowRuntimeStateStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowScheduleBus.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSignalBus.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSignalDeadLetterStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSignalStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSqlBuilder.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowWakeOutbox.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/StellaOps.Workflow.DataStore.PostgreSQL.csproj create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Authorization/WorkflowTaskAuthorizationService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Constants/ConstantsClass.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Constants/MessageKeys.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Constants/WorkflowStatuses.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Definitions/WorkflowRuntimeDefinitionStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Definitions/WorkflowRuntimeExecutionHandlerFactory.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/CanonicalWorkflowExecutionHandler.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/CanonicalWorkflowForkState.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/ConfiguredWorkflowRuntimeOrchestrator.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowEngineRuntimeSnapshot.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowEngineRuntimeSnapshotBuilder.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowEngineRuntimeSnapshotParser.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowSignalResumeState.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Hosting/WorkflowAqOptions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Hosting/WorkflowEngineOptions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Hosting/WorkflowRuntimeOptions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Rendering/WorkflowRenderGraphCompiler.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Rendering/WorkflowRenderLayoutEngineResolver.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Scheduling/NullWorkflowScheduleBus.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Scheduling/NullWorkflowSignalScheduler.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Scheduling/WorkflowScheduleBusBridge.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalBus.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalClaimStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalDeadLetterStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalDriver.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowWakeOutbox.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowWakeOutboxReceiver.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalBusBridge.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalCommandDispatcher.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalEnvelopeSerializer.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalProcessor.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Exceptions/BaseResultException.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/HostedServices/WorkflowRetentionHostedService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/HostedServices/WorkflowSignalPumpHostedService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/HostedServices/WorkflowSignalPumpWorker.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Projections/WorkflowProjectionStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/DeclarativeWorkflowExecutionHandler.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/DeclarativeWorkflowSubWorkflowRuntime.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/DefaultWorkflowAssignmentPermissionEvaluator.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/EntityFrameworkWorkflowMutationCoordinator.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/EntityFrameworkWorkflowProjectionRetentionStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/GenericAssignmentPermissionEvaluator.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/InMemoryWorkflowRuntimeStateStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/InProcessWorkflowRuntimeOrchestrator.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowDefinitionStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowGraphqlTransport.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowHttpTransport.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowLegacyRabbitTransport.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowMicroserviceTransport.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowRabbitTransport.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowCanonicalDefinitionService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowCoreServiceCollectionExtensions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDefinitionCatalog.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDefinitionDeploymentService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDefinitionRenderService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDiagramService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowEngineRuntimeProvider.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowExecutionHandlerCatalog.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowFunctionCatalogService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowHostedJobLockServices.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowMutationScopeAccessor.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowMutationTransactionScope.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRegistrationCatalog.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRetentionHostedJobOptions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRetentionOptions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRetentionService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRoleResolutionService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRuntimeService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowSignalOperationsService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/StellaOps.Workflow.Engine.csproj create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/ElkJsWorkflowRenderLayoutEngine.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/ElkJsWorkflowRenderLayoutEngineOptions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/StellaOps.Workflow.Renderer.ElkJs.csproj create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/tools/elk-layout/layout.mjs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/tools/elk-layout/package-lock.json create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/tools/elk-layout/package.json create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkSharp/ElkSharpWorkflowRenderLayoutEngine.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkSharp/StellaOps.Workflow.Renderer.ElkSharp.csproj create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Msagl/MsaglWorkflowRenderLayoutEngine.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Msagl/StellaOps.Workflow.Renderer.Msagl.csproj create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/StellaOps.Workflow.Renderer.Svg.csproj create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderPngExporter.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgDocument.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqTransportContracts.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowScheduleBus.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowSignalBus.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowSignalDeadLetterStore.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowSignalingExtensions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleManagedAqTransport.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/StellaOps.Workflow.Signaling.OracleAq.csproj create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/WorkflowAqOptions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/WorkflowSignalEnvelopeSerializer.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowSignalDriver.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowSignalDriverOptions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowSignalingExtensions.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowWakeMessageSerializer.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowWakeOutboxPublisherHostedService.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowWakeSubscription.cs create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/StellaOps.Workflow.Signaling.Redis.csproj create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoBulstradWorkflowIntegrationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoDockerFixture.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoSerializerRegistrationCoordinator.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowProjectionIntegrationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowSignalIntegrationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowStoreIntegrationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowWakeOutboxIntegrationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceCapacityTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceLatencyTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceMetricsCollector.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceNightlyTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceSmokeTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceSoakTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceTestSupport.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceThroughputTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoRedisSignalDriverPerformanceTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoWorkflowPerformanceMetricsExtensions.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/StellaOps.Workflow.DataStore.MongoDB.Tests.csproj create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqBulstradWorkflowIntegrationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqIntegrationLifetime.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqIntegrationSuiteFixture.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceCapacityTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceLatencyTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceNightlyTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceSmokeTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceSoakTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceTestSupport.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceThroughputTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqRedisSignalDriverIntegrationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqRuntimeIntegrationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqWorkflowScheduleBusTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqWorkflowSignalBusTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleDockerFixture.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OraclePerformanceMetricsCollector.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleRedisSignalDriverPerformanceTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleWorkflowPerformanceMetricsExtensions.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleWorkflowRuntimeStateStoreTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/StellaOps.Workflow.DataStore.Oracle.Tests.csproj create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceCapacityTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceLatencyTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceMetricsCollector.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceNightlyTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceSmokeTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceSoakTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceTestSupport.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceThroughputTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresRedisSignalDriverPerformanceTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresWorkflowPerformanceMetricsExtensions.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresBulstradWorkflowIntegrationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresDockerFixture.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowProjectionIntegrationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowSignalIntegrationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowStoreIntegrationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowWakeOutboxIntegrationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests.csproj create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/CanonicalWorkflowExecutionHandlerTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/ConfiguredWorkflowRuntimeOrchestratorTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/InMemoryWorkflowRuntimeStateStoreTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/RecordingWorkflowHttpTransport.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/RecordingWorkflowLegacyRabbitTransport.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/StellaOps.Workflow.Engine.Tests.csproj create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/TechnicalStyleWorkflowTestHelpers.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/TestWorkflowDefinitions.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalCompilerImportTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalDefinitionTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalEmbeddedAssetTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalExpressionRuntimeTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalImportValidationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalizationInventoryTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowDeclarativeBuilderTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowDecompilerOutputTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowHostedJobLockServiceTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRenderingPipelineTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRenderingTestHelpers.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRetentionServiceTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRoundTripCompilerTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeDefinitionStoreTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeRecoveryTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeServiceTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeServiceTransactionTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeSignalResumeTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalBridgeTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalEnvelopeSerializerTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalOperationalTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalProcessorTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalPumpWorkerTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowVersioningTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/PerformanceCommonGlobalUsings.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowEnginePerformanceSupport.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowPerformanceArtifacts.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowPerformanceComparisonMatrix.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowPerformanceComparisonMatrixTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowSignalDrainTelemetry.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/StellaOps.Workflow.IntegrationTests.Shared.csproj create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/TransportProbeWorkflows.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/TransportUnhandledProbeWorkflows.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowIntegrationAssertions.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowPlatformBootstrapTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowPlatformRedisSignalDriverBootstrapTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowTransportScripts.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/AssistantPrintInsisDocumentsRenderingTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkJsWorkflowRenderLayoutEngineTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpSourceAnalyzerTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpWorkflowRenderLayoutEngineTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/MsaglWorkflowRenderLayoutEngineTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderSvgRendererTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderingBenchmark.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderingBenchmarkTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Signaling.Redis.Tests/RedisDockerFixture.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Signaling.Redis.Tests/RedisWorkflowSignalDriverIntegrationTests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Signaling.Redis.Tests/StellaOps.Workflow.Signaling.Redis.Tests.csproj create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Endpoints/WorkflowE2ETests.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/InMemoryWorkflowProjectionStore.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/NoopWorkflowMutationCoordinator.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/NoopWorkflowProjectionRetentionStore.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/SimpleApprovalWorkflow.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/WorkflowWebApplicationFactory.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/StellaOps.Workflow.WebService.Tests.csproj create mode 100644 src/Workflow/docs/decompiled-samples/csharp/ApproveApplication.cs create mode 100644 src/Workflow/docs/decompiled-samples/csharp/AssistantPolicyReinstate.cs create mode 100644 src/Workflow/docs/decompiled-samples/csharp/AssistantPrintInsisDocuments.cs create mode 100644 src/Workflow/docs/decompiled-samples/csharp/UpdateSrPolicyIdSrcAndCopyCovers.cs create mode 100644 src/Workflow/docs/decompiled-samples/csharp/UserDataCheckConsistency.cs create mode 100644 src/Workflow/docs/decompiled-samples/json/ApproveApplication.json create mode 100644 src/Workflow/docs/decompiled-samples/json/AssistantPolicyReinstate.json create mode 100644 src/Workflow/docs/decompiled-samples/json/AssistantPrintInsisDocuments.json create mode 100644 src/Workflow/docs/decompiled-samples/json/UpdateSrPolicyIdSrcAndCopyCovers.json create mode 100644 src/Workflow/docs/decompiled-samples/json/UserDataCheckConsistency.json create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkModels.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkSharpSourceAnalyzer.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.csproj diff --git a/docs/workflow/ENGINE.md b/docs/workflow/ENGINE.md new file mode 100644 index 000000000..ccb70c1a9 --- /dev/null +++ b/docs/workflow/ENGINE.md @@ -0,0 +1,1023 @@ +# Serdica Workflow Engine + +A declarative, plugin-based workflow engine for long-running insurance business processes. Replaces Camunda BPMN with a native C# fluent DSL, canonical JSON schema, durable signal-based execution, and multi-backend persistence. + +--- + +## Table of Contents + +- [Architecture Overview](#architecture-overview) +- [Workflow Declaration DSL](#workflow-declaration-dsl) +- [Human Tasks](#human-tasks) +- [Transport Calls (Service Tasks)](#transport-calls-service-tasks) +- [Control Flow](#control-flow) +- [Sub-Workflows & Continuations](#sub-workflows--continuations) +- [Canonical Definition Schema](#canonical-definition-schema) +- [Expression System](#expression-system) +- [Signal System](#signal-system) +- [Timeout Architecture](#timeout-architecture) +- [Retention & Lifecycle](#retention--lifecycle) +- [Authorization](#authorization) +- [Plugin System](#plugin-system) +- [Configuration Reference](#configuration-reference) +- [Service Surface](#service-surface) +- [Diagram & Visualization](#diagram--visualization) +- [Error Handling](#error-handling) + +--- + +## Architecture Overview + +### Execution Flow + +``` +Workflow request + -> Workflow runtime service + -> Runtime orchestrator + -> Canonical execution handler + -> Transport adapters + -> Projection store + -> Runtime state store + -> Signal and schedule buses +``` + +### Key Components + +| Component | Responsibility | +|-----------|---------------| +| **WorkflowRuntimeService** | Engine-facing lifecycle and task operations | +| **CanonicalWorkflowExecutionHandler** | Evaluates canonical step sequences, manages fork/join state | +| **WorkflowSignalPumpHostedService** | Background consumer for durable signal processing | +| **WorkflowRetentionHostedService** | Background cleanup of stale/completed instances | +| **IWorkflowProjectionStore** | Task and instance persistence (Mongo, Oracle, Postgres) | +| **IWorkflowRuntimeStateStore** | Durable execution state snapshots | +| **IWorkflowSignalBus** | Signal publishing and delivery | +| **Transport Plugins** | HTTP, GraphQL, message-bus transports, microservice command transport | + +### Runtime Providers + +| Provider | Name | Use Case | +|----------|------|----------| +| **Serdica.InProcess** | In-memory execution | Testing, simple workflows without durability | +| **Serdica.Engine** | Canonical engine with durable state | Production workflows with signal-based resumption | + +--- + +## Workflow Declaration DSL + +Workflows are defined using a strongly-typed C# fluent builder. The DSL compiles to a canonical JSON definition at startup. + +### Minimal Workflow + +```csharp +public sealed class ApproveApplicationWorkflow + : IDeclarativeWorkflow +{ + public string WorkflowName => "ApproveApplication"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Approve Application"; + public IReadOnlyCollection WorkflowRoles => ["DBA", "UR_UNDERWRITER"]; + + public WorkflowSpec Spec { get; } = WorkflowSpec + .For() + .InitializeState(request => new Dictionary + { + ["policyId"] = JsonSerializer.SerializeToElement(request.PolicyId), + }) + .StartWith(approveTask) + .Build(); + + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + private static readonly WorkflowHumanTaskDefinition approveTask = + WorkflowHumanTask.For( + taskName: "Approve Application", + taskType: "ApproveQTApproveApplication", + route: "business/policies") + .WithPayload(context => new Dictionary + { + ["policyId"] = context.StateValues.GetRequired("policyId").AsJsonElement(), + }) + .OnComplete(flow => flow.Complete()); +} +``` + +### State Initialization + +State can be initialized from the start request using delegates or expressions: + +```csharp +// Delegate-based (typed) +.InitializeState(request => new { policyId = request.PolicyId, status = "NEW" }) + +// Expression-based (canonical, portable) +.InitializeState( + WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")), + WorkflowExpr.Prop("status", WorkflowExpr.String("NEW")))) +``` + +### Business Reference + +Business references provide a queryable key for workflow instances: + +```csharp +flow.SetBusinessReference(new WorkflowBusinessReferenceDeclaration +{ + KeyExpression = WorkflowExpr.Path("state.policyId"), + PartsExpressions = + { + ["policyId"] = WorkflowExpr.Path("state.policyId"), + ["annexId"] = WorkflowExpr.Path("state.annexId"), + }, +}) +``` + +--- + +## Human Tasks + +Human tasks pause workflow execution and wait for a user action (assign, complete, release). + +### Defining a Task + +```csharp +var reviewTask = WorkflowHumanTask.For( + taskName: "Review Changes", + taskType: "ReviewPolicyChanges", + route: "business/policies", + taskRoles: ["UR_UNDERWRITER", "UR_OPERATIONS"]) + .WithPayload(context => new Dictionary + { + ["policyId"] = context.StateValues.GetRequired("policyId").AsJsonElement(), + }) + .WithTimeout(86400) // 24-hour deadline (optional; default: no deadline) + .OnComplete(flow => flow + .WhenExpression( + "Approved?", + WorkflowExpr.Eq(WorkflowExpr.Path("payload.answer"), WorkflowExpr.String("approve")), + approved => approved + .Call("Confirm", confirmAddress, confirmPayload, + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete) + .Complete(), + rejected => rejected.Complete())); +``` + +### Task Properties + +| Property | Type | Description | +|----------|------|-------------| +| `TaskName` | string | Unique name within the workflow | +| `TaskType` | string | UI component type identifier | +| `Route` | string | Navigation route for the UI | +| `TaskRoles` | string[] | Roles that can interact with this task | +| `TimeoutSeconds` | int? | Optional deadline. Null = no deadline (runs until completed or purged) | +| `DeadlineUtc` | DateTime? | Computed: `CreatedOnUtc + TimeoutSeconds`. Null if no timeout set | + +### Task Lifecycle + +``` +Created (Pending) + -> Assigned (user claims task) + -> Completed (user submits payload) + -> OnComplete sequence executes + -> Next task activated, or workflow completes +``` + +### Task Actions & Authorization + +| Action | Who Can Perform | +|--------|----------------| +| `AssignSelf` | Any user with matching effective roles | +| `AssignOther` | Admin roles only | +| `AssignRoles` | Admin roles only | +| `Release` | Current assignee or admin | +| `Complete` | Current assignee or admin | + +--- + +## Transport Calls (Service Tasks) + +Service tasks call external services via pluggable transports. Each call has optional failure and timeout recovery branches. + +### Call with Address + +```csharp +flow.Call( + "Calculate Premium", + Address.LegacyRabbit("pas_premium_calculate_for_object"), + context => new { policyId = context.StateValues.GetRequired("policyId") }, + whenFailure: fail => fail.Complete(), // recovery on failure + whenTimeout: timeout => timeout.Complete(), // recovery on timeout + resultKey: "premiumResult", // store response in state + timeoutSeconds: 120); // per-step timeout override +``` + +### Address Types + +| Address Factory | Transport | Example | +|----------------|-----------|---------| +| `Address.Microservice(name, command)` | Microservice command transport | `Address.Microservice("PasOperations", "perform")` | +| `Address.LegacyRabbit(command)` | Legacy message-bus transport | `Address.LegacyRabbit("pas_premium_calculate")` | +| `Address.Rabbit(exchange, routingKey)` | Exchange/routing-key bus transport | `Address.Rabbit("serdica", "policy.create")` | +| `Address.Http(target, path, method?)` | HTTP REST | `Address.Http("authority", "/api/users", "GET")` | +| `Address.Graphql(target, query)` | GraphQL | `Address.Graphql("serdica", "query { ... }")` | + +### Failure & Timeout Handling + +Every `Call` step supports optional `whenFailure` and `whenTimeout` branches: + +```csharp +.Call("Service Task", address, payload, + whenFailure: fail => fail + .SetState("errorOccurred", WorkflowExpr.Bool(true)) + .Complete(), // graceful completion on failure + whenTimeout: timeout => timeout + .Call("Retry Alternative", altAddress, altPayload, + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete) + .Complete()) +``` + +If neither handler is defined and the transport call fails/times out, the exception propagates and the signal is retried (up to `MaxDeliveryAttempts`). + +### Shorthand Actions + +```csharp +.Call("Step", address, payload, + WorkflowHandledBranchAction.Complete, // on failure: complete workflow + WorkflowHandledBranchAction.Complete) // on timeout: complete workflow +``` + +--- + +## Control Flow + +### Decisions (Conditional Branching) + +```csharp +flow.WhenExpression( + "Is VIP Customer?", + WorkflowExpr.Eq(WorkflowExpr.Path("state.customerType"), WorkflowExpr.String("VIP")), + whenTrue: vip => vip + .Call("VIP Processing", ...) + .Complete(), + whenElse: standard => standard + .Call("Standard Processing", ...) + .Complete()); +``` + +### State Flag Decisions + +```csharp +flow.WhenStateFlag( + "policyExistsOnIPAL", + expectedValue: true, + "Policy exists on IPAL?", + whenTrue: exists => exists.Call("Open For Change", ...), + whenElse: notExists => notExists.Call("Create Policy", ...)); +``` + +### Repeat (Loops) + +```csharp +flow.Repeat( + "Retry Integration", + maxIterations: context => 5, + body: body => body + .Call("Integrate", integrationAddress, payload, + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete) + .SetState("retryCount", WorkflowExpr.Func("add", + WorkflowExpr.Path("state.retryCount"), WorkflowExpr.Number(1))), + continueWhile: WorkflowExpr.Ne( + WorkflowExpr.Path("state.integrationStatus"), + WorkflowExpr.String("SUCCESS"))); +``` + +### Fork (Parallel Branches) + +```csharp +flow.Fork("Process All Objects", + branch1 => branch1.Call("Process Object A", ...), + branch2 => branch2.Call("Process Object B", ...), + branch3 => branch3.Call("Process Object C", ...)); +``` + +All branches execute concurrently. The workflow resumes after all branches complete. + +### Timer (Delay) + +```csharp +flow.Timer("Wait Before Retry", + delay: context => TimeSpan.FromMinutes(5)); +``` + +### External Signal (Wait for Event) + +```csharp +flow.WaitForSignal( + "Wait for Document Upload", + signalName: "documents-uploaded", + resultKey: "uploadedDocuments"); +``` + +Signals are raised via `RaiseExternalSignalAsync` and matched by `signalName` + `WaitingToken`. + +--- + +## Sub-Workflows & Continuations + +### SubWorkflow (Inline Execution) + +Executes a child workflow inline within the parent. The parent waits for the child to complete. + +```csharp +flow.SubWorkflow( + "Run Review Process", + new WorkflowWorkflowInvocationDeclaration + { + WorkflowName = "ReviewPolicyChanges", + PayloadExpression = WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))), + }); +``` + +### ContinueWith (Signal-Based) + +Starts a new workflow instance asynchronously via the signal bus. The parent completes immediately. + +```csharp +flow.ContinueWith( + "Start Transfer Process", + new WorkflowWorkflowInvocationDeclaration + { + WorkflowName = "TransferPolicy", + PayloadExpression = WorkflowExpr.Path("state"), + }); +``` + +**When to use which:** +- **SubWorkflow**: Child must complete before parent continues. State flows back to parent. +- **ContinueWith**: Fire-and-forget. Parent completes, child runs independently. + +--- + +## Canonical Definition Schema + +Every workflow compiles to a canonical JSON definition (`serdica.workflow.definition/v1`). This enables: +- Portable workflow definitions (JSON import/export) +- Runtime validation without C# compilation +- Visual designer support + +### Step Types + +| Type | JSON `$type` | Description | +|------|-------------|-------------| +| Set State | `"set-state"` | Assign a value to workflow state | +| Business Reference | `"assign-business-reference"` | Set the business reference | +| Transport Call | `"call-transport"` | Call an external service | +| Decision | `"decision"` | Conditional branch | +| Activate Task | `"activate-task"` | Pause for human task | +| Continue With | `"continue-with-workflow"` | Start child workflow (async) | +| Sub-Workflow | `"sub-workflow"` | Execute child workflow (inline) | +| Repeat | `"repeat"` | Loop with condition | +| Timer | `"timer"` | Delay execution | +| External Signal | `"external-signal"` | Wait for external event | +| Fork | `"fork"` | Parallel branches | +| Complete | `"complete"` | Terminal step | + +### Transport Address Types + +| Type | JSON `$type` | Properties | +|------|-------------|------------| +| Microservice | `"microservice"` | `microserviceName`, `command` | +| Rabbit | `"rabbit"` | `exchange`, `routingKey` | +| Legacy Rabbit | `"legacy-rabbit"` | `command`, `mode` | +| GraphQL | `"graphql"` | `target`, `query`, `operationName?` | +| HTTP | `"http"` | `target`, `path`, `method` | + +### Example Canonical Definition + +```json +{ + "$schemaVersion": "serdica.workflow.definition/v1", + "workflowName": "ApproveApplication", + "workflowVersion": "1.0.0", + "displayName": "Approve Application", + "workflowRoles": ["DBA", "UR_UNDERWRITER"], + "start": { + "initializeStateExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } } + ] + }, + "sequence": { + "steps": [ + { + "$type": "call-transport", + "stepName": "Validate Policy", + "timeoutSeconds": 60, + "invocation": { + "address": { + "$type": "legacy-rabbit", + "command": "pas_policy_validate" + }, + "payloadExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } } + ] + } + } + }, + { + "$type": "activate-task", + "taskName": "Approve Application", + "timeoutSeconds": 86400 + } + ] + } + }, + "tasks": [ + { + "taskName": "Approve Application", + "taskType": "ApproveQTApproveApplication", + "routeExpression": { "$type": "string", "value": "business/policies" }, + "taskRoles": [], + "payloadExpression": { "$type": "path", "path": "state" } + } + ] +} +``` + +--- + +## Expression System + +The expression system evaluates declarative expressions at runtime without recompilation. All expressions are JSON-serializable for canonical portability. + +### Expression Types + +| Type | Builder | Example | +|------|---------|---------| +| Null | `WorkflowExpr.Null()` | JSON null | +| String | `WorkflowExpr.String("value")` | `"value"` | +| Number | `WorkflowExpr.Number(42)` | `42` | +| Boolean | `WorkflowExpr.Bool(true)` | `true` | +| Path | `WorkflowExpr.Path("state.policyId")` | Navigate object graph | +| Object | `WorkflowExpr.Object(props...)` | Construct object from named props | +| Array | `WorkflowExpr.Array(items...)` | Construct array | +| Function | `WorkflowExpr.Func("name", args...)` | Call a registered function | +| Binary | `WorkflowExpr.Eq(left, right)` | Comparison/arithmetic | +| Unary | `WorkflowExpr.Not(expr)` | Logical negation | + +### Path Navigation + +Paths navigate the execution context: +- `start.*` — Start request fields +- `state.*` — Current workflow state +- `payload.*` — Current task completion payload +- `result.*` — Step result (when `resultKey` is set) + +### Binary Operators + +| Operator | Builder | Description | +|----------|---------|-------------| +| `eq` | `WorkflowExpr.Eq(a, b)` | Equal | +| `ne` | `WorkflowExpr.Ne(a, b)` | Not equal | +| `gt` | `WorkflowExpr.Gt(a, b)` | Greater than | +| `gte` | `WorkflowExpr.Gte(a, b)` | Greater or equal | +| `lt` | `WorkflowExpr.Lt(a, b)` | Less than | +| `lte` | `WorkflowExpr.Lte(a, b)` | Less or equal | +| `and` | `WorkflowExpr.And(a, b)` | Logical AND | +| `or` | `WorkflowExpr.Or(a, b)` | Logical OR | +| `add` | — | Arithmetic addition | +| `subtract` | — | Arithmetic subtraction | +| `multiply` | — | Arithmetic multiplication | +| `divide` | — | Arithmetic division | + +### Built-in Functions + +| Function | Signature | Description | +|----------|-----------|-------------| +| `coalesce` | `coalesce(value1, value2, ...)` | Returns first non-null argument | +| `concat` | `concat(str1, str2, ...)` | String concatenation | +| `add` | `add(num1, num2, ...)` | Sum of numeric arguments | +| `first` | `first(array)` | First element of array | +| `if` | `if(condition, whenTrue, whenFalse)` | Conditional value | +| `isNullOrWhiteSpace` | `isNullOrWhiteSpace(value)` | Check for null/empty string | +| `length` | `length(value)` | Length of string or array | +| `mergeObjects` | `mergeObjects(obj1, obj2, ...)` | Deep merge objects | +| `upper` | `upper(value)` | Uppercase string | +| `selectManyPath` | `selectManyPath(array, path)` | Map over array elements | +| `findPath` | `findPath(data, path)` | Navigate nested paths | + +Custom functions can be registered via `IWorkflowFunctionProvider` plugins. + +--- + +## Signal System + +Signals enable durable, asynchronous communication within workflows. They are persisted to a message queue (Oracle AQ, MongoDB, etc.) and processed by the signal pump. + +### Signal Types + +| Type | Trigger | Purpose | +|------|---------|---------| +| `InternalContinue` | `ContinueWith` step | Start a child workflow asynchronously | +| `TimerDue` | Timer step delay expired | Resume workflow after delay | +| `RetryDue` | Retry delay expired | Resume after backoff | +| `ExternalSignal` | External signal submission through the workflow service surface | External event notification | +| `SubWorkflowCompleted` | Child sub-workflow finished | Resume parent workflow | + +### Signal Envelope + +```csharp +new WorkflowSignalEnvelope +{ + SignalId = "unique-id", + WorkflowInstanceId = "target-instance", + RuntimeProvider = "Serdica.Engine", + SignalType = "ExternalSignal", + ExpectedVersion = 5, // Concurrency control + WaitingToken = "wait-token", // Match specific wait + DueAtUtc = null, // null = immediate, DateTime = scheduled + Payload = { ... }, +} +``` + +### Signal Processing Pipeline + +``` +Oracle AQ / MongoDB / Postgres + -> WorkflowSignalPumpHostedService (N concurrent workers) + -> WorkflowSignalPumpWorker.RunOnceAsync + -> IWorkflowSignalBus.ReceiveAsync (blocking dequeue) + -> WorkflowSignalProcessor.ProcessAsync (route by type) + -> WorkflowSignalCommandDispatcher + -> WorkflowRuntimeService.StartWorkflowAsync (InternalContinue) + -> WorkflowRuntimeService.ResumeSignalAsync (all others) +``` + +### Concurrency Control + +Version-based optimistic concurrency prevents duplicate signal processing: +- Each signal carries `ExpectedVersion` +- `IWorkflowRuntimeStateStore.UpsertAsync` validates version matches +- On mismatch: `WorkflowRuntimeStateConcurrencyException` is thrown +- Signal pump treats concurrency conflicts as successful (completes the lease) + +### Dead Letter Queue + +Signals that fail `MaxDeliveryAttempts` times are moved to the dead-letter queue. Dead letters can be inspected and replayed through the workflow service surface. + +--- + +## Timeout Architecture + +Timeouts operate at three independent levels: + +### Level 1: Per-Step Timeout (Service Tasks) + +Each transport call step has an optional timeout that wraps the entire call (including retries) with a `CancellationTokenSource`. + +| Setting | Default | Override | +|---------|---------|----------| +| `step.TimeoutSeconds` | null | Per-step in workflow declaration | +| `DefaultTimeoutForServiceTaskCallsSeconds` | 3600s (1h) | Code constant (fallback) | + +```csharp +// Per-step override in workflow DSL: +.Call("Slow Service", address, payload, fail, timeout, timeoutSeconds: 300) + +// In canonical JSON: +{ "$type": "call-transport", "timeoutSeconds": 300, ... } +``` + +**Precedence:** `step.TimeoutSeconds` -> `DefaultTimeoutForServiceTaskCallsSeconds` (1h) + +### Level 2: Per-Attempt Transport Timeout + +Each individual transport attempt (single HTTP request, single RPC call) has its own timeout. This is independent of the step-level timeout. + +| Transport | Default | Config Section | +|-----------|---------|---------------| +| HTTP | 30s | `WorkflowHttpTransport.TimeoutSeconds` | +| GraphQL | 30s | `WorkflowGraphqlTransport.TimeoutSeconds` | +| Legacy message-bus transport | 30s | `WorkflowLegacyRabbitTransport.DefaultTimeout` | +| Exchange/routing-key bus transport | 30s | `WorkflowRabbitTransport.DefaultTimeout` | + +The step timeout wraps all attempts. Example: step timeout 120s + transport timeout 30s = up to 4 retries within the step window. + +### Level 3: Engine-Wide Execution Timeout + +Optional global timeout per workflow operation (start, complete, resume). + +| Setting | Default | Config | +|---------|---------|--------| +| `ExecutionTimeoutSeconds` | null (disabled) | `WorkflowEngine.ExecutionTimeoutSeconds` | + +Set to null for long-running business processes that span days or months. + +### Human Task Deadlines + +| Setting | Default | Override | +|---------|---------|----------| +| `TimeoutSeconds` on activate-task | null (no deadline) | `.WithTimeout(seconds)` on task builder | +| `DeadlineUtc` on task summary | null | Computed: `CreatedOnUtc + TimeoutSeconds` | + +When null, human tasks run indefinitely. Stale/orphaned tasks are cleaned up by the retention service. + +--- + +## Retention & Lifecycle + +The retention system automatically manages workflow instance lifecycle. + +### Configuration + +| Setting | Default | Config Section | +|---------|---------|---------------| +| `OpenStaleAfterDays` | 30 | `WorkflowRetention` | +| `CompletedPurgeAfterDays` | 180 | `WorkflowRetention` | + +### Retention Job + +| Setting | Default | Config Section | +|---------|---------|---------------| +| `Enabled` | true | `WorkflowRetentionHostedJob` | +| `RunOnStartup` | false | `WorkflowRetentionHostedJob` | +| `InitialDelay` | 5 min | `WorkflowRetentionHostedJob` | +| `Interval` | 24 hours | `WorkflowRetentionHostedJob` | +| `LockLease` | 2 hours | `WorkflowRetentionHostedJob` | + +### Lifecycle Flow + +``` +Instance Created (Open) + -> StaleAfterUtc = CreatedOnUtc + OpenStaleAfterDays + [Retention job marks as stale] + +Instance Completed + -> PurgeAfterUtc = CompletedOnUtc + CompletedPurgeAfterDays + [Retention job deletes instance, tasks, events, runtime state] +``` + +Manual trigger: `POST /workflow-retention/run` + +--- + +## Authorization + +Task authorization uses a pluggable evaluator pattern. + +### Interface + +```csharp +public interface IWorkflowAssignmentPermissionEvaluator +{ + WorkflowAssignmentPermissionDecision Evaluate(WorkflowAssignmentPermissionContext context); +} +``` + +### Default Plugin: Generic Assignment Permissions + +Configured via `GenericAssignmentPermissions.AdminRoles` (appsettings). + +| Action | Admin | Standard User | +|--------|-------|--------------| +| AssignSelf | Yes | Yes (if has effective role) | +| AssignOther | Yes | No | +| AssignRoles | Yes | No | +| Release | Yes | Yes (if current assignee) | +| Complete | Yes | Yes (if current assignee) | + +### Effective Roles + +A task's `EffectiveRoles` combines: +1. `WorkflowRoles` — from the workflow definition +2. `TaskRoles` — from the task definition +3. `RuntimeRoles` — computed at runtime via expression + +If `TaskRoles` are specified, they narrow the effective roles. Otherwise, `WorkflowRoles` apply. + +--- + +## Plugin System + +Plugins extend the workflow engine with backend stores, transports, signal drivers, and workflow definitions. + +### Plugin Types + +| Category | Example Plugins | +|----------|----------------| +| **Backend Store** | Oracle, MongoDB, Postgres | +| **Signal Driver** | Redis, Oracle AQ (native) | +| **Transport** | HTTP, GraphQL, legacy message-bus, exchange/routing-key bus, microservice command | +| **Permissions** | Generic RBAC | +| **Workflow Definitions** | Bulstrad (customer-specific) | + +### Creating a Plugin + +```csharp +public sealed class ServiceRegistrator : IPluginServiceRegistrator +{ + public void RegisterServices(IServiceCollection services, IConfiguration configuration) + { + services.AddWorkflowModule("my-module", "1.0.0"); + services.AddScoped(); + } +} +``` + +### Loading Order + +Plugins load in the order specified by `PluginsConfig.PluginsOrder` in appsettings. Backend stores must load before transport or workflow plugins. + +### Marker Interfaces + +- `IWorkflowBackendRegistrationMarker` — validates backend plugin is loaded +- `IWorkflowSignalDriverRegistrationMarker` — validates signal driver is loaded + +Startup validation throws `InvalidOperationException` if a configured provider is missing its plugin. + +--- + +## Configuration Reference + +### WorkflowEngine + +```json +{ + "WorkflowEngine": { + "NodeId": "workflow-node-1", + "MaxConcurrentExecutions": 16, + "MaxConcurrentSignalHandlers": 16, + "ExecutionTimeoutSeconds": 300, + "GracefulShutdownTimeoutSeconds": 30 + } +} +``` + +### WorkflowRuntime + +```json +{ + "WorkflowRuntime": { + "DefaultProvider": "Serdica.Engine", + "EnabledProviders": ["Serdica.InProcess", "Serdica.Engine"] + } +} +``` + +### WorkflowAq (Signal Queue) + +```json +{ + "WorkflowAq": { + "QueueOwner": "SRD_WFKLW", + "SignalQueueName": "WF_SIGNAL_Q", + "ScheduleQueueName": "WF_SCHEDULE_Q", + "DeadLetterQueueName": "WF_DLQ_Q", + "ConsumerName": "WORKFLOW_SERVICE", + "BlockingDequeueSeconds": 30, + "MaxDeliveryAttempts": 10 + } +} +``` + +### WorkflowRetention + +```json +{ + "WorkflowRetention": { + "OpenStaleAfterDays": 30, + "CompletedPurgeAfterDays": 180 + } +} +``` + +### WorkflowRetentionHostedJob + +```json +{ + "WorkflowRetentionHostedJob": { + "Enabled": true, + "RunOnStartup": false, + "InitialDelay": "00:05:00", + "Interval": "1.00:00:00", + "LockName": "workflow.retention", + "LockLease": "02:00:00" + } +} +``` + +### Transport Configuration + +```json +{ + "WorkflowHttpTransport": { + "TimeoutSeconds": 30, + "RetryCount": 3, + "Targets": { + "authority": { "Url": "http://localhost:52000", "Headers": {} } + } + }, + "WorkflowGraphqlTransport": { + "TimeoutSeconds": 30, + "RetryCount": 3, + "Targets": { + "serdica": { "Url": "http://localhost:5100/graphql/" } + } + }, + "WorkflowLegacyRabbitTransport": { + "DefaultTimeout": "00:00:30" + }, + "WorkflowRabbitTransport": { + "DefaultTimeout": "00:00:30", + "DefaultUserId": "workflow-engine" + } +} +``` + +### Plugin Loading + +```json +{ + "PluginsConfig": { + "PluginsDirectory": "PluginBinaries", + "PluginsOrder": [ + "assign-permissions", + "workflow-store", + "signal-driver", + "transports", + "workflow-definitions" + ] + } +} +``` + +Use deployment-specific plugin identifiers in that order: durability first, wake mechanism second, transports after that, and workflow-definition bundles last. + +--- + +## Service Surface + +The engine depends on a workflow service surface, but the platform transport and command-mapping layer are intentionally out of scope for this document. + +### Lifecycle Operations + +- Start a workflow instance. +- List workflow instances with filtering (by name, version, status, business reference, instance ID, or multiple instance IDs). Set `IncludeDetails = true` to return each instance's active task and workflow state variables. +- Read one workflow instance with tasks, events, and runtime state. + +### Task Operations + +- List tasks by workflow, status, assignee, or business reference. +- Read one task. +- Assign a task to a user or role group. +- Release a task back to the pool. +- Complete a task with payload. + +### Signal And Operations Management + +- Raise an external signal to a waiting instance. +- Inspect dead-lettered signals. +- Replay dead-lettered signals. +- Inspect signal-pump telemetry. + +### Definitions And Metadata + +- List workflow definitions (filterable by name, version, or multiple names). +- Get a single definition by name with optional rendering assets (SVG/PNG/JSON). +- Render a workflow definition as a diagram. +- Render a definition in a specific format (`svg`, `png`, or `json` render graph). +- Expose the canonical schema. +- Validate canonical definitions. +- Expose the installed function catalog and engine metadata. + +### Definition Deployment + +- Import a canonical definition with versioned storage and content-hash deduplication. +- Export a definition with optional rendering package. +- List all versions of a definition with hash, active flag, and metadata. +- Activate a specific version as the active version for a workflow name. + +### Administration + +- Trigger a manual retention sweep. + +--- + +## Diagram & Visualization + +The engine can render workflow definitions as visual diagrams. + +### Layout Engines + +| Engine | Description | +|--------|-------------| +| **ElkSharp** | Port of Eclipse Layout Kernel (default) | +| **ElkJS** | JavaScript-based ELK via Node.js | +| **MSAGL** | Microsoft Automatic Graph Layout | + +### Configuration + +```json +{ + "WorkflowRendering": { + "LayoutProvider": "ElkSharp" + } +} +``` + +### Render Pipeline + +``` +WorkflowCanonicalDefinition + -> WorkflowRenderGraphCompiler (nodes + edges) + -> WorkflowRenderLayoutEngineResolver (select engine) + -> Layout engine (compute positions) + -> WorkflowRenderDiagramResponse (JSON for UI) +``` + +--- + +## Error Handling + +### Exception Types + +| Exception | Cause | Recovery | +|-----------|-------|----------| +| `WorkflowRuntimeStateConcurrencyException` | Duplicate/stale signal delivery | Auto-handled by signal pump (completes lease) | +| `BaseResultException` | Business validation failure (not found, denied) | Returns error to caller | +| `TimeoutException` | Transport or step timeout exceeded | Executes `WhenTimeout` branch if configured | +| `NotSupportedException` | Unsupported operation (e.g., null signal store) | Configuration error — check plugin loading | + +### Signal Retry Behavior + +| Scenario | Behavior | +|----------|----------| +| Transient error | Signal abandoned, retried on next poll | +| Concurrency conflict | Signal completed (not retried) | +| Max delivery attempts exceeded | Signal moved to dead-letter queue | +| Deserialization failure | Signal dead-lettered with error logged | + +### Observability + +- **Structured logging** via Serilog (all key operations logged with structured properties) +- **Signal pump telemetry** via `WorkflowSignalPumpTelemetryService` (in-memory counters, queryable through the workflow service surface) +- **W3C trace IDs** enabled (`Activity.DefaultIdFormat = W3C`) + +--- + +## Compiler & Decompiler + +### Forward Compiler + +`WorkflowCanonicalDefinitionCompiler.Compile()` converts a C# fluent DSL workflow (`IDeclarativeWorkflow`) into a canonical JSON definition (`WorkflowCanonicalDefinition`). This runs at startup for all registered workflows. + +The compiler also generates a **JSON Schema** for the start request type, embedded in the canonical definition's `startRequest.schema` field. This provides a portable, CLR-independent contract for the workflow's input. + +### Reverse Compiler (Decompiler) + +`WorkflowCanonicalDecompiler` converts a canonical definition back to C# source code using Roslyn `SyntaxFactory`. Two modes: + +- **`Decompile(definition)`** — produces formatted C# source text including a typed start request class generated from the JSON Schema and the full workflow class with fluent builder chain +- **`Reconstruct(definition)`** — produces a new `WorkflowCanonicalDefinition` via deep clone (for structural comparison) + +The decompiler uses `nameof()` for all type and method references (`WorkflowExpr.Obj`, `LegacyRabbitAddress`, etc.) to ensure rename safety at compile time. + +### Round-Trip Verification + +The test suite verifies compiler fidelity via real Roslyn dynamic compilation: + +``` +Original C# workflow + -> [compile] -> canonical JSON (JSON1) + -> [decompile] -> C# source text + -> [Roslyn CSharpCompilation] -> in-memory assembly + -> [reflection: instantiate workflow] + -> [compile] -> canonical JSON (JSON2) + -> assert JSON1 == JSON2 +``` + +This catches any information loss in the compile/decompile cycle: missing steps, truncated expressions, wrong addresses, lost failure/timeout branches. + +**Test results:** +- 177/177 decompiled C# files compile cleanly with Roslyn +- Semantic round-trip comparison identifies remaining gaps for iterative improvement + +### Decompiled Output + +Running the `RenderAllDecompiledOutputs` test generates human-readable output for all workflows: + +``` +docs/decompiled-samples/ + csharp/ 177 .cs files (Roslyn-formatted C# with typed request models) + json/ 177 .json files (indented canonical definitions with JSON Schema) +``` + diff --git a/docs/workflow/engine/01-requirements-and-principles.md b/docs/workflow/engine/01-requirements-and-principles.md new file mode 100644 index 000000000..d7b255a02 --- /dev/null +++ b/docs/workflow/engine/01-requirements-and-principles.md @@ -0,0 +1,291 @@ +# 01. Requirements And Principles + +## 1. Product Goal + +Build a Serdica-owned workflow engine that can run the current Bulstrad workflow corpus without Elsa while preserving the existing service-level workflow product: + +- workflow start +- task inbox and task lifecycle +- business-reference based lookup +- runtime state inspection +- workflow diagrams +- canonical schema and canonical validation exposure +- workflow retention and hosted jobs + +The engine must execute the same business behavior currently expressed in the declarative workflow DSL and canonical workflow definition model. + +## 2. Functional Requirements + +### 2.1 Workflow Definition Handling + +The engine must: + +- discover workflow registrations from authored C# workflow classes +- resolve the latest or exact workflow version through the existing registration catalog +- compile authored declarative workflows into canonical runtime definitions +- keep canonical validation as a first-class platform capability +- reject invalid or unsupported definitions during startup or validation + +### 2.2 Workflow Start + +The engine must: + +- bind the untyped start payload to the workflow start request type +- resolve or derive business reference data +- initialize canonical workflow state +- execute the initial sequence until a wait boundary or completion +- create workflow projections and runtime state in one durable flow +- support workflow continuations created during start + +### 2.3 Human Tasks + +The engine must: + +- activate human tasks with: + - task type + - route + - workflow roles + - task roles + - runtime roles + - payload + - business reference +- preserve the current task assignment model: + - assign to self + - assign to user + - assign to runtime roles + - release +- expose completed and active task history through the existing projection model + +### 2.4 Task Completion + +The engine must: + +- load the current workflow state and task context +- authorize completion through the existing service layer +- apply completion payload +- continue execution from the task completion entry point +- produce next tasks, next waits, next continuations, or completion +- update runtime state and read projections durably + +### 2.5 Runtime Semantics + +The engine must support the semantic surface already present in declarative workflows: + +- state assignment +- business reference assignment +- human task activation +- microservice calls +- legacy rabbit calls +- GraphQL calls +- HTTP calls +- conditional branches +- decision branches +- repeat loops +- subworkflow invocation +- continue-with orchestration +- timeout branches +- failure branches +- function-backed expressions + +### 2.6 Subworkflows + +The engine must: + +- start child workflows +- persist parent resume frames +- carry child output back into parent state +- support nested resume across multiple levels +- preserve current declarative subworkflow semantics + +### 2.7 Scheduling + +The engine must support: + +- timeouts +- retry wake-ups +- delayed continuation +- explicit wait-until behavior + +This must happen without a steady-state polling loop. + +### 2.8 Inspection And Operations + +The service must continue to expose: + +- workflow definitions +- workflow instances +- workflow tasks +- workflow task events +- workflow diagrams +- runtime state snapshots +- canonical schema +- canonical validation + +## 3. Non-Functional Requirements + +### 3.1 Multi-Instance Deployment + +The service must support multiple application nodes against one shared Oracle database. + +Implications: + +- no single-node assumptions +- no in-memory-only correctness logic +- no sticky workflow ownership +- duplicate signal delivery must be safe + +### 3.2 Durability + +The system of record must be durable across: + +- process restart +- node restart +- full cluster restart +- database restart + +Workflow progress, pending waits, active tasks, and due timers must not be lost. + +### 3.3 No Polling + +Signal-driven wake-up is mandatory. + +The engine must not rely on a periodic database scan loop to discover work. Blocking or event-driven delivery is required for: + +- task completion wake-up +- delayed resume wake-up +- subworkflow completion wake-up +- external signal wake-up + +### 3.4 One Database + +Oracle is the shared durable state backend for: + +- workflow projections +- workflow runtime snapshots +- host coordination +- signal and schedule durability through Oracle AQ + +Redis may exist in the wider platform, but it is not required for engine correctness. + +### 3.5 Observability + +The engine must produce enough telemetry to answer: + +- what instance is waiting +- why it is waiting +- which signal resumed it +- which node executed it +- which definition version it used +- why it failed +- whether a message was retried, dead-lettered, or ignored as stale + +### 3.6 Compatibility + +The engine must preserve the existing public workflow service contracts unless a future product change explicitly changes them. + +The following service-contract groups are especially important: + +- workflow start contracts +- workflow definition contracts +- workflow task contracts +- workflow instance contracts +- workflow operational contracts + +## 4. Explicit V1 Assumptions + +These assumptions simplify the engine architecture and are intentional. + +### 4.1 Single Active Runtime Provider Per Deployment + +The service runs one engine provider at a time. + +This means: + +- no mixed-provider instance routing +- no live migration between engines +- no simultaneous old-runtime and engine execution inside one deployment + +The design still keeps abstractions around the runtime, signaling bus, and scheduler so that future replacement remains possible. + +### 4.2 Canonical Runtime, Not Elsa Activity Runtime + +The target engine executes canonical workflow definitions directly. + +Authored C# remains the source of truth, but runtime semantics are driven by canonical definitions compiled from that source. + +### 4.3 Oracle AQ Is The Default Event Backbone + +Oracle AQ is treated as part of the durable engine platform because it satisfies: + +- one-database architecture +- blocking dequeue +- durable delivery +- delayed delivery +- transactional behavior + +## 5. Design Principles + +### 5.1 Keep The Product Surface Stable + +The workflow service remains the product boundary. The engine is an internal subsystem. + +### 5.2 Separate Read Model From Runtime Model + +Task and instance projections are optimized for product reads. + +Runtime snapshots are optimized for deterministic resume. + +They are related, but they are not the same data structure. + +### 5.3 Run To Wait + +The engine should never keep a workflow instance “hot” in memory for correctness. + +Execution should run until: + +- a task is activated +- a timer is scheduled +- an external signal wait is registered +- the workflow completes + +Then the snapshot is persisted and released. + +### 5.4 Make Delivery At-Least-Once And Resume Idempotent + +Distributed delivery is never exactly-once in practice. + +The engine must treat duplicate signals, duplicate wake-ups, and late timer arrivals as normal conditions. + +### 5.5 Keep Signals Small + +Signals should identify work, not carry the full workflow state. + +The database snapshot remains authoritative. + +### 5.6 Keep Abstractions At The Backend Boundary + +Abstract: + +- runtime provider +- signal bus +- schedule bus +- snapshot store + +Do not abstract away the workflow semantics themselves. + +### 5.7 Prefer Transactional Consistency Over Cleverness + +If a feature can be made transactional in Oracle, prefer that over eventually-consistent coordination tricks. + +## 6. Success Criteria + +The engine architecture is successful when: + +- the service can start and complete workflows without Elsa +- task projections remain correct +- delayed resumes happen without polling +- a stopped cluster resumes safely after restart +- a multi-node deployment does not corrupt workflow state +- canonical definitions remain the execution contract +- operations can inspect and support the system with existing product-level APIs + diff --git a/docs/workflow/engine/02-runtime-and-component-architecture.md b/docs/workflow/engine/02-runtime-and-component-architecture.md new file mode 100644 index 000000000..316d4c40d --- /dev/null +++ b/docs/workflow/engine/02-runtime-and-component-architecture.md @@ -0,0 +1,397 @@ +# 02. Runtime And Component Architecture + +## 1. Top-Level System View + +At the highest level, the service contains six product-facing areas: + +1. definition and canonical catalog +2. start and task APIs +3. engine execution runtime +4. durable state and read projections +5. signaling and scheduling +6. operational services + +The engine replaces the Elsa-dependent runtime area, not the whole product. + +## 2. Top-Level Components + +### 2.1 API Layer + +Responsibilities: + +- expose workflow endpoints +- validate user input +- call `WorkflowRuntimeService` +- preserve current contract shape + +Examples in the current service: + +- workflow start endpoint +- task get/list endpoints +- task assign/release/complete endpoints +- instance get/list endpoints +- canonical schema and validation endpoints + +### 2.2 Product Orchestration Layer + +Responsibilities: + +- resolve workflow registration and definition +- enforce service-level flow for start and task completion +- update read projections +- call runtime provider +- persist runtime snapshot metadata +- start continuations + +The current workflow runtime service remains the product orchestrator in v1. + +### 2.3 Runtime Provider Layer + +Responsibilities: + +- provide a stable execution interface +- hide the concrete runtime implementation +- allow a future backend swap without changing service-level behavior + +Proposed abstraction: + +```csharp +public interface IWorkflowRuntimeProvider +{ + string ProviderName { get; } + + Task StartAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + StartWorkflowRequest request, + object startRequest, + CancellationToken cancellationToken = default); + + Task CompleteAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default); +} +``` + +In v1, one provider is active per deployment: + +- `SerdicaEngineRuntimeProvider` + +The abstraction still exists so the backend can change later. + +### 2.4 Canonical Execution Layer + +Responsibilities: + +- execute canonical definitions +- evaluate expressions +- drive state transitions +- activate tasks +- invoke transports +- persist wait state +- emit signals and schedules + +This is the actual engine kernel. + +### 2.5 Persistence Layer + +Responsibilities: + +- store runtime snapshots +- store instance projections +- store task projections +- store task events +- coordinate host-owned jobs and workers + +The current baseline uses one workflow database model plus one projection application service for product-facing reads. + +### 2.6 Signal And Schedule Layer + +Responsibilities: + +- deliver immediate wake-up signals +- deliver delayed wake-up signals +- support blocking receive +- support durable retry and dead-letter handling + +Default backend: + +- Oracle AQ + +### 2.7 Operational Layer + +Responsibilities: + +- retention +- dead-letter handling +- metrics +- tracing +- runtime diagnostics +- workflow diagram projection + +## 3. Mid-Level Runtime Structure + +The engine should be decomposed into the following internal runtime components. + +### 3.1 Definition Normalizer + +Purpose: + +- take authored workflow registrations +- compile them into canonical runtime definitions +- validate the definitions +- cache them for execution + +Responsibilities: + +- call canonical compiler +- call canonical validator +- fail startup when configured to require valid definitions +- expose resolved runtime definitions by workflow name/version + +### 3.2 Execution Coordinator + +Purpose: + +- provide the single in-process entry point for runtime execution + +Responsibilities: + +- load current snapshot +- acquire execution right through version check or row lock +- invoke interpreter +- collect engine side effects +- persist snapshot changes +- update projections +- enqueue signals or schedules +- commit transaction + +### 3.3 Canonical Interpreter + +Purpose: + +- interpret canonical steps until the next wait boundary + +Responsibilities: + +- evaluate canonical expressions +- handle step sequencing +- handle branching and repeat loops +- activate human tasks +- invoke transport adapters +- enter wait states +- resume from wait states +- manage subworkflow frames + +### 3.4 Expression Runtime + +Purpose: + +- evaluate canonical expressions consistently across runtime and validation expectations + +Responsibilities: + +- use core function catalog +- use plugin function catalog +- evaluate against the canonical execution context + +Current design baseline: + +- one canonical expression runtime +- one core function catalog +- zero or more plugin-provided function catalogs + +### 3.5 Transport Dispatcher + +Purpose: + +- execute transport-backed steps through Serdica transport abstractions + +Responsibilities: + +- resolve transport type +- call the correct adapter +- normalize responses to canonical result objects +- route failure and timeout behavior back into the interpreter + +### 3.6 Task Activation Writer + +Purpose: + +- convert a runtime task activation result into projection rows + +Responsibilities: + +- create task rows +- create task-created events +- preserve business reference and role semantics + +### 3.7 Signal Pump + +Purpose: + +- block on AQ dequeue +- dispatch envelopes to the execution coordinator + +Responsibilities: + +- receive signal envelope +- process with bounded concurrency +- complete or abandon transactionally +- dead-letter poison signals + +### 3.8 Scheduler Adapter + +Purpose: + +- translate runtime waits into AQ delayed messages + +Responsibilities: + +- enqueue due signals with delay +- cancel logically through waiting tokens +- ignore stale delayed messages safely + +## 4. Detailed Component Responsibilities + +### 4.1 WorkflowRuntimeService + +This service remains the product boundary for runtime actions. + +It should continue to own: + +- start request binding +- business reference resolution +- task authorization integration +- projection updates +- runtime snapshot persistence +- continuation dispatch + +It should stop owning: + +- engine-specific step execution logic +- engine-specific scheduling details +- engine-specific signal handling + +### 4.2 SerdicaEngineRuntimeProvider + +This provider becomes the main bridge between product orchestration and the runtime kernel. + +It should: + +- normalize the requested workflow into a canonical runtime definition +- create an execution request +- call the execution coordinator +- map engine execution results into `WorkflowRuntimeExecutionResult` + +It should not: + +- update read projections directly +- own task authorization +- know about HTTP endpoint contracts + +### 4.3 WorkflowProjectionStore + +This store remains the read model writer. + +It should continue to own: + +- `WF_INSTANCES` +- `WF_TASKS` +- `WF_TASK_EVENTS` + +It should not become the engine snapshot store. + +### 4.4 Runtime Snapshot Store + +This store owns the authoritative engine snapshot. + +It should: + +- read current runtime state +- write runtime state atomically +- enforce optimistic concurrency or explicit version progression +- store waiting metadata +- store provider state + +It may evolve from the current `IWorkflowRuntimeStateStore`. + +### 4.5 AQ Signal Bus + +This adapter owns durable wake-up delivery. + +It should: + +- publish immediate signals +- publish delayed signals +- receive with blocking dequeue +- expose complete/abandon semantics + +It should not: + +- understand workflow business logic +- mutate projections +- deserialize full workflow snapshots + +## 5. Runtime Request Flows + +### 5.1 Start Workflow + +1. API receives `StartWorkflowRequest`. +2. `WorkflowRuntimeService` resolves registration and definition. +3. The typed request is bound from payload. +4. Business reference is resolved. +5. `SerdicaEngineRuntimeProvider.StartAsync` is called. +6. The provider resolves the canonical runtime definition. +7. The execution coordinator creates a new snapshot and runs the interpreter. +8. The interpreter runs until: + - a task is activated + - a timer wait is registered + - an external wait is registered + - the workflow completes +9. The coordinator persists runtime snapshot changes. +10. `WorkflowRuntimeService` writes projections and runtime metadata. +11. Continuations are started if present. + +### 5.2 Complete Task + +1. API receives `WorkflowTaskCompleteRequest`. +2. `WorkflowRuntimeService` loads snapshot and task projection. +3. Authorization is checked. +4. The runtime provider is called with: + - task context + - workflow state + - completion payload +5. The execution coordinator advances the canonical definition from the task completion entry point. +6. It persists the new runtime snapshot and engine wait state. +7. `WorkflowRuntimeService` applies task completion and creates new task rows if needed. + +### 5.3 External Or Scheduled Signal + +1. AQ signal pump dequeues a signal. +2. The signal is deserialized to a workflow signal envelope. +3. The execution coordinator loads the current snapshot. +4. The coordinator verifies: + - workflow instance exists + - waiting token matches + - version is compatible +5. The interpreter resumes from the stored resume point. +6. The transaction commits snapshot changes, projection changes, and any next signals. + +## 6. Why This Structure Fits The Current Service + +The current service already separates: + +- product orchestration +- execution abstraction +- projections +- runtime state +- authorization + +The new engine architecture uses that separation rather than fighting it. + +That is the main reason the replacement can be implemented incrementally without redesigning the whole product. + diff --git a/docs/workflow/engine/03-canonical-execution-model.md b/docs/workflow/engine/03-canonical-execution-model.md new file mode 100644 index 000000000..3d04900bc --- /dev/null +++ b/docs/workflow/engine/03-canonical-execution-model.md @@ -0,0 +1,377 @@ +# 03. Canonical Execution Model + +## 1. Why The Engine Executes Canonical Definitions + +The workflow corpus is now fully declarative and canonicalizable. + +That changes the best runtime strategy: + +- authored C# remains the source of truth +- canonical definition becomes the runtime execution contract +- the engine interprets canonical definitions directly + +This gives the platform: + +- deterministic runtime behavior +- shared semantics between export/import and execution +- less runtime coupling to workflow-specific CLR delegates +- a clean separation between authoring and execution + +## 2. Definition Lifecycle + +### 2.1 Authoring + +Workflows are authored in C# through the declarative DSL. + +### 2.2 Normalization + +At service startup, each workflow registration is normalized into: + +1. workflow registration metadata +2. canonical workflow definition +3. required module set +4. function usage metadata + +### 2.3 Validation + +The runtime should validate canonical definitions before accepting them for execution. + +Recommended startup modes: + +- `Strict` + Startup fails if a definition is invalid. +- `Warn` + Startup succeeds, but invalid definitions are marked unavailable. + +### 2.4 Runtime Cache + +The engine should cache canonical runtime definitions in memory by: + +- workflow name +- workflow version + +This cache is immutable after startup in v1. + +## 3. Canonical Runtime Definition Shape + +The runtime definition should be treated as a compiled, execution-ready representation of the canonical contracts, not a raw JSON document. + +The runtime model should contain: + +- definition identity +- display metadata +- required modules +- step graph +- task declarations +- expression trees +- transport declarations +- subworkflow declarations +- continue-with declarations + +## 4. Execution Context Model + +The interpreter should run every step against a single canonical execution context. + +Recommended execution context fields: + +- `WorkflowName` +- `WorkflowVersion` +- `WorkflowInstanceId` +- `BusinessReference` +- `State` +- `StartPayload` +- `CompletionPayload` +- `CurrentTask` +- `CurrentSignal` +- `FunctionRuntime` +- `TransportDispatcher` +- `RuntimeMetadata` + +`RuntimeMetadata` should hold: + +- node id +- current signal id +- snapshot version +- waiting token +- execution started at + +## 5. Core Runtime State Model + +The runtime must distinguish between: + +- business state +- engine state + +### 5.1 Business State + +Business state is what the workflow author reasons about. + +Examples: + +- `srPolicyId` +- `policySubstatus` +- customer lookup state +- payload shaping outputs +- subworkflow results + +### 5.2 Engine State + +Engine state is what the runtime needs to resume correctly. + +Examples: + +- current workflow status +- current wait type +- current wait token +- active task identity +- resume pointer +- subworkflow frame stack +- outstanding timer descriptors +- last processed signal id + +Business state must remain visible in runtime inspection. +Engine state must remain safe and deterministic for resume. + +## 6. Run-To-Wait Execution Model + +The engine uses a run-to-wait interpreter. + +This means: + +1. load snapshot +2. execute sequentially +3. stop when a durable wait boundary is reached +4. persist resulting snapshot +5. release instance + +Wait boundaries are: + +- human task activation +- scheduled timer +- external signal wait +- child workflow wait +- terminal completion + +This model is essential for: + +- multi-instance safety +- restart recovery +- no sticky ownership +- no in-memory correctness assumptions + +## 7. Step Semantics + +### 7.1 State Assignment + +State assignment is immediate and local to the current execution transaction. + +The engine: + +- evaluates the assignment expression +- writes to the business state dictionary +- keeps changes in-memory until the next durable checkpoint + +### 7.2 Business Reference Assignment + +Business reference assignment updates the canonical business reference attached to: + +- the runtime snapshot +- new tasks +- instance projection updates + +Business reference changes must be applied transactionally with other execution results. + +### 7.3 Human Task Activation + +A human task activation step is a terminal wait boundary. + +The interpreter does not continue past it in the same execution. + +The result of task activation is: + +- one active task projection +- updated instance status +- updated runtime snapshot +- optional runtime metadata for the active task + +### 7.4 Transport Call + +Transport calls are synchronous from the perspective of a single execution slice. + +The engine: + +- evaluates payload expressions +- dispatches through the correct transport adapter +- captures result payload +- stores result under the result key when present +- chooses the success, failure, or timeout branch + +No engine-specific callback registration should be required for normal synchronous transport calls. + +### 7.5 Conditional Branch + +Conditions evaluate against the current execution context. + +Only one branch is executed. + +The branch path must be reproducible in the resume pointer model. + +### 7.6 Repeat + +Repeat executes logically as: + +- evaluate collection or repeat source +- for each iteration: + - bind iteration context + - execute nested sequence + +If an iteration hits a wait boundary, the engine snapshot must preserve: + +- repeat step id +- iteration index +- remaining resume location inside the iteration body + +### 7.7 Subworkflow Invocation + +Subworkflow invocation is a wait boundary unless the child completes inline before producing a wait. + +Parent snapshot must record: + +- child workflow identity +- child workflow version +- parent business reference +- parent resume pointer +- target result key +- parent workflow state needed for resume + +### 7.8 Continue-With + +Continue-with creates a new workflow start request as an engine side effect. + +It is not a resume boundary for the current instance unless explicitly modeled that way by the workflow. + +## 8. Resume Model + +### 8.1 Resume Pointer + +The engine must persist a deterministic resume pointer. + +It should identify: + +- entry point kind +- task name if resuming from task completion +- branch path +- next step index +- repeat iteration where applicable + +The existing declarative resume model is the right conceptual baseline, but the engine should persist it inside the canonical runtime snapshot rather than inside a CLR-only execution flow. + +### 8.2 Waiting Token + +Every durable wait must have a waiting token. + +The waiting token is how the engine prevents stale resumes. + +When a signal arrives: + +- if the waiting token does not match the snapshot +- the signal is stale and must be ignored safely + +This is the primary guard for: + +- canceled timers +- duplicate wake-ups +- late child completions +- redelivered signals + +### 8.3 Version + +Every successful execution commit must increment snapshot version. + +Signals may carry the expected version that created the wait. + +This allows the engine to detect stale work before any mutation. + +## 9. Human Task Model + +The task model remains projection-first. + +The runtime does not wait on an in-memory task object. + +Instead: + +- task activation writes a task projection row +- runtime snapshot enters `WaitingForTaskCompletion` +- task completion API provides the wake-up event + +Task completion is therefore an external signal into the engine. + +## 10. Error Model + +The interpreter should classify errors into: + +- definition errors +- expression evaluation errors +- transport errors +- timeout errors +- authorization errors +- engine consistency errors + +Definition errors are startup or validation failures. +Execution errors are runtime failures that may: + +- route into a failure branch +- schedule a retry +- fail the workflow +- move the instance to a recoverable error state + +## 11. Retry Model + +Retries should be modeled explicitly as scheduled signals. + +The engine should not sleep inside a worker. + +A retry should: + +1. persist the failure context +2. generate a new waiting token +3. enqueue a delayed resume signal +4. commit + +## 12. Completion Model + +A workflow completes when the interpreter reaches terminal completion with no outstanding waits. + +Completion result must: + +- mark instance projection completed +- mark runtime state completed +- clear stale timeout metadata +- apply retention timing + +## 13. Determinism Requirements + +The runtime must assume: + +- expressions are deterministic given the execution context +- transport calls are side effects and must be treated explicitly +- no hidden CLR delegate behavior remains in workflow definitions + +The runtime should not rely on: + +- non-deterministic local time calls inside step execution +- in-memory mutable workflow objects +- ambient state outside the canonical execution context + +## 14. Resulting Implementation Shape + +The engine kernel should be implemented as: + +- definition normalizer +- canonical interpreter +- transport dispatcher +- execution coordinator +- resume serializer/deserializer + +This produces a runtime that is small, explicit, and aligned with the already-completed full-declaration effort. + diff --git a/docs/workflow/engine/04-persistence-signaling-and-scheduling.md b/docs/workflow/engine/04-persistence-signaling-and-scheduling.md new file mode 100644 index 000000000..7730b39ae --- /dev/null +++ b/docs/workflow/engine/04-persistence-signaling-and-scheduling.md @@ -0,0 +1,403 @@ +# 04. Persistence, Signaling, And Scheduling + +## 1. Persistence Strategy + +Oracle is the single durable backend for v1. + +It stores: + +- workflow instance projections +- workflow task projections +- workflow task events +- workflow runtime snapshots +- hosted job locks +- AQ queues for immediate and delayed signals + +This keeps correctness inside one transactional platform. + +## 2. Existing Tables To Preserve + +The current workflow schema already has the right base tables: + +- `WF_INSTANCES` +- `WF_TASKS` +- `WF_TASK_EVENTS` +- `WF_RUNTIME_STATES` +- `WF_HOST_LOCKS` + +The current workflow database model is the mapping baseline for these tables. + +### 2.1 WF_INSTANCES + +Purpose: + +- product-facing workflow instance summary +- instance business reference +- instance status +- product-facing state snapshot + +### 2.2 WF_TASKS + +Purpose: + +- active and historical human task projections +- task routing +- assignment +- task payload +- effective roles + +### 2.3 WF_TASK_EVENTS + +Purpose: + +- append-only task event history +- created, assigned, released, completed, reassigned events + +### 2.4 WF_RUNTIME_STATES + +Purpose: + +- engine-owned durable runtime snapshot + +This table becomes the main source of truth for engine resume. + +## 3. Proposed Runtime State Extensions + +`WF_RUNTIME_STATES` should be extended to support canonical engine execution directly. + +Recommended new columns: + +- `STATE_VERSION` + Numeric optimistic concurrency version. +- `SNAPSHOT_SCHEMA_VERSION` + Snapshot format version for engine evolution. +- `WAITING_KIND` + Current wait type. +- `WAITING_TOKEN` + Stale-signal guard token. +- `WAITING_UNTIL_UTC` + Next due time when waiting on time-based resume. +- `ACTIVE_TASK_ID` + Current active task projection id when applicable. +- `RESUME_POINTER_JSON` + Serialized canonical resume pointer. +- `LAST_SIGNAL_ID` + Last successfully processed signal id. +- `LAST_ERROR_CODE` + Last engine error code. +- `LAST_ERROR_JSON` + Structured last error details. +- `LAST_EXECUTED_BY` + Node id that last committed execution. +- `LAST_EXECUTED_ON_UTC` + Last successful engine commit timestamp. + +The existing fields remain useful: + +- workflow identity +- business reference +- runtime provider +- runtime instance id +- runtime status +- state json +- lifecycle timestamps + +## 4. Snapshot Structure + +`STATE_JSON` should hold a provider snapshot object for `SerdicaEngine`. + +Recommended shape: + +```json +{ + "engineSchemaVersion": 1, + "workflowState": {}, + "businessReference": { + "key": "1200345", + "parts": {} + }, + "status": "Open", + "waiting": { + "kind": "TaskCompletion", + "token": "wait-123", + "untilUtc": null + }, + "resume": { + "entryPointKind": "TaskOnComplete", + "taskName": "ApproveApplication", + "branchPath": [], + "nextStepIndex": 3 + }, + "subWorkflowFrames": [], + "continuationBuffer": [] +} +``` + +## 5. Oracle AQ Strategy + +### 5.1 Why AQ + +AQ is the default signaling backend because it gives: + +- durable storage +- blocking dequeue +- delayed delivery +- database-managed recovery +- transactional semantics close to the runtime state store + +### 5.2 Queue Topology + +Use explicit queues for clarity and operations. + +Recommended objects: + +- `WF_SIGNAL_QTAB` +- `WF_SIGNAL_Q` +- `WF_SCHEDULE_QTAB` +- `WF_SCHEDULE_Q` +- `WF_DLQ_QTAB` +- `WF_DLQ_Q` + +Rationale: + +- immediate signals and delayed signals are operationally different +- dead-letter isolation matters for supportability +- queue separation makes metrics and troubleshooting simpler + +### 5.3 Payload Format + +Use a compact JSON envelope serialized to UTF-8 bytes in a `RAW` payload. + +Reasons: + +- simple from .NET +- explicit schema ownership in application code +- small message size +- backend abstraction remains possible later + +Do not put full workflow snapshots into AQ messages. + +## 6. Signal Envelope + +Recommended envelope: + +```csharp +public sealed record WorkflowSignalEnvelope +{ + public required string SignalId { get; init; } + public required string WorkflowInstanceId { get; init; } + public required string RuntimeProvider { get; init; } + public required string SignalType { get; init; } + public required long ExpectedVersion { get; init; } + public string? WaitingToken { get; init; } + public DateTime OccurredAtUtc { get; init; } + public DateTime? DueAtUtc { get; init; } + public Dictionary Payload { get; init; } = []; +} +``` + +Signal types: + +- `TaskCompleted` +- `TimerDue` +- `RetryDue` +- `ExternalSignal` +- `SubWorkflowCompleted` +- `InternalContinue` + +## 7. Transaction Model + +### 7.1 Start Transaction + +Start must durably commit: + +- instance projection +- runtime snapshot +- task rows if any +- task events if any +- scheduled or immediate AQ messages if any + +### 7.2 Completion Transaction + +Task completion must durably commit: + +- task completion event +- updated instance projection +- new task rows if any +- updated runtime snapshot +- any resulting AQ signals or schedules + +### 7.3 Signal Resume Transaction + +Signal resume must durably commit: + +- AQ dequeue +- updated runtime snapshot +- resulting projection changes +- any next AQ signals + +The intended operational model is: + +- dequeue with transactional semantics +- update state and projections +- commit once + +If commit fails, the signal must become visible again. + +## 8. Blocking Receive Model + +No polling loop should be used for work discovery. + +Each node should run a signal pump that: + +- opens one or more blocking AQ dequeue consumers +- waits on AQ rather than sleeping and scanning +- dispatches envelopes to bounded execution workers + +Suggested parameters: + +- dequeue wait seconds +- max concurrent handlers +- max poison retries +- dead-letter policy + +## 9. Scheduling Model + +### 9.1 Scheduling Requirement + +The engine must support timers without a periodic sweep job. + +### 9.2 Scheduling Approach + +When a workflow enters a timed wait: + +1. runtime snapshot is updated with: + - waiting kind + - waiting token + - due time +2. a delayed AQ message is enqueued +3. transaction commits + +When the delayed message becomes available: + +1. a signal consumer dequeues it +2. current snapshot is loaded +3. waiting token is checked +4. if token matches, resume +5. if token does not match, ignore as stale + +### 9.3 Logical Cancel Instead Of Physical Delete + +The scheduler should treat cancel and reschedule logically. + +Do not make correctness depend on deleting a queued timer message. + +Instead: + +- generate a new waiting token when schedule changes +- old delayed message becomes stale automatically + +This is simpler and more reliable in distributed execution. + +## 10. Multi-Node Concurrency Model + +The engine must assume multiple nodes can receive signals for the same workflow instance. + +Correctness model: + +- signal delivery is at-least-once +- snapshot update uses version control +- waiting token guards stale work +- duplicate resumes are safe + +Recommended write model: + +- read snapshot version +- execute +- update `WF_RUNTIME_STATES` where `STATE_VERSION = expected` +- if update count is zero, abandon and retry or ignore as stale + +This avoids permanent instance ownership. + +## 11. Restart And Recovery Semantics + +### 11.1 One Node Down + +Other nodes continue consuming AQ and processing instances. + +### 11.2 All Nodes Down, Database Up + +Signals remain durable in AQ. + +When any node comes back: + +- AQ consumers reconnect +- pending immediate and delayed signals are processed +- workflow resumes continue + +### 11.3 Database Down + +No execution can continue while Oracle is unavailable. + +Once Oracle returns: + +- AQ queues recover with the database +- runtime snapshots recover with the database +- resumed node consumers continue from durable state + +### 11.4 All Nodes And Database Down + +After Oracle returns and at least one application node starts: + +- AQ messages are still present +- runtime state is still present +- due delayed messages can be consumed +- execution resumes from durable state + +This is one of the main reasons Oracle AQ is preferred over a separate volatile wake-up layer. + +## 12. Redis Position In V1 + +Redis is optional and not part of the correctness path. + +It may be used later for: + +- local cache +- non-authoritative wake hints +- metrics fanout + +It should not be required for: + +- durable signal delivery +- timer delivery +- restart recovery + +## 13. Dead-Letter Strategy + +Messages should move to DLQ when: + +- deserialization fails +- definition is missing +- snapshot is irreparably inconsistent +- retry count exceeds threshold + +DLQ entry should preserve: + +- original envelope +- failure reason +- last node id +- failure timestamp + +## 14. Retention + +Retention remains a service responsibility. + +It should continue to clean: + +- stale instances +- stale tasks +- completed data past purge window +- runtime states past purge window + +AQ retention policy should be aligned with application retention and supportability needs, but queue cleanup must not delete active work. + diff --git a/docs/workflow/engine/05-service-surface-hosting-and-operations.md b/docs/workflow/engine/05-service-surface-hosting-and-operations.md new file mode 100644 index 000000000..52cafb2bb --- /dev/null +++ b/docs/workflow/engine/05-service-surface-hosting-and-operations.md @@ -0,0 +1,425 @@ +# 05. Service Surface, Hosting, And Operations + +## 1. Public Service Surface + +The engine replacement must preserve the current workflow product APIs. + +That means the following capability groups remain stable: + +- workflow definition inspection +- workflow start +- workflow tasks list/get/assign/release/complete +- workflow instances list/get +- workflow diagrams +- workflow retention run +- canonical schema inspection +- canonical import validation + +The existing service-contract groups remain the baseline: + +- workflow definition contracts +- workflow start contracts +- workflow task contracts +- workflow instance contracts +- workflow operational contracts + +## 2. Service Metadata + +The service should continue to advertise: + +- definition inspection support +- instance inspection support +- canonical schema inspection support +- canonical validation support + +The diagram provider value should change from old-runtime semantics to an engine-compatible diagram provider, but the public contract can remain unchanged. + +## 3. Workflow Diagram Strategy + +The current diagram service builds a simplified linear diagram from definition metadata and overlays instance/task status. + +The current simplified workflow diagram service is the baseline. V1 engine design keeps this approach. + +Why: + +- it is already product-compatible +- it does not depend on Elsa runtime internals +- it uses task and instance projections, which remain in place + +The engine should not block on building a richer graph renderer. + +## 4. Authorization And Assignment + +Authorization remains in the service layer, not the engine kernel. + +This should remain true in v1: + +- engine activates tasks +- projection store writes tasks +- service decides who may assign/release/complete them + +The engine should never embed user-specific authorization policy. + +## 5. Hosting Model + +### 5.1 Host Shape + +The service process should host: + +- API endpoints +- canonical definition cache +- runtime provider +- AQ signal consumer hosted service +- retention hosted service + +### 5.2 Background Services + +Recommended hosted services: + +- `WorkflowEngineSignalHostedService` +- `WorkflowEngineScheduleHostedService` + This may be unnecessary if delayed AQ messages are consumed by the same signal service. +- `WorkflowRetentionHostedService` + +### 5.3 Concurrency Configuration + +The host must expose configuration for: + +- signal consumer count +- max concurrent execution handlers +- dequeue wait duration +- per-execution timeout + +## 6. Configuration Model + +### 6.1 Runtime Configuration + +Recommended runtime options: + +```json +{ + "WorkflowRuntime": { + "Provider": "SerdicaEngine", + "FailStartupOnInvalidDefinition": true + } +} +``` + +In v1 this is a single-provider choice, not a mixed routing system. + +### 6.2 Engine Execution Configuration + +Recommended engine options: + +```json +{ + "WorkflowEngine": { + "NodeId": "workflow-node-1", + "MaxConcurrentExecutions": 16, + "ExecutionTimeoutSeconds": 300, + "DefinitionCacheMode": "Startup" + } +} +``` + +### 6.3 AQ Configuration + +Recommended AQ options: + +```json +{ + "WorkflowAq": { + "Schema": "SRD_WFKLW", + "SignalQueueName": "WF_SIGNAL_Q", + "ScheduleQueueName": "WF_SCHEDULE_Q", + "DeadLetterQueueName": "WF_DLQ_Q", + "DequeueWaitSeconds": 30, + "MaxDeliveryAttempts": 10, + "SignalConsumers": 4 + } +} +``` + +### 6.4 Retention Configuration + +Reuse the existing retention options and align engine snapshot retention with projection retention. + +## 7. Operational Diagnostics + +The engine must make the following available in logs and metrics: + +- workflow instance id +- workflow name +- workflow version +- business reference key +- signal id +- signal type +- waiting token +- state version +- node id +- execution duration +- dequeue latency +- retry count +- dead-letter count +- transport name and step id on failure + +## 8. Metrics + +Recommended metrics: + +- workflows started +- workflows completed +- workflows failed +- tasks activated +- task completions processed +- AQ signals dequeued +- AQ signal failures +- AQ DLQ count +- timer signals fired +- stale signals ignored +- execution conflict retries +- average execution slice duration +- active waiting instances by waiting kind + +## 9. Logging + +Logging should distinguish between: + +- product logs +- engine execution logs +- signal bus logs +- scheduler logs +- transport logs + +The engine should log structured fields, not only free text. + +Minimum structured fields: + +- `workflowInstanceId` +- `workflowName` +- `workflowVersion` +- `businessReferenceKey` +- `signalId` +- `signalType` +- `nodeId` +- `stateVersion` +- `waitingToken` + +## 10. Failure Handling Policy + +### 10.1 Recoverable Failures + +Examples: + +- transient transport failure +- transient AQ dequeue failure +- optimistic concurrency conflict + +Handling: + +- retry execution +- reschedule if policy exists +- keep workflow consistent + +### 10.2 Non-Recoverable Failures + +Examples: + +- invalid snapshot format +- missing definition for existing instance +- unresolvable signal payload + +Handling: + +- move signal to DLQ +- mark instance runtime state as failed or blocked +- expose failure through inspection + +## 11. Security Boundaries + +### 11.1 Service API Boundary + +User-facing authorization stays where it currently belongs: + +- endpoint layer +- task authorization service + +### 11.2 Engine Boundary + +The engine should trust only: + +- validated workflow definitions +- validated task completion requests from the service layer +- authenticated transport adapters + +### 11.3 AQ Boundary + +AQ queues should be scoped to the workflow schema and not shared casually with unrelated services. + +## 12. Testing Strategy + +### 12.1 Unit Tests + +Test: + +- canonical interpreter step behavior +- resume pointer serialization +- waiting token behavior +- optimistic concurrency conflict handling +- AQ envelope serialization + +### 12.2 Component Tests + +Test: + +- start flow to task activation +- task completion to next task +- timer registration to delayed resume +- subworkflow completion to parent resume +- transport failure to retry or failure branch + +### 12.3 Integration Tests + +Test with real Oracle and AQ: + +- signal enqueue/dequeue +- delayed message handling +- restart recovery +- multi-node duplicate delivery safety + +### 12.4 Oracle And AQ Reliability Tests + +The engine should have a dedicated Oracle-focused integration suite, not just generic workflow integration coverage. + +The Oracle suite should be split into four layers. + +#### 12.4.1 Oracle Transport Reality Tests + +These tests prove the raw AQ behavior that the engine depends on: + +- immediate enqueue followed by blocking dequeue +- delayed enqueue followed by eventual dequeue +- enqueue with transaction commit succeeds +- enqueue with transaction rollback disappears +- dequeue with `OnCommit` plus rollback causes redelivery +- dequeue with `OnCommit` plus commit removes message +- dead-letter enqueue and replay path +- browse path against dead-letter queue +- queue creation and teardown in ephemeral schemas or ephemeral queue names + +These tests should stay small and synthetic so transport failures are easy to isolate. + +#### 12.4.2 Engine Persistence And Delivery Coupling Tests + +These tests prove that Oracle state and Oracle AQ stay consistent together: + +- runtime state update plus AQ enqueue committed atomically +- runtime state update rolled back means no visible signal +- projection update plus AQ enqueue committed atomically +- duplicate AQ delivery with the same waiting token is harmless +- stale expected version plus valid waiting token is ignored safely +- stale timer message after reschedule becomes a no-op + +These are the most important correctness tests for the run-to-wait architecture. + +#### 12.4.3 Restart And Recovery Tests + +These tests should simulate realistic restart conditions: + +- app restart with immediate signal already in queue +- app restart with delayed signal not yet due +- app restart after delayed signal becomes due +- app restart after dequeue but before commit +- Oracle container restart while waiting instances exist +- Oracle restart while delayed messages are still pending +- service restart with dead-letter backlog present + +These tests should prove that no polling is needed to recover normal execution. + +#### 12.4.4 Oracle Load And Timing Tests + +These tests should focus on timing variance and backlog behavior: + +- cold-container delayed message latency envelope +- many delayed messages becoming due in the same second +- burst of immediate signals after service startup +- mixed immediate and delayed signals on one queue +- long-running dequeue loops with empty polls between real messages +- bounded backlog drain time for representative queue depth + +The goal is not only correctness, but knowing what timing variance is normal on local and CI Oracle containers. + +The detailed workload model, KPI set, harness structure, and test-tier split should live in [08-load-and-performance-plan.md](08-load-and-performance-plan.md). + +### 12.5 Bulstrad Product-Parity Tests + +Synthetic engine tests are necessary but not sufficient. + +The main parity suite should use real Bulstrad declarative workflows with scripted downstream transport responses. The purpose is to prove that the Serdica engine executes product workflows, not just toy workflows. + +Recommended first-wave Bulstrad coverage: + +- transport-heavy completion flows such as `AssistantPrintInsisDocuments` +- approval/review chains such as `ReviewPolicyOpenForChange` +- parent-child workflow chains such as `OpenForChangePolicy` +- cancellation flows such as `AnnexCancellation` +- policy end-state flows such as `AssistantPolicyCancellation` +- reinstate or reopen flows such as `AssistantPolicyReinstate` +- shared-policy integration flows such as `InsisIntegrationNew` +- shared-policy confirmation and conversion flows such as `QuotationConfirm` +- failure-tolerant cleanup flows such as `QuoteOrAplCancel` + +Each Bulstrad test should assert: + +- task sequence +- task payload shape +- transport invocation order +- final workflow state +- runtime version progression +- absence of leaked subworkflow frames or stale wait metadata + +Current Oracle-backed parity coverage already includes these families and uses restarted providers plus real Oracle workflow tables, not synthetic in-memory state. + +### 12.6 Chaos And Fault-Injection Tests + +The engine should also have a deterministic chaos suite. + +Recommended failure points: + +- before snapshot save +- after snapshot save but before projection save +- after projection save but before AQ enqueue +- after AQ enqueue but before commit +- after dequeue but before signal processing completes +- after signal processing but before lease commit + +Recommended assertions: + +- no duplicate open tasks +- no lost committed signal +- no unbounded retry loop +- no invalid version rollback +- no stuck instance without an explainable wait reason + +### 12.7 Parity Tests + +The most important tests compare outcomes against the current declarative workflow expectations: + +- same task sequence +- same state changes +- same business reference results +- same transport payload shaping + +## 13. Supportability + +Operations staff should be able to answer: + +- what is this instance waiting for +- when was it last executed +- what signal is due next +- why was a signal ignored +- why did a signal go to DLQ +- which step failed + +This is why runtime state inspection and structured failure metadata are mandatory. + diff --git a/docs/workflow/engine/06-implementation-structure.md b/docs/workflow/engine/06-implementation-structure.md new file mode 100644 index 000000000..accdbbd8d --- /dev/null +++ b/docs/workflow/engine/06-implementation-structure.md @@ -0,0 +1,285 @@ +# 06. Implementation Structure + +## 1. Implementation Goal + +The implementation should mirror the architecture instead of collapsing everything into `Services/`. + +The code layout should make it obvious which parts are: + +- product orchestration +- engine runtime +- persistence +- signaling +- scheduling +- operations + +## 2. Proposed Folder Layout + +Recommended new structure across the workflow host, shared abstractions, and external service contracts. + +### 2.1 Service Host Project + +Proposed folders: + +```text +Engine/ + Contracts/ + Execution/ + Persistence/ + Signaling/ + Scheduling/ + Hosting/ + Diagnostics/ +``` + +Detailed proposal: + +```text +Engine/ + Contracts/ + IWorkflowRuntimeProvider.cs + IWorkflowSignalBus.cs + IWorkflowScheduleBus.cs + IWorkflowRuntimeSnapshotStore.cs + IWorkflowRuntimeDefinitionStore.cs + + Execution/ + SerdicaEngineRuntimeProvider.cs + WorkflowExecutionCoordinator.cs + WorkflowCanonicalInterpreter.cs + WorkflowResumePointerSerializer.cs + WorkflowExecutionSliceResult.cs + WorkflowWaitDescriptor.cs + WorkflowSubWorkflowCoordinator.cs + WorkflowTransportDispatcher.cs + + Persistence/ + OracleWorkflowRuntimeSnapshotStore.cs + WorkflowRuntimeSnapshotMapper.cs + WorkflowRuntimeStateMutator.cs + + Signaling/ + OracleAqWorkflowSignalBus.cs + WorkflowSignalEnvelope.cs + WorkflowSignalPump.cs + WorkflowSignalHandler.cs + + Scheduling/ + OracleAqWorkflowScheduleBus.cs + WorkflowScheduleRequest.cs + + Hosting/ + WorkflowEngineSignalHostedService.cs + WorkflowEngineStartupValidator.cs + + Diagnostics/ + WorkflowEngineMetrics.cs + WorkflowEngineLogScope.cs +``` + +### 2.2 Shared Abstractions Project + +Keep these in abstractions: + +- execution contracts +- signal/schedule bus interfaces +- runtime provider interfaces +- runtime snapshot records where shared + +Do not put Oracle-specific details into the shared abstractions project. + +### 2.3 Contracts Project + +Keep only external service contracts there. + +Do not leak engine-internal snapshot or AQ message contracts into public workflow contracts. + +## 3. Recommended Core Interfaces + +### 3.1 Runtime Provider + +```csharp +public interface IWorkflowRuntimeProvider +{ + string ProviderName { get; } + + Task StartAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + StartWorkflowRequest request, + object startRequest, + CancellationToken cancellationToken = default); + + Task CompleteAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default); +} +``` + +### 3.2 Snapshot Store + +```csharp +public interface IWorkflowRuntimeSnapshotStore +{ + Task GetAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default); + + Task TryUpsertAsync( + WorkflowRuntimeSnapshot snapshot, + long expectedVersion, + CancellationToken cancellationToken = default); +} +``` + +### 3.3 Signal Bus + +```csharp +public interface IWorkflowSignalBus +{ + Task PublishAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default); + + Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default); +} +``` + +### 3.4 Schedule Bus + +```csharp +public interface IWorkflowScheduleBus +{ + Task ScheduleAsync( + WorkflowSignalEnvelope envelope, + DateTime dueAtUtc, + CancellationToken cancellationToken = default); +} +``` + +### 3.5 Definition Store + +```csharp +public interface IWorkflowRuntimeDefinitionStore +{ + WorkflowRuntimeDefinition GetRequiredDefinition( + string workflowName, + string workflowVersion); +} +``` + +## 4. Runtime Definition Normalization + +Recommended startup path: + +1. read registrations from `WorkflowRegistrationCatalog` +2. compile each workflow to canonical definition +3. validate canonical definition +4. convert to `WorkflowRuntimeDefinition` +5. store in immutable in-memory cache + +This startup step should be implemented once and reused by: + +- runtime execution +- canonical inspection endpoints +- diagnostics + +## 5. Snapshot Model + +Recommended runtime snapshot record: + +```csharp +public sealed record WorkflowRuntimeSnapshot +{ + public required string WorkflowInstanceId { get; init; } + public required string WorkflowName { get; init; } + public required string WorkflowVersion { get; init; } + public required string RuntimeProvider { get; init; } + public required long Version { get; init; } + public WorkflowBusinessReference? BusinessReference { get; init; } + public required string RuntimeStatus { get; init; } + public required WorkflowEngineState EngineState { get; init; } + public DateTime CreatedOnUtc { get; init; } + public DateTime? CompletedOnUtc { get; init; } + public DateTime LastUpdatedOnUtc { get; init; } +} +``` + +## 6. AQ Adapter Design + +AQ adapters should be isolated behind backend-neutral interfaces. + +Do not let the rest of the engine know about: + +- queue table names +- enqueue option types +- dequeue option types +- AQ-specific exception types + +That isolation is the main swap seam for any future non-AQ backend. + +## 7. Transaction Boundary Design + +### 7.1 Coordinator Owns Transactions + +`WorkflowExecutionCoordinator` should own the unit of work for: + +- snapshot update +- projection update +- AQ publish +- AQ dequeue completion + +This avoids split responsibility across product services and engine helpers. + +### 7.2 Projection Store Remains Focused + +`WorkflowProjectionStore` should stay focused on: + +- read projection writes +- query paths +- task event history + +It should not become the coordinator for AQ or engine versioning. + +## 8. Startup Composition + +`WorkflowServiceCollectionExtensions` should eventually compose the engine roughly like this: + +```csharp +services.Configure(...); +services.Configure(...); +services.Configure(...); + +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddSingleton(); +services.AddHostedService(); +``` + +## 9. Avoided Anti-Patterns + +The implementation should explicitly avoid: + +- a giant engine service that knows everything +- polling tables for due work +- in-memory only timer ownership +- transport-specific engine branches scattered across the codebase +- storing huge snapshots in AQ messages +- mixing public contracts with engine internal contracts + +## 10. Implementation Rules + +1. Put backend-specific code behind an interface. +2. Keep canonical interpretation pure and backend-agnostic. +3. Keep Oracle transaction handling close to the execution coordinator. +4. Make resume idempotency part of the snapshot model, not a side utility. +5. Keep projection writes product-oriented, not runtime-oriented. + diff --git a/docs/workflow/engine/07-sprint-plan.md b/docs/workflow/engine/07-sprint-plan.md new file mode 100644 index 000000000..bbd49221a --- /dev/null +++ b/docs/workflow/engine/07-sprint-plan.md @@ -0,0 +1,676 @@ +# 07. Sprint Plan + +## Planning Assumptions + +- sprint length: 2 weeks +- one team owning runtime, persistence, and service integration +- Oracle AQ available +- no concurrent-engine migration scope +- acceptance means code, tests, and updated docs + +## Sprint 1: Foundations And Contracts + +### Goal + +Create the engine skeleton and the stable interfaces. + +### Scope + +- add runtime provider abstraction +- add signal bus abstraction +- add schedule bus abstraction +- add runtime snapshot abstraction +- add engine option classes +- add `docs/engine/` package + +### Deliverables + +- interface set compiled into shared abstractions +- configuration classes +- initial DI composition path +- unit tests for options and registration + +### Exit Criteria + +- service builds with engine abstractions present +- no Elsa runtime assumptions are introduced into new code +- docs and interface names are stable enough for later sprints + +## Sprint 2: Canonical Runtime Definition Store + +### Goal + +Make canonical execution definitions available at runtime without Elsa. + +### Scope + +- compile authored workflows to canonical runtime definitions at startup +- validate definitions during startup +- cache runtime definitions +- expose startup failure mode for invalid definitions + +### Deliverables + +- `WorkflowRuntimeDefinitionStore` +- definition normalization pipeline +- startup validator +- tests covering: + - valid definition load + - invalid definition rejection + - version resolution + +### Exit Criteria + +- all registered workflows load into runtime definition cache +- the runtime can resolve definition by name/version + +## Sprint 3: Snapshot Store And Versioned Runtime State + +### Goal + +Turn `WF_RUNTIME_STATES` into a first-class engine snapshot store. + +### Scope + +- extend runtime state schema +- implement snapshot mapper +- implement optimistic concurrency versioning +- wire snapshot reads and writes + +### Deliverables + +- database migration scripts +- `OracleWorkflowRuntimeSnapshotStore` +- snapshot serialization contracts +- tests for: + - initial insert + - update with expected version + - stale version conflict + +### Exit Criteria + +- runtime snapshots can be loaded and committed with version control +- stale updates are rejected safely + +## Sprint 4: AQ Signal And Schedule Backbone + +### Goal + +Introduce Oracle AQ as the durable event backbone. + +### Scope + +- create AQ setup scripts +- implement signal bus +- implement schedule bus +- implement signal envelope serialization +- implement hosted signal consumer skeleton + +### Deliverables + +- AQ DDL scripts +- `OracleAqWorkflowSignalBus` +- `OracleAqWorkflowScheduleBus` +- integration tests with enqueue/dequeue +- delayed message smoke tests + +### Exit Criteria + +- engine can publish and receive immediate signals without polling +- engine can publish and receive delayed signals + +## Sprint 5: Start Flow And Human Task Activation + +### Goal + +Run workflows from start until first durable wait. + +### Scope + +- implement execution coordinator +- implement canonical interpreter subset: + - state assignment + - business reference assignment + - task activation + - terminal completion +- integrate with `WorkflowRuntimeService` +- keep existing projection model + +### Deliverables + +- `SerdicaEngineRuntimeProvider.StartAsync` +- execution slice result model +- task activation write path +- tests for: + - start to task + - start to completion + - business reference propagation + +### Exit Criteria + +- selected declarative workflows can start and create correct tasks without Elsa + +## Sprint 6: Task Completion And Transport Calls + +### Goal + +Advance workflows after task completion and support transport-backed orchestration. + +### Scope + +- implement task completion execution path +- implement canonical interpreter support for: + - transport calls + - branches + - success/failure paths +- integrate completion flow with runtime snapshot commit + +### Deliverables + +- `SerdicaEngineRuntimeProvider.CompleteAsync` +- transport dispatcher +- tests for: + - completion to next task + - failure branch + - timeout branch where applicable + +### Exit Criteria + +- representative workflows can complete first task and reach correct next state + +## Sprint 7: Subworkflows, Continue-With, And Repeat + +### Goal + +Support the higher-order orchestration patterns used heavily in the corpus. + +### Scope + +- implement subworkflow frame persistence +- implement parent resume +- implement continue-with production +- implement repeat resume semantics + +### Deliverables + +- subworkflow coordinator +- resume pointer serializer +- tests for: + - child completion resumes parent + - nested frame handling + - repeat interrupted by wait + - continue-with request emission + +### Exit Criteria + +- representative subworkflow-heavy families execute correctly + +## Sprint 8: Timers, Retries, And Delayed Resume + +### Goal + +Finish the non-polling scheduling path. + +### Scope + +- implement timer waits +- implement retry scheduling +- implement stale timer ignore logic via waiting tokens +- integrate delayed AQ delivery into execution coordinator + +### Deliverables + +- timer wait model +- delayed resume handler +- tests for: + - timer due resume + - retry due resume + - canceled timer ignored + - restart-safe delayed processing + +### Exit Criteria + +- the engine supports time-based orchestration without polling loops + +## Sprint 9: Operational Parity + +### Goal + +Reach product-surface and operations parity with the existing workflow service. + +### Scope + +- diagram parity validation +- runtime state inspection parity +- retention integration +- structured metrics and logging +- DLQ handling and diagnostics + +### Deliverables + +- runtime metadata mapping updates +- operational dashboards or documented metric set +- DLQ support +- tests for supportability paths + +### Exit Criteria + +- operations can inspect and support engine-driven instances through the existing product surface + +## Sprint 10: Corpus Parity And Hardening + +### Goal + +Prove the engine against the real declarative workflow corpus. + +### Scope + +- execute representative high-fanout families end-to-end +- resolve remaining interpreter gaps +- multi-node duplicate delivery testing +- restart and recovery testing +- performance and soak tests + +### Deliverables + +- parity report against selected workflow families +- load test results +- recovery test results +- production readiness checklist + +### Exit Criteria + +- selected production-grade workflows run without Elsa +- restart recovery is proven +- no polling is used for steady-state signal or timer discovery + +## Sprint 11: Bulstrad E2E Parity And Oracle Reliability + +### Goal + +Turn the engine from a validated runtime into a production-grade execution platform by proving it against real Bulstrad workflows and hostile Oracle operating conditions. + +### Scope + +- build a curated Bulstrad Oracle-AQ E2E suite +- replace synthetic runtime-state backing in Oracle integration tests with the real Oracle runtime-state store +- add Oracle transaction-coupling tests for state, projections, and AQ publish +- add Oracle restart, redelivery, and DLQ replay tests +- add multi-worker and duplicate-delivery race tests +- add deterministic fault-injection around commit boundaries + +### Deliverables + +- `BulstradOracleAqE2ETests` +- curated representative workflows with scripted downstream responders +- Oracle transport reliability suite covering: + - immediate and delayed delivery + - rollback and redelivery + - dead-letter browse and replay + - restart-safe delayed processing +- concurrency suite covering: + - duplicate signal delivery + - same-instance multi-worker races + - retry-after-conflict behavior +- documented timing expectations for cold-start and steady-state Oracle AQ + +### Implemented Coverage + +The current Oracle-backed integration harness now includes: + +- Bulstrad policy-change families: + - `OpenForChangePolicy` + - `ReviewPolicyOpenForChange` + - `AssistantAddAnnex` + - `AnnexCancellation` + - `AssistantPolicyReinstate` + - `AssistantPolicyCancellation` + - `AssistantPrintInsisDocuments` +- shared policy families: + - `InsisIntegrationNew` + - `QuotationConfirm` + - `QuoteOrAplCancel` +- Oracle transport and recovery matrix: + - immediate and delayed AQ delivery + - delayed backlog drain within a bounded latency envelope + - dequeue rollback redelivery + - ambient Oracle transaction commit and rollback for immediate messages + - ambient Oracle transaction commit and rollback for delayed messages + - dead-letter browse, replay, and backlog replay + - dead-letter backlog survival across Oracle restart + - timer backlog recovery across provider restart and Oracle restart + - external-signal backlog recovery, worker abandon/recovery, and duplicate-delivery races + - schedule/publish failure rollback inside workflow mutation transactions + +### Exit Criteria + +- representative Bulstrad workflows execute correctly on `SerdicaEngine` with real Oracle AQ +- AQ-backed restart and delayed-delivery behavior is proven under realistic timing variance +- duplicate delivery and commit-boundary failures are shown to be safe +- the team has a stable PR suite and a broader nightly suite for Oracle-backed engine validation + +## Sprint 12: Load, Performance, And Capacity Characterization + +### Goal + +Turn the correctness-focused Oracle validation suite into a real load and performance program with stable smoke gates, nightly trend runs, soak coverage, and first capacity numbers. + +### Scope + +- build a dedicated performance harness on top of the Oracle AQ integration foundation +- separate PR smoke, nightly characterization, weekly soak, and explicit capacity tiers +- add synthetic engine workloads for stable measurement +- add representative Bulstrad workload runners for business realism +- persist performance artifacts and summary reports +- define baseline and regression strategy per environment + +### Deliverables + +- categorized performance scenarios: + - `WorkflowPerfLatency` + - `WorkflowPerfThroughput` + - `WorkflowPerfSmoke` + - `WorkflowPerfNightly` + - `WorkflowPerfSoak` + - `WorkflowPerfCapacity` +- result artifact writer under `TestResults/workflow-performance/` +- scenario matrix covering: + - AQ immediate bursts + - AQ delayed bursts + - mixed signal backlogs + - synthetic start/task/signal/timer/subworkflow flows + - representative Bulstrad families + - restart and replay under load +- first baseline report for local Docker and CI Oracle +- first capacity note for one-node and multi-node assumptions + +### Exit Criteria + +- PR smoke load checks are cheap and stable enough to run continuously +- nightly runs capture latency, throughput, and correctness artifacts +- soak runs prove no backlog drift or correctness decay over extended execution +- representative Bulstrad workflows have measured latency envelopes, not just functional pass/fail +- the team has an initial sizing recommendation for worker concurrency and queue backlog expectations + +### Implemented Foundation + +The current Sprint 12 implementation now includes: + +- performance categories and artifact generation under `TestResults/workflow-performance/` +- Oracle AQ smoke scenarios for: + - immediate burst drain + - delayed burst drain + - synthetic external-signal backlog resume + - short Bulstrad business burst using `QuoteOrAplCancel` +- persisted comparison against the previous artifact for the same scenario and tier +- Oracle AQ nightly scenarios for: + - larger immediate burst drain + - larger delayed burst drain + - larger synthetic external-signal backlog resume + - Bulstrad `QuotationConfirm -> PdfGenerator` burst +- Oracle AQ soak scenario for: + - sustained synthetic signal round-trip waves without correctness drift +- Oracle AQ latency baseline for: + - one-at-a-time synthetic signal round-trip with phase-level latency summaries +- Oracle AQ throughput baseline for: + - parallel synthetic signal round-trip with `16` workload concurrency and `8` signal workers +- Oracle AQ capacity ladder for: + - synthetic signal round-trip at concurrency `1`, `4`, `8`, and `16` +- thread-safe scripted transport recording for concurrent smoke scenarios +- first full Oracle baseline run with documented metrics in: + - [10-oracle-performance-baseline-2026-03-17.md](10-oracle-performance-baseline-2026-03-17.md) + - [10-oracle-performance-baseline-2026-03-17.json](10-oracle-performance-baseline-2026-03-17.json) + +### Reference + +The detailed workload model, KPI set, harness design, and baseline strategy are defined in [08-load-and-performance-plan.md](08-load-and-performance-plan.md). + +## Sprint 13: Engine-Native Rendering And Authoring Projection + +### Goal + +Restore definition rendering and authoring projection without reintroducing Elsa types or runtime dependencies into the workflow declarations or the engine host. + +### Scope + +- design and implement a native definition-to-diagram projection for declarative and canonical workflows +- support deterministic node and edge generation from runtime definitions +- preserve task, branch, repeat, fork, timer, signal, and subworkflow visibility in the rendered output +- define a stable rendering contract for the operational API and future authoring tools +- keep rendering as a separate projection layer, not as part of runtime execution + +### Deliverables + +- native rendering model and renderer for `WorkflowRuntimeDefinition` +- canonical-to-diagram projection rules for: + - linear sequences + - decisions and conditional branches + - repeats + - forks and joins + - timers and external-signal waits + - continuations and subworkflows +- updated operational metadata and diagram endpoints backed only by engine assets +- test suite covering rendering determinism and parity for representative Bulstrad workflows + +### Exit Criteria + +- workflow definitions render without any Elsa packages, builders, or activity models +- rendered diagrams remain stable for the same declarative definition across rebuilds +- operational diagram inspection uses the native renderer only +- the rendering layer is ready to support a later authoring surface without changing workflow declarations + +## Sprint 14: Backend Portability And Store Profiles + +### Goal + +Turn the Oracle-first engine into a backend-switchable engine with one selected backend profile per deployment. + +### Scope + +- introduce backend profile abstraction and dedicated backend plugin registration +- split projection persistence from the current Oracle-first application service +- formalize mutation coordinator abstraction +- add backend-neutral dead-letter contract +- add backend conformance suite +- implement PostgreSQL profile +- design MongoDB profile in executable detail, with implementation only after explicit product approval + +### Deliverables + +- `IWorkflowBackendRegistrationMarker` +- backend-neutral projection contract +- backend-neutral mutation coordinator contract +- backend conformance suite +- dedicated Oracle, PostgreSQL, and MongoDB backend plugin projects +- executable MongoDB backend plugin design package + +### Exit Criteria + +- host selects one backend profile by configuration +- host stays backend-neutral and does not resolve Oracle/PostgreSQL directly +- Oracle and PostgreSQL pass the same conformance suite +- MongoDB path is specified well enough that implementation is a bounded engineering task +- workflow declarations and canonical definitions remain unchanged across backend profiles + +## Sprint 15: Backend-Neutral Parity And Performance Harness + +### Goal + +Remove the remaining Oracle-only assumptions from the validation stack so PostgreSQL and MongoDB can be measured with the same correctness, Bulstrad, and performance scenarios. + +### Scope + +- extract backend-neutral performance artifacts, categories, and scenario drivers +- extract backend-neutral runtime workload helpers from the Oracle-only harness +- define one hostile-condition matrix shared by Oracle, PostgreSQL, and MongoDB +- define one curated Bulstrad parity pack shared by all backends +- define one normalized performance artifact format and baseline comparison model + +### Deliverables + +- shared `IntegrationTests/Performance/Common/` package +- shared normalized performance metrics model +- shared Bulstrad workload catalog for: + - `OpenForChangePolicy` + - `ReviewPolicyOpenForChange` + - `AssistantPrintInsisDocuments` + - `AssistantAddAnnex` + - `AnnexCancellation` + - `AssistantPolicyCancellation` + - `AssistantPolicyReinstate` + - `InsisIntegrationNew` + - `QuotationConfirm` + - `QuoteOrAplCancel` +- backend-neutral hostile-condition checklist for: + - duplicate delivery + - same-instance resume race + - abandon and reclaim + - rollback on publish/schedule failure + - restart with pending due messages + - DLQ replay + - backlog drain + +### Exit Criteria + +- Oracle, PostgreSQL, and MongoDB use the same performance artifact shape +- Oracle no longer owns the reporting model for later backend baselines +- PostgreSQL and MongoDB can plug into the same workload definitions without changing workflow semantics + +## Sprint 16: PostgreSQL Hardening, Bulstrad Parity, And Baseline + +### Goal + +Bring PostgreSQL to Oracle-level confidence for correctness, hostile conditions, representative product behavior, and measured performance. + +### Scope + +- close the PostgreSQL hostile-condition gap to the Oracle matrix +- add PostgreSQL-backed Bulstrad E2E parity +- implement PostgreSQL latency, throughput, smoke, nightly, soak, and capacity suites +- publish PostgreSQL baseline artifacts and narrative summary + +### Deliverables + +- PostgreSQL hostile-condition integration suite +- PostgreSQL Bulstrad parity suite +- PostgreSQL performance suites for: + - latency + - throughput + - smoke + - nightly + - soak + - capacity +- baseline documents: + - `11-postgres-performance-baseline-.md` + - `11-postgres-performance-baseline-.json` + +### Exit Criteria + +- PostgreSQL passes the same hostile-condition matrix as Oracle +- representative Bulstrad workflows run correctly on PostgreSQL +- PostgreSQL has a durable, documented performance baseline comparable to Oracle + +## Sprint 17: MongoDB Hardening, Bulstrad Parity, And Baseline + +### Goal + +Bring MongoDB to the same product and operational confidence level as the relational backends without changing workflow behavior. + +### Scope + +- close the MongoDB hostile-condition gap to the Oracle matrix +- add MongoDB-backed Bulstrad E2E parity +- implement MongoDB latency, throughput, smoke, nightly, soak, and capacity suites +- publish MongoDB baseline artifacts and narrative summary + +### Deliverables + +- MongoDB hostile-condition integration suite +- MongoDB Bulstrad parity suite +- MongoDB performance suites for: + - latency + - throughput + - smoke + - nightly + - soak + - capacity +- baseline documents: + - `12-mongo-performance-baseline-.md` + - `12-mongo-performance-baseline-.json` + +### Exit Criteria + +- MongoDB passes the same hostile-condition matrix as Oracle +- representative Bulstrad workflows run correctly on MongoDB +- MongoDB has a durable, documented performance baseline comparable to Oracle and PostgreSQL + +## Sprint 18: Final Three-Backend Characterization And Decision Pack + +### Goal + +Produce the final side-by-side comparison for Oracle, PostgreSQL, and MongoDB using the same workloads, the same correctness rules, and the same performance artifact format. + +### Scope + +- rerun the shared Bulstrad parity pack on all three backends +- rerun the shared hostile-condition matrix on all three backends +- rerun the shared performance tiers and compare normalized metrics +- capture backend-specific metrics appendices without letting them replace normalized workflow metrics +- publish the final recommendation pack + +### Deliverables + +- final comparison documents: + - `13-backend-comparison-.md` + - `13-backend-comparison-.json` +- normalized comparison across: + - serial latency + - steady-state throughput + - capacity ladder + - backlog drain + - duplicate-delivery safety + - restart recovery +- backend-specific appendices for: + - Oracle wait and AQ observations + - PostgreSQL lock, WAL, and queue-table observations + - MongoDB transaction, lock, and change-stream observations + +### Exit Criteria + +- all three backends are compared through the same workload lens +- the team has one documented backend recommendation pack +- future backend decisions can reuse the same comparison harness instead of inventing new ad hoc measurements + +### Current Status + +- baseline comparison pack published in: + - [13-backend-comparison-2026-03-17.md](13-backend-comparison-2026-03-17.md) + - [13-backend-comparison-2026-03-17.json](13-backend-comparison-2026-03-17.json) +- normalized performance comparison is complete for Oracle, PostgreSQL, and MongoDB +- reliability and Bulstrad hardening depth remains Oracle-first, so the current comparison is a baseline decision pack, not the final production closeout +- the signal path is now split into durable store and wake driver seams +- PostgreSQL and MongoDB now persist transactional wake-outbox records behind that seam +- the optional Redis wake-driver plugin is implemented for PostgreSQL and MongoDB +- Oracle intentionally remains on native AQ and does not support the Redis wake-driver combination + +## Cross-Sprint Work Items + +These should be maintained continuously, not left to the end: + +- architecture doc updates +- test harness improvements +- canonical execution parity assertions +- operational telemetry quality +- snapshot schema versioning discipline +- Oracle timing-envelope observations for CI and local Docker environments + +## Final Milestone Definition + +The project is complete when: + +- the workflow service can run on the engine as the active runtime +- task and instance APIs remain stable +- Oracle AQ handles both immediate signaling and delayed scheduling +- the service resumes correctly after restart without polling +- the engine runs representative real workflows with production-grade observability + diff --git a/docs/workflow/engine/08-load-and-performance-plan.md b/docs/workflow/engine/08-load-and-performance-plan.md new file mode 100644 index 000000000..898b1727d --- /dev/null +++ b/docs/workflow/engine/08-load-and-performance-plan.md @@ -0,0 +1,544 @@ +# 08. Load And Performance Plan + +## Purpose + +This document defines how the Serdica workflow engine should be load-tested, performance-characterized, and capacity-sized once functional parity is in place. + +The goal is not only to prove that the engine is correct under load, but to answer these product and platform questions: + +- how many workflow starts, task completions, and signal resumes can one node sustain +- how quickly does backlog drain after restart or outage +- how much timing variance is normal for Oracle AQ on local Docker, CI, and shared environments +- which workloads are Oracle-bound, AQ-bound, or engine-bound +- which scenarios are safe to gate in PR and which belong in nightly or explicit soak runs + +## Principles + +The performance plan follows these rules: + +- correctness comes first; a fast but lossy engine result is a failed run +- performance tests must be split by intent: smoke, characterization, stress, soak, and failure-under-load +- transport-only tests and full workflow tests must both exist; they answer different questions +- synthetic workflows are required for stable measurement +- representative Bulstrad workflows are required for product confidence +- PR gates should use coarse, stable envelopes +- nightly and explicit runs should record and compare detailed metrics +- Oracle and AQ behavior must be measured directly, not inferred from app logs alone + +## What Must Be Measured + +### Correctness Under Load + +Every load run should capture: + +- total workflows started +- total tasks activated +- total tasks completed +- total signals published +- total signals processed +- total signals ignored as stale or duplicate +- total dead-lettered signals +- total runtime concurrency conflicts +- total failed runs +- total stuck instances at end of run + +Correctness invariants: + +- no lost committed signal +- no duplicate open task for the same logical wait +- no orphan subworkflow frame +- no runtime state row left without a valid explainable wait reason +- no queue backlog remaining after a successful drain phase unless the scenario intentionally leaves poison messages in DLQ + +### Latency + +The engine should measure at least: + +- start-to-first-task latency +- start-to-completion latency +- task-complete-to-next-task latency +- signal-publish-to-task-visible latency +- timer-due-to-resume latency +- delayed-message lateness relative to requested due time +- backlog-drain completion time +- restart-to-first-processed-signal time + +These should be recorded as: + +- average +- p50 +- p95 +- p99 +- max + +### Throughput + +The engine should measure: + +- workflows started per second +- task completions per second +- signals published per second +- signals processed per second +- backlog drain rate in signals per second +- completed end-to-end business workflows per minute + +### Saturation + +The engine should measure: + +- app process CPU +- app process private memory and working set +- Oracle container CPU and memory when running locally +- queue depth over time +- active waiting instances over time +- dead-letter depth over time +- runtime state update conflicts over time +- open task count over time + +### Oracle-Side Signals + +If the environment permits access, also collect: + +- AQ queue depth before, during, and after load +- queue-table growth during sustained runs +- visible dequeue lag +- Oracle session count for the test service +- lock or wait spikes on workflow tables +- transaction duration for mutation transactions + +If the environment does not permit these views, fall back to: + +- app-side timing +- browse counts from AQ +- workflow table row counts +- signal pump telemetry snapshots + +## Workload Model + +The load plan should be split into four workload families. + +### 1. Transport Microbenchmarks + +These isolate Oracle AQ behavior from workflow logic. + +Use them to answer: + +- how fast can AQ accept immediate messages +- how fast can AQ release delayed messages +- what is the drain rate for mixed backlogs +- how much delayed-message jitter is normal + +Core scenarios: + +- burst immediate enqueue and drain +- burst delayed enqueue with same due second +- mixed immediate and delayed enqueue on one queue +- dequeue rollback redelivery under sustained load +- dead-letter and replay backlog +- delayed backlog surviving Oracle restart + +### 2. Synthetic Engine Workloads + +These isolate the runtime from business-specific transport noise. + +Recommended synthetic workflow types: + +- start-to-complete with no task +- start-to-task with one human task +- signal-wait then task activation +- timer-wait then task activation +- continue-with dispatcher chain +- parent-child subworkflow chain + +Use them to answer: + +- raw start throughput +- raw resume throughput +- timer-due drain rate +- subworkflow coordination cost +- task activation/update cost + +### 3. Representative Bulstrad Workloads + +These prove that realistic product workflows behave well under load. + +The first performance wave should use workflows that are already functionally covered in the Oracle suite: + +- `AssistantPrintInsisDocuments` +- `OpenForChangePolicy` +- `ReviewPolicyOpenForChange` +- `AssistantAddAnnex` +- `AnnexCancellation` +- `AssistantPolicyCancellation` +- `AssistantPolicyReinstate` +- `InsisIntegrationNew` +- `QuotationConfirm` +- `QuoteOrAplCancel` + +Use them to answer: + +- how the engine behaves with realistic transport payload shaping +- how nested child workflows affect latency +- how multi-step review chains behave during backlog drain +- how short utility flows compare to long policy chains + +### 4. Failure-Under-Load Workloads + +These are not optional. A production engine must be tested while busy. + +Scenarios: + +- provider restart during active signal drain +- Oracle restart while delayed backlog exists +- dead-letter replay while new live signals continue to arrive +- duplicate signal storm against the same waiting instance set +- one worker repeatedly failing while another healthy worker continues +- scheduled backlog plus external-signal backlog mixed together + +Use them to answer: + +- whether recovery stays bounded +- whether backlog drain remains monotonic +- whether duplicate-delivery protections still hold under pressure +- whether DLQ replay can safely coexist with live traffic + +## Test Tiers + +Performance testing should not be a single bucket. + +### Tier 1: PR Smoke + +Purpose: + +- catch catastrophic regressions quickly + +Characteristics: + +- small datasets +- short run time +- deterministic scenarios +- hard pass/fail envelopes + +Recommended scope: + +- one AQ immediate burst +- one AQ delayed backlog burst +- one synthetic signal-resume scenario +- one short Bulstrad business flow + +Target duration: + +- under 5 minutes total + +Gating style: + +- zero correctness failures +- no DLQ unless explicitly expected +- coarse latency ceilings only + +### Tier 2: Nightly Characterization + +Purpose: + +- measure trends and detect meaningful performance regression + +Characteristics: + +- moderate dataset +- multiple concurrency levels +- metrics persisted as artifacts + +Recommended scope: + +- full Oracle transport matrix +- synthetic engine workloads at 1, 4, 8, and 16-way concurrency +- 3-5 representative Bulstrad families +- restart and DLQ replay under moderate backlog + +Target duration: + +- 15 to 45 minutes + +Gating style: + +- correctness failures fail the run +- latency/throughput compare against baseline with tolerance + +### Tier 3: Weekly Soak + +Purpose: + +- detect leaks, drift, and long-tail timing issues + +Characteristics: + +- long-running mixed workload +- periodic restarts or controlled faults +- queue depth and runtime-state stability tracking + +Recommended scope: + +- 30 to 120 minute mixed load +- immediate, delayed, and replay traffic mixed together +- repeated provider restarts +- one Oracle restart in the middle of the run + +Gating style: + +- no unbounded backlog growth +- no stuck instances +- no memory growth trend outside a defined envelope + +### Tier 4: Explicit Capacity And Breakpoint Runs + +Purpose: + +- learn real limits before production sizing decisions + +Characteristics: + +- not part of normal CI +- intentionally pushes throughput until latency or failure thresholds break + +Recommended scope: + +- ramp concurrency upward until queue lag or DB pressure exceeds target +- test one-node and multi-node configurations +- record saturation points, not just pass/fail + +Deliverable: + +- capacity report with recommended node counts and operational envelopes + +## Scenario Matrix + +The initial scenario matrix should look like this. + +### Oracle AQ Transport + +- immediate burst: 100, 500, 1000 messages +- delayed burst: 50, 100, 250 messages due in same second +- mixed burst: 70 percent immediate, 30 percent delayed +- redelivery burst: 25 messages rolled back once then committed +- DLQ burst: 25 poison messages then replay + +### Synthetic Engine + +- start-to-task: 50, 200, 500 workflow starts +- task-complete-to-next-task: 50, 200 completions +- signal-wait-resume: 50, 200, 500 waiting instances resumed concurrently +- timer-wait-resume: 50, 200 due timers +- subworkflow chain: 25, 100 parent-child chains + +### Bulstrad Business + +- short business flow: `QuoteOrAplCancel` +- medium transport flow: `InsisIntegrationNew` +- child-workflow flow: `QuotationConfirm` +- long review chain: `OpenForChangePolicy` +- print flow: `AssistantPrintInsisDocuments` +- cancellation flow: `AnnexCancellation` + +### Failure Under Load + +- 100 waiting instances, provider restart during drain +- 100 delayed messages, Oracle restart before due time +- 50 poison signals plus live replay traffic +- duplicate external signal storm against 50 waiting instances +- mixed task completions and signal resumes on same service instance set + +## Concurrency Steps + +Use explicit concurrency ladders instead of one arbitrary load value. + +Recommended first ladder: + +- 1 +- 4 +- 8 +- 16 +- 32 + +Use different ladders if the environment is too small, but always record: + +- node count +- worker concurrency +- queue backlog size +- workflow count +- message mix + +## Metrics Collection Design + +The harness should persist results for every performance run. + +Each result set should include: + +- scenario name +- git commit or working tree marker +- test timestamp +- environment label +- node count +- concurrency level +- workflow count +- signal count +- Oracle queue names used +- measured latency summary +- throughput summary +- correctness summary +- process resource summary +- optional Oracle observations + +Recommended output format: + +- JSON artifact for machines +- short markdown summary for humans + +Recommended location: + +- `TestResults/workflow-performance/` + +## Baseline Strategy + +Do not hard-code aggressive latency thresholds before collecting stable data. + +Use this sequence: + +1. characterization phase + Run each scenario several times on local Docker and CI Oracle. + +2. baseline phase + Record stable p50, p95, p99, throughput, and drain-rate envelopes. + +3. gating phase + Add coarse PR thresholds and tighter nightly regression detection. + +PR thresholds should be: + +- intentionally forgiving +- correctness-first +- designed to catch major regressions only + +Nightly thresholds should be: + +- baseline-relative +- environment-specific if necessary +- reviewed whenever Oracle container images or CI hardware changes + +## Harness Design + +The load harness should be separate from the normal fast integration suite. + +Recommended structure: + +- keep correctness-focused Oracle AQ tests in the current integration project +- add categorized performance tests with explicit categories such as: + - `WorkflowPerfLatency` + - `WorkflowPerfThroughput` + - `WorkflowPerfSmoke` + - `WorkflowPerfNightly` + - `WorkflowPerfSoak` + - `WorkflowPerfCapacity` +- keep scenario builders reusable so the same workflow/transports can be used in correctness and performance runs + +The harness should include: + +- scenario driver +- result collector +- metric aggregator +- optional Oracle observation collector +- artifact writer +- explicit phase-latency capture for start, signal publish, and signal-to-completion on the synthetic signal round-trip workload + +## Multi-Backend Expansion Rules + +Once Oracle is the validated reference baseline, PostgreSQL and MongoDB must adopt the same load and performance structure instead of inventing backend-specific suites first. + +Required rules: + +- keep one shared scenario catalog for Oracle, PostgreSQL, and MongoDB +- compare backends first on normalized workflow metrics, not backend-native counters +- keep backend-native metrics as appendices, not as the headline result +- use the same tier names and artifact schema across all backends +- keep the same curated Bulstrad workload pack across all backends unless a workflow is backend-blocked by a real functional defect + +The shared artifact set should ultimately include: + +- `10-oracle-performance-baseline-.md/.json` +- `11-postgres-performance-baseline-.md/.json` +- `12-mongo-performance-baseline-.md/.json` +- `13-backend-comparison-.md/.json` + +The shared normalized metrics are: + +- serial end-to-end latency +- start-to-first-task latency +- signal-publish-to-visible-resume latency +- steady-state throughput +- capacity ladder at `c1`, `c4`, `c8`, and `c16` +- backlog drain time +- failures +- dead letters +- runtime conflicts +- stuck instances + +Backend-native appendices should include: + +- Oracle: + - AQ browse depth + - `V$SYSSTAT` deltas + - `V$SYS_TIME_MODEL` deltas + - top wait deltas +- PostgreSQL: + - queue-table depth + - `pg_stat_database` + - `pg_stat_statements` + - lock and wait observations + - WAL pressure observations +- MongoDB: + - signal collection depth + - `serverStatus` counters + - transaction counters + - change-stream wake observations + - lock percentage observations + +## Oracle-Specific Observation Plan + +For Oracle-backed runs, observe both the engine and the database. + +At minimum, record: + +- AQ browse depth before, during, and after the run +- count of runtime-state rows touched +- count of task and task-event rows created +- number of dead-lettered signals +- duplicate/stale resume ignore count + +If the environment allows deeper Oracle access, also record: + +- session count for the service user +- top wait classes during the run +- lock waits on workflow tables +- statement time for key mutation queries + +## Exit Criteria + +The load/performance work is complete when: + +- PR smoke scenarios are stable and cheap enough to run continuously +- nightly characterization produces persisted metrics and useful regression signal +- at least one weekly soak run is stable without correctness drift +- representative Bulstrad families have measured latency and throughput envelopes +- Oracle restart, provider restart, DLQ replay, and duplicate-delivery scenarios are all characterized under load +- the team can state a first production sizing recommendation for one node and multi-node deployment + +## Next Sprint Shape + +This plan maps naturally to a dedicated sprint focused on: + +- performance harness infrastructure +- synthetic scenario library +- representative Bulstrad workload runner +- metrics artifact generation +- baseline capture +- first capacity report + diff --git a/docs/workflow/engine/09-backend-portability-plan.md b/docs/workflow/engine/09-backend-portability-plan.md new file mode 100644 index 000000000..4c83e9d8d --- /dev/null +++ b/docs/workflow/engine/09-backend-portability-plan.md @@ -0,0 +1,806 @@ +# 09. Backend Portability Plan + +## Purpose + +This document defines how `SerdicaEngine` should evolve from an Oracle-first runtime into a backend-switchable engine that can also run on PostgreSQL and MongoDB without changing workflow declarations, canonical definitions, or runtime semantics. + +The goal is not to support every backend in the same way internally. + +The goal is to preserve one stable engine contract: + +- the same declarative workflow classes +- the same canonical runtime definitions +- the same public workflow/task APIs +- the same runtime behavior around tasks, waits, timers, external signals, subworkflows, retries, and retention + +Backend switching must only change infrastructure adapters and host configuration. + +## Current Baseline + +Today the strongest backend shape is Oracle: + +- runtime state persists in an Oracle-backed runtime-state adapter +- projections persist in an Oracle-backed projection adapter +- immediate signaling and delayed scheduling run through Oracle AQ adapters +- the engine host composes those adapters through backend registration + +Oracle is the reference implementation because it already gives: + +- one durable database +- durable queueing +- delayed delivery +- blocking dequeue without polling +- transactional coupling between state mutation and queue enqueue + +That reference point matters because PostgreSQL and MongoDB must match the engine contract even if they reach it through different infrastructure mechanisms. + +## Non-Negotiable Product Rules + +Backend portability must not break these rules: + +1. Authored workflow classes remain pure declaration classes. +2. Canonical runtime definitions remain backend-agnostic. +3. Engine execution remains run-to-wait. +4. Multi-instance deployment remains supported. +5. Steady-state signal and timer discovery must not rely on polling loops. +6. Signal delivery remains at-least-once. +7. Resume remains idempotent through version and waiting-token checks. +8. Public API contracts and projections remain stable. +9. Operational features remain available: + - signal raise + - dead-letter inspection + - dead-letter replay + - runtime inspection + - retention + - diagram inspection + +## Architecture Principle + +Do not make the engine "database-agnostic" by hiding everything behind one giant repository. + +That approach will collapse important guarantees. + +Instead, separate the backend into explicit capabilities: + +1. runtime state persistence +2. projection persistence +3. signal transport +4. schedule transport +5. mutation transaction boundary +6. wake-up notification strategy +7. lease or concurrency strategy +8. dead-letter and replay strategy +9. retention and purge strategy + +Each backend implementation must satisfy the full capability matrix. + +## Implemented Signal Driver Split + +The engine now separates durable signal ownership from wake-up delivery. + +The shared seam is defined by engine signal-driver abstractions plus signal and schedule bridge contracts. + +That split exists to preserve transactional correctness while still allowing faster wake strategies later. + +The separation is: + +- `IWorkflowSignalStore`: durable immediate signal persistence +- `IWorkflowSignalDriver`: wake-up and claim path for available signals +- `IWorkflowSignalScheduler`: durable delayed-signal persistence +- `IWorkflowWakeOutbox`: deferred wake publication when the driver is not transaction-coupled to the durable store + +The public engine surface still uses: + +- `IWorkflowSignalBus` +- `IWorkflowScheduleBus` + +Those are now bridge contracts. + +They do not define backend mechanics directly. + +### Current Backend Matrix + +| Backend profile | Durable signal store | Wake driver | Schedule store | Dispatch mode | +|-----------|--------|------------|---------|-------------| +| Oracle | Oracle AQ signal adapter | Oracle AQ blocking dequeue | Oracle AQ schedule adapter | `NativeTransactional` | +| PostgreSQL | PostgreSQL durable signal store | PostgreSQL native wake or claim adapter | PostgreSQL durable schedule store | `NativeTransactional` | +| MongoDB | MongoDB durable signal store | MongoDB change-stream wake or claim adapter | MongoDB durable schedule store | `NativeTransactional` | + +### Implemented Optional Redis Wake Driver + +The Redis driver is implemented as a separate wake-driver plugin. + +Its shape is intentionally narrow: + +- Oracle, PostgreSQL, and MongoDB remain the durable signal stores. +- Oracle, PostgreSQL, and MongoDB persist durable signals transactionally. +- Redis receives wake hints directly after commit through the mutation scope post-commit hook. +- workers wake through Redis and then claim from the durable backend store. + +Oracle is now supported in this combination, but it is not the preferred Oracle profile. +Oracle native AQ wake remains the default because it is slightly faster in the current measurements and keeps the cleanest native timer and dequeue path. + +Redis on Oracle exists for topology consistency, not because Oracle needs Redis for correctness or because it is the current fastest Oracle path. + +### Redis Driver Rules + +Redis must remain a wake driver plugin, not the authoritative durable signal queue for mixed backends. + +The intended shape is: + +- Oracle or PostgreSQL or MongoDB remains the durable `IWorkflowSignalStore` +- Redis becomes an `IWorkflowSignalDriver` +- Redis is published directly after the durable store transaction commits +- backend-native wake drivers are not active when Redis is selected + +That preserves the required correctness model: + +1. persist runtime state, projections, and durable signal inside the backend mutation boundary +2. commit the mutation boundary +3. publish the Redis wake hint from the registered post-commit action +4. wake workers and claim from the durable backend store + +`IWorkflowWakeOutbox` remains in the abstraction set for future non-Redis wake drivers that may still need deferred publication, but it is not the active Redis hot path. + +Redis may improve signal-to-resume latency, especially for PostgreSQL and MongoDB where the durable store and the wake path are already split cleanly. + +Redis must not become the correctness layer unless the whole durable signal model also moves there, which is not the design target of this engine. + +## Required Capability Matrix + +Every engine backend profile must define concrete answers for the following: + +| Capability | Oracle | PostgreSQL | MongoDB | +|-----------|--------|------------|---------| +| Runtime state durability | Native | Required | Required | +| Projection durability | Native | Required | Required | +| Optimistic concurrency | Row/version | Row/version | Document version | +| Immediate signal durability | AQ | Queue table or queue extension | Signal collection | +| Delayed scheduling | AQ delayed delivery | Durable due-message table | Durable due-message collection | +| Blocking wake-up | AQ dequeue | `LISTEN/NOTIFY`, Redis wake driver, or dedicated queue worker | Change streams or Redis wake driver | +| Atomic state + signal publish | Native DB transaction | Outbox transaction | Transactional outbox or equivalent | +| Dead-letter support | AQ + table | Queue/DLQ table | DLQ collection | +| Multi-node safety | DB + AQ | DB + wake hints | DB + change stream / wake hints | +| Restart recovery | Native | Required | Required | + +The backend is not complete until every row has a real implementation. + +## Engine Backend Layers + +The switchable backend model should be built around these interfaces. + +### 1. Runtime State Store + +Responsible for: + +- loading runtime snapshot by workflow instance id +- inserting new snapshot +- updating snapshot with expected version +- querying runtime status for operational needs +- storing engine-specific snapshot JSON + +Target interface shape: + +```csharp +public interface IWorkflowRuntimeStateStore +{ + Task GetAsync(string workflowInstanceId, CancellationToken ct = default); + Task InsertAsync(WorkflowRuntimeStateRecord record, CancellationToken ct = default); + Task UpdateAsync( + WorkflowRuntimeStateRecord record, + long expectedVersion, + CancellationToken ct = default); +} +``` + +Notes: + +- Oracle and PostgreSQL should use explicit version columns. +- MongoDB should use a document version field and conditional update filter. +- This store must not also own signal publishing logic. + +### 2. Projection Store + +Responsible for: + +- workflow instance summaries +- task summaries +- task event history +- business reference lookup +- support read APIs + +The projection model is product-facing and must remain stable. + +That means: + +- the shape of projection records must not depend on the backend +- only the persistence adapter may change + +Target direction: + +- split the current projection application service into a backend-neutral application service plus backend adapters +- keep one projection contract +- allow Oracle and PostgreSQL to stay relational +- allow MongoDB to project into document collections if needed + +### 3. Signal Bus + +Responsible for durable immediate signals: + +- internal continue +- external signal +- task completion continuation +- subworkflow completion +- replay from dead-letter + +The current contract already exists in the engine runtime abstractions. + +Required guarantees: + +- at-least-once delivery +- ack only after successful processing +- delivery count visibility +- explicit abandon +- explicit dead-letter move +- replay support + +### 4. Schedule Bus + +Responsible for durable delayed delivery: + +- timer due +- retry due +- delayed continuation + +Required guarantees: + +- message is not lost across process restart +- message becomes visible at or after due time +- stale due messages are safely ignored through waiting tokens +- schedule and immediate signal semantics use the same envelope model + +### 5. Mutation Transaction Boundary + +This is the most important portability seam. + +The engine mutates three things together: + +- runtime state +- projections +- signals or schedules + +Oracle can do that in one database transaction because state, projections, and AQ live inside the same durable boundary. + +PostgreSQL and MongoDB may require an outbox-based boundary instead. + +This must be explicit: + +```csharp +public interface IWorkflowMutationCoordinator +{ + Task ExecuteAsync( + Func action, + CancellationToken ct = default); +} +``` + +Where the mutation context exposes: + +- runtime state adapter +- projection adapter +- signal outbox writer +- schedule outbox writer + +Do not let the runtime service hand-roll transaction logic per backend. + +### 6. Wake-Up Notifier + +The engine must not scan due rows in a steady loop. + +That means every backend needs a wake-up channel: + +- Oracle: AQ blocking dequeue +- PostgreSQL: `LISTEN/NOTIFY` as wake hint for durable queue tables +- MongoDB: change streams as wake hint for durable signal collections + +The wake-up channel is not the durable source of truth except in Oracle AQ. + +It is only the wake mechanism. + +That distinction is mandatory for PostgreSQL and MongoDB. + +## Backend Profiles + +## Oracle Profile + +### Role + +Oracle remains the reference backend profile and the operational default. + +### Storage Model + +- runtime state table +- relational projection tables +- AQ signal queue +- AQ schedule queue or delayed signal queue +- DLQ table and AQ-assisted replay + +### Commit Model + +- one transaction for runtime state, projections, and AQ enqueue + +### Wake Model + +- AQ blocking dequeue + +### Advantages + +- strongest correctness story +- simplest atomic mutation model +- no extra wake layer required + +### Risks + +- Oracle-specific infrastructure coupling +- AQ operational expertise required +- portability work must not assume AQ-only features in engine logic + +Oracle should be treated as the semantic gold standard that other backends must match. + +## PostgreSQL Profile + +### Goal + +Provide a backend profile that preserves engine semantics using PostgreSQL as the durable system of record. + +### Recommended Shape + +- runtime state in PostgreSQL tables +- projections in PostgreSQL tables +- durable signal queue table +- durable schedule queue table +- DLQ table +- `LISTEN/NOTIFY` for wake-up hints only + +### Why Not `LISTEN/NOTIFY` Alone + +`LISTEN/NOTIFY` is not sufficient as the durable signal layer because notifications are ephemeral. + +The durable truth must stay in tables. + +The recommended model is: + +1. insert durable signal row in the same transaction as state/projection mutation +2. emit `NOTIFY` before commit or immediately after durable insert +3. workers wake up and claim rows from the signal queue table +4. if notification is missed, the next notification or startup recovery still finds the rows + +### Queue Claim Strategy + +Recommended queue-claim pattern: + +- `FOR UPDATE SKIP LOCKED` +- ordered by available time, priority, and creation time +- delivery count increment on claim +- explicit ack by state transition or delete +- explicit dead-letter move after delivery limit + +### Schedule Strategy + +Recommended schedule table: + +- `signal_id` +- `available_at_utc` +- `workflow_instance_id` +- `runtime_provider` +- `signal_type` +- serialized payload +- delivery count +- dead-letter metadata + +Recommended wake-up path: + +- durable insert into schedule table +- `NOTIFY workflow_signal` +- workers wake and attempt claim of rows with `available_at_utc <= now()` + +This is still not "polling" if workers block on `LISTEN` and only do bounded claim attempts on wake-up, startup, and recovery events. + +### Atomicity Model + +PostgreSQL cannot rely on an external broker if we want the same atomicity guarantees. + +The cleanest profile is: + +- database state +- database projections +- database signal queue +- database schedule queue +- `NOTIFY` as non-durable wake hint + +That keeps the entire correctness boundary in PostgreSQL. + +### Operational Notes + +Need explicit handling for: + +- orphan claimed rows after node crash +- reclaim timeout +- dead-letter browsing and replay +- table bloat and retention +- index strategy for due rows + +### Suggested Components + +- `PostgresWorkflowRuntimeStateStore` +- `PostgresWorkflowProjectionStore` +- `PostgresWorkflowSignalQueue` +- `PostgresWorkflowScheduleQueue` +- `PostgresWorkflowWakeListener` +- `PostgresWorkflowMutationCoordinator` + +## MongoDB Profile + +### Goal + +Provide a backend profile that preserves engine semantics using MongoDB as the durable system of record. + +### Recommended Shape + +- runtime state in a `workflow_runtime_states` collection +- projections in dedicated collections +- durable `workflow_signals` collection +- durable `workflow_schedules` collection +- dead-letter collection +- change streams for wake-up hints + +### Why Change Streams Are Not Enough + +Change streams are a wake mechanism, not the durable queue itself. + +The durable truth must remain in collections so the engine can recover after: + +- service restart +- watcher restart +- temporary connectivity loss + +### Document Model + +Signal document fields should include: + +- `_id` +- `workflowInstanceId` +- `runtimeProvider` +- `signalType` +- `waitingToken` +- `expectedVersion` +- `dueAtUtc` +- `status` +- `deliveryCount` +- `claimedBy` +- `claimedAtUtc` +- `deadLetterReason` +- `payload` + +### Claim Strategy + +Recommended model: + +- atomically claim one available document with `findOneAndUpdate` +- filter by: + - `status = Ready` + - `dueAtUtc <= now` + - not already claimed +- set: + - `status = Claimed` + - `claimedBy` + - `claimedAtUtc` + - increment `deliveryCount` + +Ack means: + +- delete the signal or mark it completed + +Abandon means: + +- move back to `Ready` + +Dead-letter means: + +- move to DLQ collection or set `status = DeadLetter` + +### Schedule Strategy + +Two reasonable models exist. + +#### Model A: Separate Schedule Collection + +- keep delayed signals in `workflow_schedules` +- promote due documents into `workflow_signals` +- wake workers through change streams + +This is simpler conceptually but adds one extra movement step. + +#### Model B: Unified Signal Collection + +- store all signals in one collection +- use `dueAtUtc` and `status` +- workers claim only due documents + +This is the better v1 choice because it keeps one signal envelope pipeline. + +### Atomicity Model + +MongoDB can support multi-document transactions in replica-set mode. + +That means the preferred model is: + +- runtime state +- projections +- signal collection writes +- schedule writes + +all inside one MongoDB transaction. + +If that operational assumption is unacceptable, then MongoDB is not a correctness-grade replacement for the Oracle profile and should not be offered as a production engine backend. + +### Wake Model + +Use change streams to avoid steady-state polling: + +- watch inserts and state transitions for ready or due signals +- on startup, run bounded recovery sweep for unclaimed ready signals +- on worker restart, resume from durable signal documents, not from missed change stream events + +### Operational Notes + +Need explicit handling for: + +- resume token persistence for observers +- claimed-document recovery after node failure +- shard-key implications if sharding is introduced later +- transactional prerequisites in local and CI test environments + +### Suggested Components + +- `MongoWorkflowRuntimeStateStore` +- `MongoWorkflowProjectionStore` +- `MongoWorkflowSignalStore` +- `MongoWorkflowWakeStreamListener` +- `MongoWorkflowMutationCoordinator` + +## Backend Selection Model + +The engine should not expose dozens of independent switches in appsettings. + +Use one backend profile section plus internal composition. + +Recommended shape: + +```json +{ + "WorkflowEngine": { + "BackendProfile": "Oracle" + } +} +``` + +And then backend-specific option sections: + +```json +{ + "WorkflowBackend:Oracle": { + "ConnectionString": "...", + "QueueOwner": "SRD_WFKLW", + "SignalQueueName": "WF_SIGNAL_Q", + "DeadLetterQueueName": "WF_SIGNAL_DLQ" + }, + "WorkflowBackend:PostgreSql": { + "ConnectionString": "...", + "SignalTable": "workflow_signals", + "ScheduleTable": "workflow_schedules", + "DeadLetterTable": "workflow_signal_dead_letters", + "NotificationChannel": "workflow_signal" + }, + "WorkflowBackend:MongoDb": { + "ConnectionString": "...", + "DatabaseName": "serdica_workflow", + "SignalCollection": "workflow_signals", + "RuntimeStateCollection": "workflow_runtime_states", + "ProjectionPrefix": "workflow" + } +} +``` + +The DI layer should map `BackendProfile` to one complete backend package, not a mix-and-match set of partial adapters. + +That avoids unsupported combinations like: + +- Oracle state + Mongo signals +- PostgreSQL state + Redis schedule + +unless they are designed explicitly as a later profile. + +## Implementation Refactor Needed + +To make the backend switch clean, the current Oracle-first host should be refactored in this order. + +### Phase 1: Split Projection Persistence + +Refactor the current projection application service into: + +- projection application service +- backend-neutral projection contract +- Oracle implementation + +Then add backend implementations later without changing the application service. + +### Phase 2: Introduce Dedicated Backend Plugin Registration + +Add: + +```csharp +public interface IWorkflowBackendRegistrationMarker +{ + string BackendName { get; } +} +``` + +Then create dedicated backend plugins for: + +- Oracle +- PostgreSQL +- MongoDB + +The host should remain backend-neutral and validate that the selected backend plugin has registered itself. +Each backend plugin should own registration of: + +- runtime state store +- projection store +- mutation coordinator +- signal bus +- schedule bus +- dead-letter store +- backend-specific options and wake-up strategy + +### Phase 3: Move Transaction Logic Into Backend Coordinator + +Refactor the current workflow mutation transaction scope so the runtime service no longer knows whether the backend uses: + +- direct database transaction +- database transaction plus outbox +- document transaction + +The runtime service should only ask for one mutation boundary. + +### Phase 4: Normalize Dead-Letter Model + +Standardize a backend-neutral dead-letter record so the operational endpoints do not care which backend stores it. + +That includes: + +- signal id +- workflow instance id +- signal type +- first failure time +- last failure time +- delivery count +- last error +- payload snapshot + +### Phase 5: Introduce Backend Conformance Tests + +Every backend must pass the same contract suite: + +- state insert/update/version conflict +- task activation and completion +- timer due resume +- external signal resume +- subworkflow completion resume +- duplicate delivery safety +- restart recovery +- dead-letter move and replay +- retention and purge + +Oracle should remain the first backend to pass the full suite. + +PostgreSQL and MongoDB are not ready until they pass the same suite. + +## Backend-Specific Risks + +## PostgreSQL Risks + +- row-level queue claim logic can create hot indexes under high throughput +- `LISTEN/NOTIFY` payloads are not durable +- reclaim and retry logic must be designed carefully to avoid stuck claimed rows +- due-row access patterns must be tuned with indexes and partitioning if volume grows + +## MongoDB Risks + +- production-grade correctness depends on replica-set transactions +- change streams add operational requirements and resume-token handling +- projection queries may become more complex if the read model is heavily relational today +- collection growth and retention strategy must be explicit early + +## Oracle Risks + +- Oracle remains the strongest correctness model but the least portable implementation +- engine logic must not drift toward AQ-only assumptions that other backends cannot model + +## Recommended Rollout Order + +Do not build PostgreSQL and MongoDB in parallel first. + +Use this order: + +1. stabilize Oracle as the contract baseline +2. refactor the host into a true backend-plugin model +3. implement PostgreSQL profile +4. pass the full backend conformance suite on PostgreSQL +5. implement MongoDB profile only if there is a real product need for MongoDB as the system of record + +PostgreSQL should come before MongoDB because: + +- its runtime-state and projection model are closer to the current Oracle design +- its transaction semantics fit the engine more naturally +- the read-side model is already relational + +## Validation Order After Functional Backend Completion + +Functional backend completion is not the same as backend readiness. + +After a backend can start, resume, signal, schedule, and retain workflows, the next required order is: + +1. backend-neutral hostile-condition coverage +2. curated Bulstrad parity coverage +3. backend-neutral performance tiers +4. backend-specific baseline publication +5. final three-backend comparison + +This means: + +- PostgreSQL is not done when its basic stores and buses compile; it must also match the Oracle hostile-condition and Bulstrad suites +- MongoDB is not done when replica-set transactions and signal delivery work; it must also match the same parity and performance suites +- the final adoption decision should be based on the shared comparison pack, not on isolated backend microbenchmarks + +## Proposed Sprint + +## Sprint 14: Backend Portability And Store Profiles + +### Goal + +Turn the Oracle-first engine into a backend-switchable engine with one selected backend profile per deployment. + +### Scope + +- introduce backend profile abstraction and dedicated backend plugin registration +- split projection persistence from the current Oracle-first application service +- formalize mutation coordinator abstraction +- add backend-neutral dead-letter contract +- define and implement backend conformance suite +- implement PostgreSQL profile +- design MongoDB profile in executable detail, with implementation only after explicit product approval + +### Deliverables + +- `IWorkflowBackendRegistrationMarker` +- backend-neutral projection contract +- backend-neutral mutation coordinator contract +- backend conformance test suite +- dedicated Oracle, PostgreSQL, and MongoDB backend plugin projects +- architecture-ready MongoDB backend plugin design package + +### Exit Criteria + +- host selects one backend profile by configuration +- host stays backend-neutral and does not resolve Oracle/PostgreSQL directly +- Oracle and PostgreSQL pass the same conformance suite +- MongoDB path is specified well enough that implementation is a bounded engineering task +- workflow declarations and canonical definitions remain unchanged across backend profiles + +## Final Rule + +Backend switching is an infrastructure concern, not a workflow concern. + +If a future backend requires changing workflow declarations, canonical definitions, or engine semantics, that backend does not fit the architecture and should not be adopted without a new ADR. + diff --git a/docs/workflow/engine/10-oracle-performance-baseline-2026-03-17.json b/docs/workflow/engine/10-oracle-performance-baseline-2026-03-17.json new file mode 100644 index 000000000..46c131e88 --- /dev/null +++ b/docs/workflow/engine/10-oracle-performance-baseline-2026-03-17.json @@ -0,0 +1,1855 @@ +{ + "Date": "2026-03-17", + "Workspace": "C:\\dev\\serdica-backend4", + "TestCommand": "dotnet test src/Serdica/Ablera.Serdica.Workflow/__Tests/Ablera.Serdica.Workflow.IntegrationTests/Ablera.Serdica.Workflow.IntegrationTests.csproj -c Release --no-build --filter \"FullyQualifiedName~OracleAqPerformance\"", + "SuiteResult": { + "Passed": 12, + "Total": 12, + "Duration": "2 m 40 s" + }, + "RawArtifactDirectory": "src/Serdica/Ablera.Serdica.Workflow/__Tests/Ablera.Serdica.Workflow.IntegrationTests/bin/Release/net9.0/TestResults/workflow-performance/", + "OracleEnvironment": { + "DockerImage": "gvenzl/oracle-free:23-slim", + "Instance": "FREE", + "Version": "23.0.0.0.0", + "AqBackend": "Oracle AQ with pooled connections and retry-hardened setup" + }, + "MeasurementViews": { + "SerialLatencyScenario": "oracle-aq-signal-roundtrip-latency-serial", + "SteadyThroughputScenario": "oracle-aq-signal-roundtrip-throughput-parallel", + "CapacityScenarioPrefix": "oracle-aq-signal-roundtrip-capacity-" + }, + "Notes": { + "TopWaitCounts": [ + { + "Name": "log file sync", + "Count": 14 + }, + { + "Name": "library cache lock", + "Count": 1 + } + ], + "Interpretation": [ + "Serial latency baseline and steady throughput baseline are now separated from the capacity ladder.", + "Commit pressure remains the dominant Oracle-side cost center.", + "Capacity c1 improved materially after the harness was changed to use parallel drain and parallel verification.", + "c8 is the last comfortable rung on this local Oracle Free setup; c16 is the first pressure rung." + ] + }, + "Scenarios": [ + { + "ScenarioName": "oracle-aq-signal-roundtrip-capacity-c1", + "Tier": "WorkflowPerfCapacity", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:54:12.6576147Z", + "CompletedAtUtc": "2026-03-17T06:54:17.4098827Z", + "OperationCount": 16, + "Concurrency": 1, + "Counters": { + "WorkflowsStarted": 16, + "TasksActivated": 0, + "TasksCompleted": 0, + "SignalsPublished": 16, + "SignalsProcessed": 16, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 167501824, + "PrivateMemoryBytes": 66420736, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "workflowName": "OracleAqPerfSignalRoundTripWorkflow", + "queueName": "WF_SIG_CC2D6C94", + "ladder": "1,4,8,16", + "workerCount": "1" + }, + "LatencySummary": { + "SampleCount": 16, + "AverageMilliseconds": 4257.2755125, + "P50Milliseconds": 4267.0387, + "P95Milliseconds": 4336.3771, + "P99Milliseconds": 4355.01502, + "MaxMilliseconds": 4359.6745 + }, + "PhaseLatencySummaries": null, + "BaselineComparison": { + "Status": "Compared", + "BaselineJsonPath": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfCapacity\\20260317T065038616-oracle-aq-signal-roundtrip-capacity-c1.json", + "ThroughputDeltaPercent": 111.40344778535216, + "AverageLatencyDeltaPercent": -17.682079318976367, + "P95LatencyDeltaPercent": -26.66951996523375, + "MaxLatencyDeltaPercent": -36.90018313918222 + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 64, + "user rollbacks": 3, + "session logical reads": 3609, + "db block gets": 814, + "consistent gets": 2795, + "physical reads": 11, + "physical writes": 0, + "redo size": 232824, + "parse count (total)": 261, + "execute count": 716, + "bytes sent via SQL*Net to client": 196713, + "bytes received via SQL*Net from client": 138344 + }, + "TimeModelDeltas": { + "DB time": 653630, + "DB CPU": 403101, + "sql execute elapsed time": 164051, + "connection management call elapsed time": 0, + "PL/SQL execution elapsed time": 15242 + }, + "TopWaitDeltas": [ + { + "EventName": "log file sync", + "TotalWaits": 81, + "TimeWaitedMicroseconds": 305097 + }, + { + "EventName": "SQL*Net message to client", + "TotalWaits": 596, + "TimeWaitedMicroseconds": 8862 + }, + { + "EventName": "Allocate UGA memory from OS", + "TotalWaits": 125, + "TimeWaitedMicroseconds": 3656 + }, + { + "EventName": "Disk file operations I/O", + "TotalWaits": 4, + "TimeWaitedMicroseconds": 622 + }, + { + "EventName": "Allocate PGA memory from OS", + "TotalWaits": 6, + "TimeWaitedMicroseconds": 153 + }, + { + "EventName": "asynch descriptor resize", + "TotalWaits": 4, + "TimeWaitedMicroseconds": 77 + }, + { + "EventName": "Allocate CGA memory from OS", + "TotalWaits": 4, + "TimeWaitedMicroseconds": 72 + }, + { + "EventName": "Free private memory to OS", + "TotalWaits": 1, + "TimeWaitedMicroseconds": 30 + } + ] + }, + "DurationMilliseconds": 4752.268, + "ThroughputPerSecond": 3.3668134877915135 + }, + { + "ScenarioName": "oracle-aq-signal-roundtrip-capacity-c16", + "Tier": "WorkflowPerfCapacity", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:54:27.7619206Z", + "CompletedAtUtc": "2026-03-17T06:54:35.2853885Z", + "OperationCount": 256, + "Concurrency": 16, + "Counters": { + "WorkflowsStarted": 256, + "TasksActivated": 0, + "TasksCompleted": 0, + "SignalsPublished": 256, + "SignalsProcessed": 256, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 207147008, + "PrivateMemoryBytes": 99090432, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "workflowName": "OracleAqPerfSignalRoundTripWorkflow", + "queueName": "WF_SIG_CC2D6C94", + "ladder": "1,4,8,16", + "workerCount": "8" + }, + "LatencySummary": { + "SampleCount": 256, + "AverageMilliseconds": 6551.805912499999, + "P50Milliseconds": 6500.01545, + "P95Milliseconds": 6710.049424999999, + "P99Milliseconds": 6717.13804, + "MaxMilliseconds": 6721.8137 + }, + "PhaseLatencySummaries": null, + "BaselineComparison": { + "Status": "Compared", + "BaselineJsonPath": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfCapacity\\20260317T065102433-oracle-aq-signal-roundtrip-capacity-c16.json", + "ThroughputDeltaPercent": 141.30725140729317, + "AverageLatencyDeltaPercent": -43.90882039706423, + "P95LatencyDeltaPercent": -48.53969704462981, + "MaxLatencyDeltaPercent": -49.00058094819212 + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 1024, + "user rollbacks": 24, + "session logical reads": 229828, + "db block gets": 17077, + "consistent gets": 212751, + "physical reads": 0, + "physical writes": 0, + "redo size": 3796688, + "parse count (total)": 1324, + "execute count": 6736, + "bytes sent via SQL*Net to client": 2722733, + "bytes received via SQL*Net from client": 1662437 + }, + "TimeModelDeltas": { + "DB time": 17605655, + "DB CPU": 6083523, + "sql execute elapsed time": 4293379, + "connection management call elapsed time": 0, + "PL/SQL execution elapsed time": 240928 + }, + "TopWaitDeltas": [ + { + "EventName": "log file sync", + "TotalWaits": 1277, + "TimeWaitedMicroseconds": 11084078 + }, + { + "EventName": "row cache lock", + "TotalWaits": 121, + "TimeWaitedMicroseconds": 597217 + }, + { + "EventName": "buffer busy waits", + "TotalWaits": 921, + "TimeWaitedMicroseconds": 235079 + }, + { + "EventName": "SQL*Net message to client", + "TotalWaits": 8796, + "TimeWaitedMicroseconds": 195262 + }, + { + "EventName": "row cache mutex", + "TotalWaits": 19, + "TimeWaitedMicroseconds": 128664 + }, + { + "EventName": "library cache: mutex X", + "TotalWaits": 41, + "TimeWaitedMicroseconds": 76424 + }, + { + "EventName": "latch: redo allocation", + "TotalWaits": 74, + "TimeWaitedMicroseconds": 70888 + }, + { + "EventName": "resmgr:cpu quantum", + "TotalWaits": 81, + "TimeWaitedMicroseconds": 56985 + } + ] + }, + "DurationMilliseconds": 7523.4679, + "ThroughputPerSecond": 34.02686146903079 + }, + { + "ScenarioName": "oracle-aq-signal-roundtrip-capacity-c4", + "Tier": "WorkflowPerfCapacity", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:54:17.5248858Z", + "CompletedAtUtc": "2026-03-17T06:54:21.7301288Z", + "OperationCount": 64, + "Concurrency": 4, + "Counters": { + "WorkflowsStarted": 64, + "TasksActivated": 0, + "TasksCompleted": 0, + "SignalsPublished": 64, + "SignalsProcessed": 64, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 185749504, + "PrivateMemoryBytes": 80089088, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "workflowName": "OracleAqPerfSignalRoundTripWorkflow", + "queueName": "WF_SIG_CC2D6C94", + "ladder": "1,4,8,16", + "workerCount": "4" + }, + "LatencySummary": { + "SampleCount": 64, + "AverageMilliseconds": 3926.41788125, + "P50Milliseconds": 3925.37505, + "P95Milliseconds": 3988.329625, + "P99Milliseconds": 3994.244458, + "MaxMilliseconds": 3994.738 + }, + "PhaseLatencySummaries": null, + "BaselineComparison": { + "Status": "Compared", + "BaselineJsonPath": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfCapacity\\20260317T065046261-oracle-aq-signal-roundtrip-capacity-c4.json", + "ThroughputDeltaPercent": 122.35248078014354, + "AverageLatencyDeltaPercent": -36.08936464561038, + "P95LatencyDeltaPercent": -37.50932606605761, + "MaxLatencyDeltaPercent": -37.55120121225212 + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 256, + "user rollbacks": 12, + "session logical reads": 19710, + "db block gets": 3622, + "consistent gets": 16088, + "physical reads": 0, + "physical writes": 0, + "redo size": 913884, + "parse count (total)": 476, + "execute count": 1760, + "bytes sent via SQL*Net to client": 744731, + "bytes received via SQL*Net from client": 474884 + }, + "TimeModelDeltas": { + "DB time": 1867747, + "DB CPU": 1070601, + "sql execute elapsed time": 396183, + "connection management call elapsed time": 60311, + "PL/SQL execution elapsed time": 31491 + }, + "TopWaitDeltas": [ + { + "EventName": "log file sync", + "TotalWaits": 325, + "TimeWaitedMicroseconds": 1012356 + }, + { + "EventName": "SQL*Net message to client", + "TotalWaits": 2270, + "TimeWaitedMicroseconds": 25930 + }, + { + "EventName": "latch: shared pool", + "TotalWaits": 13, + "TimeWaitedMicroseconds": 17320 + }, + { + "EventName": "buffer busy waits", + "TotalWaits": 206, + "TimeWaitedMicroseconds": 12138 + }, + { + "EventName": "row cache lock", + "TotalWaits": 16, + "TimeWaitedMicroseconds": 5119 + }, + { + "EventName": "Allocate UGA memory from OS", + "TotalWaits": 201, + "TimeWaitedMicroseconds": 3391 + }, + { + "EventName": "Disk file operations I/O", + "TotalWaits": 11, + "TimeWaitedMicroseconds": 1143 + }, + { + "EventName": "Allocate PGA memory from OS", + "TotalWaits": 36, + "TimeWaitedMicroseconds": 697 + } + ] + }, + "DurationMilliseconds": 4205.243, + "ThroughputPerSecond": 15.21909673234103 + }, + { + "ScenarioName": "oracle-aq-signal-roundtrip-capacity-c8", + "Tier": "WorkflowPerfCapacity", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:54:21.747641Z", + "CompletedAtUtc": "2026-03-17T06:54:27.7465236Z", + "OperationCount": 128, + "Concurrency": 8, + "Counters": { + "WorkflowsStarted": 128, + "TasksActivated": 0, + "TasksCompleted": 0, + "SignalsPublished": 128, + "SignalsProcessed": 128, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 189431808, + "PrivateMemoryBytes": 84828160, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "workflowName": "OracleAqPerfSignalRoundTripWorkflow", + "queueName": "WF_SIG_CC2D6C94", + "ladder": "1,4,8,16", + "workerCount": "8" + }, + "LatencySummary": { + "SampleCount": 128, + "AverageMilliseconds": 5226.55715703125, + "P50Milliseconds": 5140.54075, + "P95Milliseconds": 5561.215920000001, + "P99Milliseconds": 5593.021650000001, + "MaxMilliseconds": 5605.5868 + }, + "PhaseLatencySummaries": null, + "BaselineComparison": { + "Status": "Compared", + "BaselineJsonPath": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfCapacity\\20260317T065053290-oracle-aq-signal-roundtrip-capacity-c8.json", + "ThroughputDeltaPercent": 102.85810116259543, + "AverageLatencyDeltaPercent": -35.780623894340465, + "P95LatencyDeltaPercent": -33.70068728532989, + "MaxLatencyDeltaPercent": -33.38835279487057 + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 512, + "user rollbacks": 24, + "session logical reads": 66375, + "db block gets": 8433, + "consistent gets": 57942, + "physical reads": 7, + "physical writes": 0, + "redo size": 1910412, + "parse count (total)": 863, + "execute count": 3576, + "bytes sent via SQL*Net to client": 1427337, + "bytes received via SQL*Net from client": 887393 + }, + "TimeModelDeltas": { + "DB time": 14103899, + "DB CPU": 2746786, + "sql execute elapsed time": 1382807, + "connection management call elapsed time": 106726, + "PL/SQL execution elapsed time": 87557 + }, + "TopWaitDeltas": [ + { + "EventName": "log file sync", + "TotalWaits": 646, + "TimeWaitedMicroseconds": 11077050 + }, + { + "EventName": "latch: shared pool", + "TotalWaits": 70, + "TimeWaitedMicroseconds": 299112 + }, + { + "EventName": "resmgr:cpu quantum", + "TotalWaits": 432, + "TimeWaitedMicroseconds": 165515 + }, + { + "EventName": "SQL*Net message to client", + "TotalWaits": 4524, + "TimeWaitedMicroseconds": 82576 + }, + { + "EventName": "buffer busy waits", + "TotalWaits": 484, + "TimeWaitedMicroseconds": 68374 + }, + { + "EventName": "kksfbc child completion", + "TotalWaits": 1, + "TimeWaitedMicroseconds": 50135 + }, + { + "EventName": "row cache lock", + "TotalWaits": 62, + "TimeWaitedMicroseconds": 40049 + }, + { + "EventName": "latch free", + "TotalWaits": 18, + "TimeWaitedMicroseconds": 35223 + } + ] + }, + "DurationMilliseconds": 5998.8826, + "ThroughputPerSecond": 21.337307051149825 + }, + { + "ScenarioName": "oracle-aq-signal-roundtrip-latency-serial", + "Tier": "WorkflowPerfLatency", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:54:36.958622Z", + "CompletedAtUtc": "2026-03-17T06:55:26.7141408Z", + "OperationCount": 16, + "Concurrency": 1, + "Counters": { + "WorkflowsStarted": 16, + "TasksActivated": 0, + "TasksCompleted": 0, + "SignalsPublished": 16, + "SignalsProcessed": 16, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 208019456, + "PrivateMemoryBytes": 98623488, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "workflowName": "OracleAqPerfSignalRoundTripWorkflow", + "queueName": "WF_SIG_1A387D3D", + "workerCount": "1", + "measurementKind": "serial-latency" + }, + "LatencySummary": { + "SampleCount": 16, + "AverageMilliseconds": 3104.8496437500003, + "P50Milliseconds": 3105.14445, + "P95Milliseconds": 3165.035025, + "P99Milliseconds": 3218.930925, + "MaxMilliseconds": 3232.4049 + }, + "PhaseLatencySummaries": { + "start": { + "SampleCount": 16, + "AverageMilliseconds": 25.13830625, + "P50Milliseconds": 22.84435, + "P95Milliseconds": 41.9216, + "P99Milliseconds": 44.5976, + "MaxMilliseconds": 45.2666 + }, + "signalPublish": { + "SampleCount": 16, + "AverageMilliseconds": 16.56644375, + "P50Milliseconds": 15.26365, + "P95Milliseconds": 31.3911, + "P99Milliseconds": 46.85069999999999, + "MaxMilliseconds": 50.7156 + }, + "signalToCompletion": { + "SampleCount": 16, + "AverageMilliseconds": 3079.70409375, + "P50Milliseconds": 3086.8015, + "P95Milliseconds": 3128.563025, + "P99Milliseconds": 3188.378045, + "MaxMilliseconds": 3203.3318 + } + }, + "BaselineComparison": { + "Status": "Missing", + "BaselineJsonPath": null, + "ThroughputDeltaPercent": null, + "AverageLatencyDeltaPercent": null, + "P95LatencyDeltaPercent": null, + "MaxLatencyDeltaPercent": null + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 64, + "user rollbacks": 48, + "session logical reads": 14846, + "db block gets": 786, + "consistent gets": 14060, + "physical reads": 0, + "physical writes": 0, + "redo size": 230460, + "parse count (total)": 235, + "execute count": 807, + "bytes sent via SQL*Net to client": 179165, + "bytes received via SQL*Net from client": 112421 + }, + "TimeModelDeltas": { + "DB time": 722781, + "DB CPU": 543391, + "sql execute elapsed time": 241566, + "connection management call elapsed time": 0, + "PL/SQL execution elapsed time": 13221 + }, + "TopWaitDeltas": [ + { + "EventName": "log file sync", + "TotalWaits": 80, + "TimeWaitedMicroseconds": 255291 + }, + { + "EventName": "db file sequential read", + "TotalWaits": 39, + "TimeWaitedMicroseconds": 13037 + }, + { + "EventName": "SQL*Net message to client", + "TotalWaits": 660, + "TimeWaitedMicroseconds": 12336 + }, + { + "EventName": "db file scattered read", + "TotalWaits": 2, + "TimeWaitedMicroseconds": 2155 + }, + { + "EventName": "Free private memory to OS", + "TotalWaits": 16, + "TimeWaitedMicroseconds": 1630 + }, + { + "EventName": "Allocate UGA memory from OS", + "TotalWaits": 73, + "TimeWaitedMicroseconds": 1321 + }, + { + "EventName": "Allocate PGA memory from OS", + "TotalWaits": 18, + "TimeWaitedMicroseconds": 578 + }, + { + "EventName": "Allocate CGA memory from OS", + "TotalWaits": 19, + "TimeWaitedMicroseconds": 552 + } + ] + }, + "DurationMilliseconds": 49755.5188, + "ThroughputPerSecond": 0.3215723679681539 + }, + { + "ScenarioName": "oracle-aq-bulstrad-quotation-confirm-convert-to-policy-nightly", + "Tier": "WorkflowPerfNightly", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:55:28.3701595Z", + "CompletedAtUtc": "2026-03-17T06:55:35.1312976Z", + "OperationCount": 12, + "Concurrency": 4, + "Counters": { + "WorkflowsStarted": 12, + "TasksActivated": 12, + "TasksCompleted": 12, + "SignalsPublished": 0, + "SignalsProcessed": 12, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 231018496, + "PrivateMemoryBytes": 112046080, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "workflowName": "QuotationConfirm", + "legacyRabbitInvocationCount": "48", + "expectedInvocationCount": "48" + }, + "LatencySummary": { + "SampleCount": 12, + "AverageMilliseconds": 5679.634774999999, + "P50Milliseconds": 5460.608899999999, + "P95Milliseconds": 6259.651475000001, + "P99Milliseconds": 6272.989415, + "MaxMilliseconds": 6276.3239 + }, + "PhaseLatencySummaries": null, + "BaselineComparison": { + "Status": "Compared", + "BaselineJsonPath": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfNightly\\20260317T060159466-oracle-aq-bulstrad-quotation-confirm-convert-to-policy-nightly.json", + "ThroughputDeltaPercent": -33.66266102448049, + "AverageLatencyDeltaPercent": 40.369281037237485, + "P95LatencyDeltaPercent": 51.58563760609489, + "MaxLatencyDeltaPercent": 51.77749455179318 + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 48, + "user rollbacks": 3, + "session logical reads": 18562, + "db block gets": 2640, + "consistent gets": 15922, + "physical reads": 0, + "physical writes": 0, + "redo size": 505656, + "parse count (total)": 483, + "execute count": 1106, + "bytes sent via SQL*Net to client": 397537, + "bytes received via SQL*Net from client": 268370 + }, + "TimeModelDeltas": { + "DB time": 5342865, + "DB CPU": 1328408, + "sql execute elapsed time": 3827897, + "connection management call elapsed time": 0, + "PL/SQL execution elapsed time": 17890 + }, + "TopWaitDeltas": [ + { + "EventName": "library cache lock", + "TotalWaits": 11, + "TimeWaitedMicroseconds": 2391328 + }, + { + "EventName": "log file sync", + "TotalWaits": 113, + "TimeWaitedMicroseconds": 825425 + }, + { + "EventName": "local write wait", + "TotalWaits": 28, + "TimeWaitedMicroseconds": 437578 + }, + { + "EventName": "cursor: pin S wait on X", + "TotalWaits": 34, + "TimeWaitedMicroseconds": 254529 + }, + { + "EventName": "kksfbc child completion", + "TotalWaits": 2, + "TimeWaitedMicroseconds": 100354 + }, + { + "EventName": "control file parallel write", + "TotalWaits": 3, + "TimeWaitedMicroseconds": 42446 + }, + { + "EventName": "direct path sync", + "TotalWaits": 1, + "TimeWaitedMicroseconds": 40158 + }, + { + "EventName": "library cache: mutex X", + "TotalWaits": 10, + "TimeWaitedMicroseconds": 39522 + } + ] + }, + "DurationMilliseconds": 6761.1381, + "ThroughputPerSecond": 1.7748491189671158 + }, + { + "ScenarioName": "oracle-aq-delayed-burst-nightly", + "Tier": "WorkflowPerfNightly", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:55:43.6507323Z", + "CompletedAtUtc": "2026-03-17T06:55:48.1341496Z", + "OperationCount": 48, + "Concurrency": 1, + "Counters": { + "WorkflowsStarted": 0, + "TasksActivated": 0, + "TasksCompleted": 0, + "SignalsPublished": 48, + "SignalsProcessed": 48, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 222384128, + "PrivateMemoryBytes": 103014400, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "queueName": "WF_SIG_F0549346", + "messageCount": "48", + "delaySeconds": "2" + }, + "LatencySummary": { + "SampleCount": 48, + "AverageMilliseconds": 3908.132391666668, + "P50Milliseconds": 3958.3989, + "P95Milliseconds": 3978.469545, + "P99Milliseconds": 3985.599789, + "MaxMilliseconds": 3991.7527 + }, + "PhaseLatencySummaries": null, + "BaselineComparison": { + "Status": "Compared", + "BaselineJsonPath": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfNightly\\20260317T060209494-oracle-aq-delayed-burst-nightly.json", + "ThroughputDeltaPercent": -6.152715251377564, + "AverageLatencyDeltaPercent": -0.6888796354329934, + "P95LatencyDeltaPercent": 0.8697128773936016, + "MaxLatencyDeltaPercent": 0.9970636927902036 + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 96, + "user rollbacks": 3, + "session logical reads": 3043, + "db block gets": 1463, + "consistent gets": 1580, + "physical reads": 0, + "physical writes": 0, + "redo size": 197696, + "parse count (total)": 121, + "execute count": 211, + "bytes sent via SQL*Net to client": 16724, + "bytes received via SQL*Net from client": 19231 + }, + "TimeModelDeltas": { + "DB time": 631890, + "DB CPU": 235035, + "sql execute elapsed time": 83903, + "connection management call elapsed time": 0, + "PL/SQL execution elapsed time": 0 + }, + "TopWaitDeltas": [ + { + "EventName": "log file sync", + "TotalWaits": 96, + "TimeWaitedMicroseconds": 425159 + }, + { + "EventName": "Allocate CGA memory from OS", + "TotalWaits": 3, + "TimeWaitedMicroseconds": 4549 + }, + { + "EventName": "SQL*Net message to client", + "TotalWaits": 202, + "TimeWaitedMicroseconds": 4059 + }, + { + "EventName": "Free private memory to OS", + "TotalWaits": 3, + "TimeWaitedMicroseconds": 1657 + }, + { + "EventName": "Allocate UGA memory from OS", + "TotalWaits": 7, + "TimeWaitedMicroseconds": 109 + } + ] + }, + "DurationMilliseconds": 4483.4173, + "ThroughputPerSecond": 10.706119191715658 + }, + { + "ScenarioName": "oracle-aq-immediate-burst-nightly", + "Tier": "WorkflowPerfNightly", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:55:49.0628295Z", + "CompletedAtUtc": "2026-03-17T06:55:51.4541179Z", + "OperationCount": 120, + "Concurrency": 1, + "Counters": { + "WorkflowsStarted": 0, + "TasksActivated": 0, + "TasksCompleted": 0, + "SignalsPublished": 120, + "SignalsProcessed": 120, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 223862784, + "PrivateMemoryBytes": 104402944, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "queueName": "WF_SIG_5EFE21C5", + "messageCount": "120" + }, + "LatencySummary": { + "SampleCount": 120, + "AverageMilliseconds": 902.1741275000003, + "P50Milliseconds": 852.80865, + "P95Milliseconds": 1179.586125, + "P99Milliseconds": 1193.377898, + "MaxMilliseconds": 1207.4427 + }, + "PhaseLatencySummaries": null, + "BaselineComparison": { + "Status": "Compared", + "BaselineJsonPath": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfNightly\\20260317T060213962-oracle-aq-immediate-burst-nightly.json", + "ThroughputDeltaPercent": -49.673222184325404, + "AverageLatencyDeltaPercent": 50.774487640711754, + "P95LatencyDeltaPercent": 92.4785518550147, + "MaxLatencyDeltaPercent": 96.5422708017108 + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 240, + "user rollbacks": 0, + "session logical reads": 12426, + "db block gets": 3916, + "consistent gets": 8510, + "physical reads": 0, + "physical writes": 0, + "redo size": 451200, + "parse count (total)": 198, + "execute count": 416, + "bytes sent via SQL*Net to client": 37073, + "bytes received via SQL*Net from client": 47714 + }, + "TimeModelDeltas": { + "DB time": 1432494, + "DB CPU": 429385, + "sql execute elapsed time": 133696, + "connection management call elapsed time": 0, + "PL/SQL execution elapsed time": 0 + }, + "TopWaitDeltas": [ + { + "EventName": "log file sync", + "TotalWaits": 241, + "TimeWaitedMicroseconds": 1085144 + }, + { + "EventName": "SQL*Net message to client", + "TotalWaits": 484, + "TimeWaitedMicroseconds": 8176 + }, + { + "EventName": "db file sequential read", + "TotalWaits": 1, + "TimeWaitedMicroseconds": 543 + }, + { + "EventName": "Allocate UGA memory from OS", + "TotalWaits": 8, + "TimeWaitedMicroseconds": 196 + }, + { + "EventName": "Allocate CGA memory from OS", + "TotalWaits": 2, + "TimeWaitedMicroseconds": 88 + }, + { + "EventName": "Free private memory to OS", + "TotalWaits": 1, + "TimeWaitedMicroseconds": 47 + } + ] + }, + "DurationMilliseconds": 2391.2884, + "ThroughputPerSecond": 50.18215285115756 + }, + { + "ScenarioName": "oracle-aq-synthetic-external-resume-nightly", + "Tier": "WorkflowPerfNightly", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:55:35.8138896Z", + "CompletedAtUtc": "2026-03-17T06:55:42.6076187Z", + "OperationCount": 36, + "Concurrency": 8, + "Counters": { + "WorkflowsStarted": 36, + "TasksActivated": 36, + "TasksCompleted": 0, + "SignalsPublished": 36, + "SignalsProcessed": 36, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 224591872, + "PrivateMemoryBytes": 105607168, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "workflowName": "OracleAqPerfExternalSignalWorkflow", + "queueName": "WF_SIG_C844EA13" + }, + "LatencySummary": { + "SampleCount": 36, + "AverageMilliseconds": 6238.798127777779, + "P50Milliseconds": 6243.439249999999, + "P95Milliseconds": 6425.953225, + "P99Milliseconds": 6457.968145, + "MaxMilliseconds": 6466.7494 + }, + "PhaseLatencySummaries": null, + "BaselineComparison": { + "Status": "Compared", + "BaselineJsonPath": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfNightly\\20260317T060204534-oracle-aq-synthetic-external-resume-nightly.json", + "ThroughputDeltaPercent": -32.035556142502045, + "AverageLatencyDeltaPercent": 43.30188970414623, + "P95LatencyDeltaPercent": 45.26597303091357, + "MaxLatencyDeltaPercent": 45.84078417977302 + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 144, + "user rollbacks": 3, + "session logical reads": 28335, + "db block gets": 2572, + "consistent gets": 25763, + "physical reads": 0, + "physical writes": 0, + "redo size": 686140, + "parse count (total)": 289, + "execute count": 1099, + "bytes sent via SQL*Net to client": 403271, + "bytes received via SQL*Net from client": 319970 + }, + "TimeModelDeltas": { + "DB time": 2164819, + "DB CPU": 1043873, + "sql execute elapsed time": 529220, + "connection management call elapsed time": 0, + "PL/SQL execution elapsed time": 27601 + }, + "TopWaitDeltas": [ + { + "EventName": "log file sync", + "TotalWaits": 251, + "TimeWaitedMicroseconds": 1163462 + }, + { + "EventName": "kksfbc child completion", + "TotalWaits": 1, + "TimeWaitedMicroseconds": 54809 + }, + { + "EventName": "library cache: mutex X", + "TotalWaits": 9, + "TimeWaitedMicroseconds": 41247 + }, + { + "EventName": "SQL*Net message to client", + "TotalWaits": 1315, + "TimeWaitedMicroseconds": 31165 + }, + { + "EventName": "row cache lock", + "TotalWaits": 17, + "TimeWaitedMicroseconds": 24348 + }, + { + "EventName": "row cache mutex", + "TotalWaits": 2, + "TimeWaitedMicroseconds": 21077 + }, + { + "EventName": "cursor: pin S wait on X", + "TotalWaits": 3, + "TimeWaitedMicroseconds": 11636 + }, + { + "EventName": "buffer busy waits", + "TotalWaits": 19, + "TimeWaitedMicroseconds": 2137 + } + ] + }, + "DurationMilliseconds": 6793.7291, + "ThroughputPerSecond": 5.299004342107195 + }, + { + "ScenarioName": "oracle-aq-bulstrad-quote-or-apl-cancel-smoke", + "Tier": "WorkflowPerfSmoke", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:55:52.0130806Z", + "CompletedAtUtc": "2026-03-17T06:55:52.5208681Z", + "OperationCount": 10, + "Concurrency": 4, + "Counters": { + "WorkflowsStarted": 10, + "TasksActivated": 0, + "TasksCompleted": 0, + "SignalsPublished": 0, + "SignalsProcessed": 0, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 227803136, + "PrivateMemoryBytes": 107597824, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "workflowName": "QuoteOrAplCancel", + "legacyRabbitInvocationCount": "20" + }, + "LatencySummary": { + "SampleCount": 10, + "AverageMilliseconds": 28.54009, + "P50Milliseconds": 26.61685, + "P95Milliseconds": 40.051069999999996, + "P99Milliseconds": 42.35701400000001, + "MaxMilliseconds": 42.9335 + }, + "PhaseLatencySummaries": null, + "BaselineComparison": { + "Status": "Compared", + "BaselineJsonPath": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfSmoke\\20260317T060215450-oracle-aq-bulstrad-quote-or-apl-cancel-smoke.json", + "ThroughputDeltaPercent": -59.98320163454201, + "AverageLatencyDeltaPercent": 49.51849329421625, + "P95LatencyDeltaPercent": 49.744982321777876, + "MaxLatencyDeltaPercent": 42.19027369313515 + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 10, + "user rollbacks": 0, + "session logical reads": 3411, + "db block gets": 157, + "consistent gets": 3254, + "physical reads": 0, + "physical writes": 0, + "redo size": 40748, + "parse count (total)": 28, + "execute count": 139, + "bytes sent via SQL*Net to client": 49929, + "bytes received via SQL*Net from client": 29281 + }, + "TimeModelDeltas": { + "DB time": 208868, + "DB CPU": 110617, + "sql execute elapsed time": 71486, + "connection management call elapsed time": 0, + "PL/SQL execution elapsed time": 5008 + }, + "TopWaitDeltas": [ + { + "EventName": "log file sync", + "TotalWaits": 20, + "TimeWaitedMicroseconds": 107967 + }, + { + "EventName": "library cache: mutex X", + "TotalWaits": 3, + "TimeWaitedMicroseconds": 10258 + }, + { + "EventName": "SQL*Net message to client", + "TotalWaits": 155, + "TimeWaitedMicroseconds": 2545 + }, + { + "EventName": "Allocate UGA memory from OS", + "TotalWaits": 10, + "TimeWaitedMicroseconds": 304 + }, + { + "EventName": "Free private memory to OS", + "TotalWaits": 1, + "TimeWaitedMicroseconds": 128 + }, + { + "EventName": "row cache lock", + "TotalWaits": 1, + "TimeWaitedMicroseconds": 122 + }, + { + "EventName": "buffer busy waits", + "TotalWaits": 3, + "TimeWaitedMicroseconds": 69 + }, + { + "EventName": "Allocate CGA memory from OS", + "TotalWaits": 2, + "TimeWaitedMicroseconds": 61 + } + ] + }, + "DurationMilliseconds": 507.7875, + "ThroughputPerSecond": 19.69327720749329 + }, + { + "ScenarioName": "oracle-aq-delayed-burst-smoke", + "Tier": "WorkflowPerfSmoke", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:55:57.8489272Z", + "CompletedAtUtc": "2026-03-17T06:56:02.0518356Z", + "OperationCount": 12, + "Concurrency": 1, + "Counters": { + "WorkflowsStarted": 0, + "TasksActivated": 0, + "TasksCompleted": 0, + "SignalsPublished": 12, + "SignalsProcessed": 12, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 227708928, + "PrivateMemoryBytes": 107507712, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "queueName": "WF_SIG_A40A4BC1", + "messageCount": "12", + "delaySeconds": "2" + }, + "LatencySummary": { + "SampleCount": 12, + "AverageMilliseconds": 4040.6222666666667, + "P50Milliseconds": 4028.3791, + "P95Milliseconds": 4083.701855, + "P99Milliseconds": 4084.0370909999997, + "MaxMilliseconds": 4084.1209 + }, + "PhaseLatencySummaries": null, + "BaselineComparison": { + "Status": "Compared", + "BaselineJsonPath": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfSmoke\\20260317T060220104-oracle-aq-delayed-burst-smoke.json", + "ThroughputDeltaPercent": 21.213184184551785, + "AverageLatencyDeltaPercent": -19.478987186577744, + "P95LatencyDeltaPercent": -18.653181534784977, + "MaxLatencyDeltaPercent": -18.647327161761783 + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 24, + "user rollbacks": 3, + "session logical reads": 566, + "db block gets": 363, + "consistent gets": 203, + "physical reads": 0, + "physical writes": 0, + "redo size": 52724, + "parse count (total)": 70, + "execute count": 97, + "bytes sent via SQL*Net to client": 6345, + "bytes received via SQL*Net from client": 4855 + }, + "TimeModelDeltas": { + "DB time": 219282, + "DB CPU": 110228, + "sql execute elapsed time": 49856, + "connection management call elapsed time": 0, + "PL/SQL execution elapsed time": 0 + }, + "TopWaitDeltas": [ + { + "EventName": "log file sync", + "TotalWaits": 26, + "TimeWaitedMicroseconds": 77954 + }, + { + "EventName": "enq: CR - block range reuse ckpt", + "TotalWaits": 2, + "TimeWaitedMicroseconds": 41949 + }, + { + "EventName": "SQL*Net message to client", + "TotalWaits": 58, + "TimeWaitedMicroseconds": 1587 + }, + { + "EventName": "reliable message", + "TotalWaits": 2, + "TimeWaitedMicroseconds": 456 + }, + { + "EventName": "Allocate UGA memory from OS", + "TotalWaits": 6, + "TimeWaitedMicroseconds": 44 + }, + { + "EventName": "Free private memory to OS", + "TotalWaits": 1, + "TimeWaitedMicroseconds": 24 + } + ] + }, + "DurationMilliseconds": 4202.9084, + "ThroughputPerSecond": 2.8551657228599128 + }, + { + "ScenarioName": "oracle-aq-immediate-burst-smoke", + "Tier": "WorkflowPerfSmoke", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:56:02.7946607Z", + "CompletedAtUtc": "2026-03-17T06:56:03.2161378Z", + "OperationCount": 24, + "Concurrency": 1, + "Counters": { + "WorkflowsStarted": 0, + "TasksActivated": 0, + "TasksCompleted": 0, + "SignalsPublished": 24, + "SignalsProcessed": 24, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 227786752, + "PrivateMemoryBytes": 107585536, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "queueName": "WF_SIG_BF3EDC15", + "messageCount": "24" + }, + "LatencySummary": { + "SampleCount": 24, + "AverageMilliseconds": 205.87201249999998, + "P50Milliseconds": 207.7811, + "P95Milliseconds": 209.90347, + "P99Milliseconds": 210.10454000000001, + "MaxMilliseconds": 210.1602 + }, + "PhaseLatencySummaries": null, + "BaselineComparison": { + "Status": "Compared", + "BaselineJsonPath": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfSmoke\\20260317T060225611-oracle-aq-immediate-burst-smoke.json", + "ThroughputDeltaPercent": -36.2397387663529, + "AverageLatencyDeltaPercent": 59.29590703199914, + "P95LatencyDeltaPercent": 53.92061743219336, + "MaxLatencyDeltaPercent": 53.66878762929416 + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 48, + "user rollbacks": 0, + "session logical reads": 973, + "db block gets": 575, + "consistent gets": 398, + "physical reads": 0, + "physical writes": 0, + "redo size": 88700, + "parse count (total)": 101, + "execute count": 128, + "bytes sent via SQL*Net to client": 9192, + "bytes received via SQL*Net from client": 9178 + }, + "TimeModelDeltas": { + "DB time": 243422, + "DB CPU": 124594, + "sql execute elapsed time": 39082, + "connection management call elapsed time": 0, + "PL/SQL execution elapsed time": 0 + }, + "TopWaitDeltas": [ + { + "EventName": "log file sync", + "TotalWaits": 48, + "TimeWaitedMicroseconds": 138062 + }, + { + "EventName": "SQL*Net message to client", + "TotalWaits": 100, + "TimeWaitedMicroseconds": 2214 + }, + { + "EventName": "Allocate UGA memory from OS", + "TotalWaits": 8, + "TimeWaitedMicroseconds": 123 + }, + { + "EventName": "Allocate CGA memory from OS", + "TotalWaits": 2, + "TimeWaitedMicroseconds": 86 + }, + { + "EventName": "Disk file operations I/O", + "TotalWaits": 1, + "TimeWaitedMicroseconds": 82 + }, + { + "EventName": "Allocate PGA memory from OS", + "TotalWaits": 1, + "TimeWaitedMicroseconds": 39 + }, + { + "EventName": "Free private memory to OS", + "TotalWaits": 1, + "TimeWaitedMicroseconds": 30 + } + ] + }, + "DurationMilliseconds": 421.4771, + "ThroughputPerSecond": 56.942595457736616 + }, + { + "ScenarioName": "oracle-aq-synthetic-external-resume-smoke", + "Tier": "WorkflowPerfSmoke", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:55:52.9589984Z", + "CompletedAtUtc": "2026-03-17T06:55:56.8023924Z", + "OperationCount": 12, + "Concurrency": 4, + "Counters": { + "WorkflowsStarted": 12, + "TasksActivated": 12, + "TasksCompleted": 0, + "SignalsPublished": 12, + "SignalsProcessed": 12, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 229904384, + "PrivateMemoryBytes": 109285376, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "queueName": "WF_SIG_7387F25A", + "workflowName": "OracleAqPerfExternalSignalWorkflow" + }, + "LatencySummary": { + "SampleCount": 12, + "AverageMilliseconds": 3644.9080833333333, + "P50Milliseconds": 3644.0836, + "P95Milliseconds": 3691.308265, + "P99Milliseconds": 3695.795253, + "MaxMilliseconds": 3696.917 + }, + "PhaseLatencySummaries": null, + "BaselineComparison": { + "Status": "Compared", + "BaselineJsonPath": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfSmoke\\20260317T060216020-oracle-aq-synthetic-external-resume-smoke.json", + "ThroughputDeltaPercent": -5.2379667554250275, + "AverageLatencyDeltaPercent": 3.6733375828938293, + "P95LatencyDeltaPercent": 4.3863842062592076, + "MaxLatencyDeltaPercent": 4.44895876995974 + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 48, + "user rollbacks": 3, + "session logical reads": 9457, + "db block gets": 824, + "consistent gets": 8633, + "physical reads": 0, + "physical writes": 0, + "redo size": 231692, + "parse count (total)": 135, + "execute count": 400, + "bytes sent via SQL*Net to client": 134356, + "bytes received via SQL*Net from client": 108435 + }, + "TimeModelDeltas": { + "DB time": 479600, + "DB CPU": 258527, + "sql execute elapsed time": 118851, + "connection management call elapsed time": 0, + "PL/SQL execution elapsed time": 7229 + }, + "TopWaitDeltas": [ + { + "EventName": "log file sync", + "TotalWaits": 84, + "TimeWaitedMicroseconds": 246930 + }, + { + "EventName": "library cache: mutex X", + "TotalWaits": 4, + "TimeWaitedMicroseconds": 20550 + }, + { + "EventName": "SQL*Net message to client", + "TotalWaits": 441, + "TimeWaitedMicroseconds": 6808 + }, + { + "EventName": "row cache lock", + "TotalWaits": 4, + "TimeWaitedMicroseconds": 1550 + }, + { + "EventName": "cursor: pin S wait on X", + "TotalWaits": 1, + "TimeWaitedMicroseconds": 1178 + }, + { + "EventName": "library cache load lock", + "TotalWaits": 1, + "TimeWaitedMicroseconds": 347 + }, + { + "EventName": "Allocate CGA memory from OS", + "TotalWaits": 6, + "TimeWaitedMicroseconds": 202 + }, + { + "EventName": "Free private memory to OS", + "TotalWaits": 3, + "TimeWaitedMicroseconds": 189 + } + ] + }, + "DurationMilliseconds": 3843.394, + "ThroughputPerSecond": 3.1222403948177053 + }, + { + "ScenarioName": "oracle-aq-signal-roundtrip-soak", + "Tier": "WorkflowPerfSoak", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:56:03.9121892Z", + "CompletedAtUtc": "2026-03-17T06:56:31.5323533Z", + "OperationCount": 108, + "Concurrency": 8, + "Counters": { + "WorkflowsStarted": 108, + "TasksActivated": 0, + "TasksCompleted": 0, + "SignalsPublished": 108, + "SignalsProcessed": 108, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 227196928, + "PrivateMemoryBytes": 105689088, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "workflowName": "OracleAqPerfSignalRoundTripWorkflow", + "queueName": "WF_SIG_A0DDB628", + "waveCount": "6", + "workflowsPerWave": "18", + "workerCount": "8" + }, + "LatencySummary": { + "SampleCount": 108, + "AverageMilliseconds": 4494.292304629631, + "P50Milliseconds": 4281.37695, + "P95Milliseconds": 5589.325505, + "P99Milliseconds": 5594.529047999999, + "MaxMilliseconds": 5595.0419 + }, + "PhaseLatencySummaries": null, + "BaselineComparison": { + "Status": "Compared", + "BaselineJsonPath": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfSoak\\20260317T065117730-oracle-aq-signal-roundtrip-soak.json", + "ThroughputDeltaPercent": -3.62935135494, + "AverageLatencyDeltaPercent": 6.328785591276806, + "P95LatencyDeltaPercent": 13.88171964381323, + "MaxLatencyDeltaPercent": 12.159385572433859 + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 432, + "user rollbacks": 180, + "session logical reads": 104711, + "db block gets": 5862, + "consistent gets": 98849, + "physical reads": 0, + "physical writes": 0, + "redo size": 1535580, + "parse count (total)": 641, + "execute count": 3398, + "bytes sent via SQL*Net to client": 1193820, + "bytes received via SQL*Net from client": 721244 + }, + "TimeModelDeltas": { + "DB time": 15394405, + "DB CPU": 2680492, + "sql execute elapsed time": 1525687, + "connection management call elapsed time": 0, + "PL/SQL execution elapsed time": 82833 + }, + "TopWaitDeltas": [ + { + "EventName": "log file sync", + "TotalWaits": 540, + "TimeWaitedMicroseconds": 12450347 + }, + { + "EventName": "resmgr:cpu quantum", + "TotalWaits": 536, + "TimeWaitedMicroseconds": 209620 + }, + { + "EventName": "row cache lock", + "TotalWaits": 71, + "TimeWaitedMicroseconds": 157132 + }, + { + "EventName": "kksfbc child completion", + "TotalWaits": 2, + "TimeWaitedMicroseconds": 100181 + }, + { + "EventName": "SQL*Net message to client", + "TotalWaits": 4074, + "TimeWaitedMicroseconds": 97003 + }, + { + "EventName": "cursor: pin S wait on X", + "TotalWaits": 25, + "TimeWaitedMicroseconds": 89298 + }, + { + "EventName": "buffer busy waits", + "TotalWaits": 343, + "TimeWaitedMicroseconds": 75467 + }, + { + "EventName": "row cache mutex", + "TotalWaits": 16, + "TimeWaitedMicroseconds": 38066 + } + ] + }, + "DurationMilliseconds": 27620.1641, + "ThroughputPerSecond": 3.9101867609830747 + }, + { + "ScenarioName": "oracle-aq-signal-roundtrip-throughput-parallel", + "Tier": "WorkflowPerfThroughput", + "EnvironmentName": "oracle-aq-docker", + "StartedAtUtc": "2026-03-17T06:56:36.6858785Z", + "CompletedAtUtc": "2026-03-17T06:56:41.2618718Z", + "OperationCount": 96, + "Concurrency": 16, + "Counters": { + "WorkflowsStarted": 96, + "TasksActivated": 0, + "TasksCompleted": 0, + "SignalsPublished": 96, + "SignalsProcessed": 96, + "SignalsIgnored": 0, + "DeadLetteredSignals": 0, + "RuntimeConflicts": 0, + "Failures": 0, + "StuckInstances": 0 + }, + "ResourceSnapshot": { + "WorkingSetBytes": 238190592, + "PrivateMemoryBytes": 116928512, + "MachineName": "AW-36152", + "FrameworkDescription": ".NET 9.0.14", + "OsDescription": "Microsoft Windows 10.0.26200" + }, + "Metadata": { + "workflowName": "OracleAqPerfSignalRoundTripWorkflow", + "queueName": "WF_SIG_0C12807D", + "workerCount": "8", + "measurementKind": "steady-throughput" + }, + "LatencySummary": { + "SampleCount": 96, + "AverageMilliseconds": 4142.129051041666, + "P50Milliseconds": 4128.4953000000005, + "P95Milliseconds": 4215.6449999999995, + "P99Milliseconds": 4228.832115, + "MaxMilliseconds": 4233.3335 + }, + "PhaseLatencySummaries": { + "start": { + "SampleCount": 96, + "AverageMilliseconds": 63.14811041666666, + "P50Milliseconds": 62.105450000000005, + "P95Milliseconds": 122.10040000000001, + "P99Milliseconds": 131.04404, + "MaxMilliseconds": 134.4477 + }, + "signalPublish": { + "SampleCount": 96, + "AverageMilliseconds": 18.605395833333333, + "P50Milliseconds": 18.21255, + "P95Milliseconds": 25.991500000000002, + "P99Milliseconds": 28.005344999999995, + "MaxMilliseconds": 29.0569 + }, + "signalToCompletion": { + "SampleCount": 96, + "AverageMilliseconds": 3905.2602708333307, + "P50Milliseconds": 3905.5325, + "P95Milliseconds": 4007.863475, + "P99Milliseconds": 4014.24199, + "MaxMilliseconds": 4016.8201 + } + }, + "BaselineComparison": { + "Status": "Missing", + "BaselineJsonPath": null, + "ThroughputDeltaPercent": null, + "AverageLatencyDeltaPercent": null, + "P95LatencyDeltaPercent": null, + "MaxLatencyDeltaPercent": null + }, + "OracleMetrics": { + "InstanceName": "FREE", + "HostName": "0239876c971c", + "Version": "23.0.0.0.0", + "SysStatDeltas": { + "user commits": 384, + "user rollbacks": 24, + "session logical reads": 94329, + "db block gets": 5635, + "consistent gets": 88694, + "physical reads": 0, + "physical writes": 0, + "redo size": 1361772, + "parse count (total)": 532, + "execute count": 2579, + "bytes sent via SQL*Net to client": 1020811, + "bytes received via SQL*Net from client": 624681 + }, + "TimeModelDeltas": { + "DB time": 6436908, + "DB CPU": 2478448, + "sql execute elapsed time": 1563590, + "connection management call elapsed time": 0, + "PL/SQL execution elapsed time": 77360 + }, + "TopWaitDeltas": [ + { + "EventName": "log file sync", + "TotalWaits": 480, + "TimeWaitedMicroseconds": 3692491 + }, + { + "EventName": "resmgr:cpu quantum", + "TotalWaits": 319, + "TimeWaitedMicroseconds": 283186 + }, + { + "EventName": "row cache lock", + "TotalWaits": 42, + "TimeWaitedMicroseconds": 205821 + }, + { + "EventName": "SQL*Net message to client", + "TotalWaits": 3400, + "TimeWaitedMicroseconds": 94432 + }, + { + "EventName": "buffer busy waits", + "TotalWaits": 294, + "TimeWaitedMicroseconds": 72618 + }, + { + "EventName": "library cache: mutex X", + "TotalWaits": 21, + "TimeWaitedMicroseconds": 21944 + }, + { + "EventName": "row cache mutex", + "TotalWaits": 5, + "TimeWaitedMicroseconds": 21715 + }, + { + "EventName": "enq: TX - contention", + "TotalWaits": 3, + "TimeWaitedMicroseconds": 1188 + } + ] + }, + "DurationMilliseconds": 4575.9933, + "ThroughputPerSecond": 20.979051695726913 + } + ] +} diff --git a/docs/workflow/engine/10-oracle-performance-baseline-2026-03-17.md b/docs/workflow/engine/10-oracle-performance-baseline-2026-03-17.md new file mode 100644 index 000000000..631de5a04 --- /dev/null +++ b/docs/workflow/engine/10-oracle-performance-baseline-2026-03-17.md @@ -0,0 +1,200 @@ +# Oracle Performance Baseline 2026-03-17 + +## Purpose + +This document captures the current Oracle-backed load and performance baseline for the Serdica workflow engine. It is the reference point for later PostgreSQL and MongoDB backend comparisons. + +The durable machine-readable companion is [10-oracle-performance-baseline-2026-03-17.json](10-oracle-performance-baseline-2026-03-17.json). + +## Run Metadata + +- Date: `2026-03-17` +- Test command: + - integration performance suite filtered to `OracleAqPerformance` +- Suite result: + - `12/12` tests passed + - total wall-clock time: `2 m 40 s` +- Raw artifact directory: + - `TestResults/workflow-performance/` +- Oracle environment: + - Docker image: `gvenzl/oracle-free:23-slim` + - instance: `FREE` + - version: `23.0.0.0.0` + - AQ backend: Oracle AQ with pooled connections and retry-hardened setup + +## Scenario Summary + +| Scenario | Tier | Ops | Conc | Duration ms | Throughput/s | Avg ms | P95 ms | Max ms | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| `oracle-aq-signal-roundtrip-capacity-c1` | `WorkflowPerfCapacity` | 16 | 1 | 4752.27 | 3.37 | 4257.28 | 4336.38 | 4359.67 | +| `oracle-aq-signal-roundtrip-capacity-c4` | `WorkflowPerfCapacity` | 64 | 4 | 4205.24 | 15.22 | 3926.42 | 3988.33 | 3994.74 | +| `oracle-aq-signal-roundtrip-capacity-c8` | `WorkflowPerfCapacity` | 128 | 8 | 5998.88 | 21.34 | 5226.56 | 5561.22 | 5605.59 | +| `oracle-aq-signal-roundtrip-capacity-c16` | `WorkflowPerfCapacity` | 256 | 16 | 7523.47 | 34.03 | 6551.81 | 6710.05 | 6721.81 | +| `oracle-aq-signal-roundtrip-latency-serial` | `WorkflowPerfLatency` | 16 | 1 | 49755.52 | 0.32 | 3104.85 | 3165.04 | 3232.40 | +| `oracle-aq-bulstrad-quotation-confirm-convert-to-policy-nightly` | `WorkflowPerfNightly` | 12 | 4 | 6761.14 | 1.77 | 5679.63 | 6259.65 | 6276.32 | +| `oracle-aq-delayed-burst-nightly` | `WorkflowPerfNightly` | 48 | 1 | 4483.42 | 10.71 | 3908.13 | 3978.47 | 3991.75 | +| `oracle-aq-immediate-burst-nightly` | `WorkflowPerfNightly` | 120 | 1 | 2391.29 | 50.18 | 902.17 | 1179.59 | 1207.44 | +| `oracle-aq-synthetic-external-resume-nightly` | `WorkflowPerfNightly` | 36 | 8 | 6793.73 | 5.30 | 6238.80 | 6425.95 | 6466.75 | +| `oracle-aq-bulstrad-quote-or-apl-cancel-smoke` | `WorkflowPerfSmoke` | 10 | 4 | 507.79 | 19.69 | 28.54 | 40.05 | 42.93 | +| `oracle-aq-delayed-burst-smoke` | `WorkflowPerfSmoke` | 12 | 1 | 4202.91 | 2.86 | 4040.62 | 4083.70 | 4084.12 | +| `oracle-aq-immediate-burst-smoke` | `WorkflowPerfSmoke` | 24 | 1 | 421.48 | 56.94 | 205.87 | 209.90 | 210.16 | +| `oracle-aq-synthetic-external-resume-smoke` | `WorkflowPerfSmoke` | 12 | 4 | 3843.39 | 3.12 | 3644.91 | 3691.31 | 3696.92 | +| `oracle-aq-signal-roundtrip-soak` | `WorkflowPerfSoak` | 108 | 8 | 27620.16 | 3.91 | 4494.29 | 5589.33 | 5595.04 | +| `oracle-aq-signal-roundtrip-throughput-parallel` | `WorkflowPerfThroughput` | 96 | 16 | 4575.99 | 20.98 | 4142.13 | 4215.64 | 4233.33 | + +## Measurement Split + +The synthetic signal round-trip workload is now measured in three separate ways so the numbers are not conflated: + +- `oracle-aq-signal-roundtrip-latency-serial`: one workflow at a time, one signal worker, used as the single-instance latency baseline. +- `oracle-aq-signal-roundtrip-throughput-parallel`: `96` workflows, `16`-way workload concurrency, `8` signal workers, used as the steady-state throughput baseline. +- `oracle-aq-signal-roundtrip-capacity-c*`: batch-wave capacity ladder used to observe scaling and pressure points. + +This split matters because the old low `c1` figure was easy to misread. The useful baseline now is: + +- serial latency baseline: `3104.85 ms` average end-to-end per workflow +- steady throughput baseline: `20.98 ops/s` with `16` workload concurrency and `8` signal workers +- capacity `c1`: `3.37 ops/s`; this is now just the smallest batch-wave rung, not the headline latency number + +### Serial Latency Baseline + +| Phase | Avg ms | P95 ms | Max ms | +| --- | ---: | ---: | ---: | +| `start` | 25.14 | 41.92 | 45.27 | +| `signalPublish` | 16.57 | 31.39 | 50.72 | +| `signalToCompletion` | 3079.70 | 3128.56 | 3203.33 | + +Interpretation: + +- most of the serial latency is in `signalToCompletion`, not in start or signal publication +- start itself is cheap +- signal publication itself is also cheap + +### Steady Throughput Baseline + +| Phase | Avg ms | P95 ms | Max ms | +| --- | ---: | ---: | ---: | +| `start` | 63.15 | 122.10 | 134.45 | +| `signalPublish` | 18.61 | 25.99 | 29.06 | +| `signalToCompletion` | 3905.26 | 4007.86 | 4016.82 | + +Interpretation: + +- the engine sustained `20.98 ops/s` in a `96`-operation wave +- end-to-end average stayed at `4142.13 ms` +- start and signal publication remained small compared to the resume path + +## Oracle Observations + +### Dominant Waits + +- `log file sync` was the top wait in `14/15` scenario artifacts. Commit pressure is still the main Oracle-side cost center for this engine profile. +- The only scenario with a different top wait was the heavier Bulstrad nightly flow: + - `oracle-aq-bulstrad-quotation-confirm-convert-to-policy-nightly` -> `library cache lock` +- At higher concurrency the second-order waits become visible: + - `resmgr:cpu quantum` + - `row cache lock` + - `buffer busy waits` + +### Capacity Ladder + +| Scenario | Throughput/s | P95 ms | User Commits | Session Logical Reads | Redo Size | DB Time | DB CPU | Top Wait | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| `c1` | 3.37 | 4336.38 | 64 | 3609 | 232824 | 653630 | 403101 | `log file sync` | +| `c4` | 15.22 | 3988.33 | 256 | 19710 | 913884 | 1867747 | 1070601 | `log file sync` | +| `c8` | 21.34 | 5561.22 | 512 | 66375 | 1910412 | 14103899 | 2746786 | `log file sync` | +| `c16` | 34.03 | 6710.05 | 1024 | 229828 | 3796688 | 17605655 | 6083523 | `log file sync` | + +Interpretation: + +- The harness changes improved the ladder materially compared to the previous cut. +- `c1` moved to `3.37 ops/s` from the earlier `1.63 ops/s`, mostly because the harness no longer spends as much time in serial verifier tail behavior. +- `c16` reached `34.03 ops/s`, but it is also the first rung with clearly visible CPU scheduling and contention pressure. +- `c8` is still the last comfortable rung on this local Oracle Free setup. + +### Transport Baselines + +| Scenario | Throughput/s | User Commits | Session Logical Reads | Redo Size | Top Wait | +| --- | ---: | ---: | ---: | ---: | --- | +| `oracle-aq-immediate-burst-smoke` | 56.94 | 48 | 973 | 88700 | `log file sync` | +| `oracle-aq-immediate-burst-nightly` | 50.18 | 240 | 12426 | 451200 | `log file sync` | +| `oracle-aq-delayed-burst-smoke` | 2.86 | 24 | 566 | 52724 | `log file sync` | +| `oracle-aq-delayed-burst-nightly` | 10.71 | 96 | 3043 | 197696 | `log file sync` | + +Interpretation: + +- Immediate AQ transport remains much cheaper than full workflow resume. +- Delayed AQ transport is still dominated by the intentional delay window, not raw dequeue throughput. + +### Business Flow Baselines + +| Scenario | Throughput/s | Avg ms | User Commits | Session Logical Reads | Redo Size | Top Wait | +| --- | ---: | ---: | ---: | ---: | ---: | --- | +| `oracle-aq-bulstrad-quote-or-apl-cancel-smoke` | 19.69 | 28.54 | 10 | 3411 | 40748 | `log file sync` | +| `oracle-aq-bulstrad-quotation-confirm-convert-to-policy-nightly` | 1.77 | 5679.63 | 48 | 18562 | 505656 | `library cache lock` | + +Interpretation: + +- The short Bulstrad flow is still mostly transport-bound. +- The heavier `QuotationConfirm -> ConvertToPolicy` flow remains a useful real-workflow pressure baseline because it introduces parse and library pressure that the synthetic workloads do not. + +### Soak Baseline + +`oracle-aq-signal-roundtrip-soak` completed `108` operations at concurrency `8` with: + +- throughput: `3.91 ops/s` +- average latency: `4494.29 ms` +- P95 latency: `5589.33 ms` +- `0` failures +- `0` dead-lettered signals +- `0` runtime conflicts +- `0` stuck instances + +Oracle metrics for the soak run: + +- `user commits`: `432` +- `user rollbacks`: `54` +- `session logical reads`: `104711` +- `redo size`: `1535580` +- `DB time`: `15394405` +- `DB CPU`: `2680492` +- top waits: + - `log file sync`: `10904550 us` + - `resmgr:cpu quantum`: `1573185 us` + - `row cache lock`: `719739 us` + +## What Must Stay Constant For Future Backend Comparisons + +When PostgreSQL and MongoDB backends are benchmarked, keep these constant: + +- same scenario names +- same operation counts +- same concurrency levels +- same worker counts for signal drain +- same synthetic workflow definitions +- same Bulstrad workflow families +- same correctness assertions + +Compare these dimensions directly: + +- throughput per second +- latency average, P95, P99, and max +- phase latency summaries for start, signal publish, and signal-to-completion on the synthetic signal round-trip workload +- failures, dead letters, runtime conflicts, and stuck instances +- commit count analogs +- logical read or document/row read analogs +- redo or WAL/journal write analogs +- dominant waits, locks, or contention classes + +## First Sizing Note + +On this local Oracle Free baseline: + +- Oracle AQ immediate burst handling is comfortably above the small workflow tiers, but not at the earlier near-`100 ops/s` level on this latest run; the current nightly transport baseline is `50.18 ops/s`. +- The first clear saturation signal is still not transport dequeue itself, but commit pressure and then CPU scheduling pressure. +- The separated throughput baseline is the better reference for backend comparisons than the old low `c1` figure. +- `c8` remains the last comfortably scaling signal-roundtrip rung on this machine. +- `c16` is still correct and faster, but it is the first pressure rung, not the default deployment target. + +This is a baseline, not a production commitment. PostgreSQL and MongoDB backend work should reuse the same scenarios and produce the same summary tables before any architectural preference is declared. + diff --git a/docs/workflow/engine/11-postgres-performance-baseline-2026-03-17.json b/docs/workflow/engine/11-postgres-performance-baseline-2026-03-17.json new file mode 100644 index 000000000..6dc5c2198 --- /dev/null +++ b/docs/workflow/engine/11-postgres-performance-baseline-2026-03-17.json @@ -0,0 +1,213 @@ +{ + "Date": "2026-03-17", + "Workspace": "C:\\dev\\serdica-backend4", + "TestCommand": "dotnet test src/Serdica/Ablera.Serdica.Workflow/__Tests/Ablera.Serdica.Workflow.IntegrationTests/Ablera.Serdica.Workflow.IntegrationTests.csproj -c Release --no-build --filter \"FullyQualifiedName~PostgresPerformance\"", + "SuiteResult": { + "Passed": 11, + "Total": 11, + "Duration": "2 m 16 s" + }, + "RawArtifactDirectory": "src/Serdica/Ablera.Serdica.Workflow/__Tests/Ablera.Serdica.Workflow.IntegrationTests/bin/Release/net9.0/TestResults/workflow-performance/", + "PostgresEnvironment": { + "DockerImage": "postgres:16-alpine", + "Database": "workflow", + "Version": "PostgreSQL 16.13", + "Backend": "Durable queue tables plus LISTEN/NOTIFY wake hints" + }, + "MeasurementViews": { + "SerialLatencyScenario": "postgres-signal-roundtrip-latency-serial", + "SteadyThroughputScenario": "postgres-signal-roundtrip-throughput-parallel", + "CapacityScenarioPrefix": "postgres-signal-roundtrip-capacity-" + }, + "Notes": { + "TopWaitCounts": [ + { + "Name": "Client:ClientRead", + "Count": 13 + }, + { + "Name": "", + "Count": 1 + } + ], + "Interpretation": [ + "Serial latency baseline and steady throughput baseline are separated from the capacity ladder.", + "The capacity ladder still scales through c16 on this local PostgreSQL Docker setup.", + "Immediate queue handling remains much cheaper than full workflow resume.", + "The dominant observed backend state is client read waiting, not an obvious storage stall." + ] + }, + "Scenarios": [ + { + "ScenarioName": "postgres-signal-roundtrip-capacity-c1", + "Tier": "WorkflowPerfCapacity", + "OperationCount": 16, + "Concurrency": 1, + "DurationMilliseconds": 3895.54, + "ThroughputPerSecond": 4.11, + "AverageLatencyMilliseconds": 3738.08, + "P95LatencyMilliseconds": 3762.51, + "MaxLatencyMilliseconds": 3771.10, + "TopWait": "Client:ClientRead", + "CounterDeltas": { + "xact_commit": 251, + "xact_rollback": 7, + "blks_hit": 1654, + "blks_read": 24, + "tup_inserted": 48, + "tup_updated": 48, + "tup_deleted": 16 + } + }, + { + "ScenarioName": "postgres-signal-roundtrip-capacity-c4", + "Tier": "WorkflowPerfCapacity", + "OperationCount": 64, + "Concurrency": 4, + "DurationMilliseconds": 3700.99, + "ThroughputPerSecond": 17.29, + "AverageLatencyMilliseconds": 3577.49, + "P95LatencyMilliseconds": 3583.70, + "MaxLatencyMilliseconds": 3584.43, + "TopWait": "Client:ClientRead", + "CounterDeltas": { + "xact_commit": 1080, + "xact_rollback": 21, + "blks_hit": 7084, + "blks_read": 1, + "tup_inserted": 192, + "tup_updated": 192, + "tup_deleted": 64 + } + }, + { + "ScenarioName": "postgres-signal-roundtrip-capacity-c8", + "Tier": "WorkflowPerfCapacity", + "OperationCount": 128, + "Concurrency": 8, + "DurationMilliseconds": 3853.89, + "ThroughputPerSecond": 33.21, + "AverageLatencyMilliseconds": 3713.31, + "P95LatencyMilliseconds": 3718.66, + "MaxLatencyMilliseconds": 3719.34, + "TopWait": "Client:ClientRead", + "CounterDeltas": { + "xact_commit": 2348, + "xact_rollback": 44, + "blks_hit": 17069, + "blks_read": 0, + "tup_inserted": 384, + "tup_updated": 384, + "tup_deleted": 128 + } + }, + { + "ScenarioName": "postgres-signal-roundtrip-capacity-c16", + "Tier": "WorkflowPerfCapacity", + "OperationCount": 256, + "Concurrency": 16, + "DurationMilliseconds": 4488.07, + "ThroughputPerSecond": 57.04, + "AverageLatencyMilliseconds": 4251.48, + "P95LatencyMilliseconds": 4287.87, + "MaxLatencyMilliseconds": 4294.09, + "TopWait": "Client:ClientRead", + "CounterDeltas": { + "xact_commit": 4536, + "xact_rollback": 48, + "blks_hit": 40443, + "blks_read": 0, + "tup_inserted": 768, + "tup_updated": 768, + "tup_deleted": 256 + } + }, + { + "ScenarioName": "postgres-signal-roundtrip-latency-serial", + "Tier": "WorkflowPerfLatency", + "OperationCount": 16, + "Concurrency": 1, + "DurationMilliseconds": 49290.47, + "ThroughputPerSecond": 0.32, + "AverageLatencyMilliseconds": 3079.33, + "P95LatencyMilliseconds": 3094.94, + "MaxLatencyMilliseconds": 3101.71, + "PhaseLatencySummaries": { + "start": { + "AverageMilliseconds": 6.12, + "P95Milliseconds": 9.29, + "MaxMilliseconds": 11.26 + }, + "signalPublish": { + "AverageMilliseconds": 5.63, + "P95Milliseconds": 6.82, + "MaxMilliseconds": 7.53 + }, + "signalToCompletion": { + "AverageMilliseconds": 3073.20, + "P95Milliseconds": 3086.59, + "MaxMilliseconds": 3090.44 + } + } + }, + { + "ScenarioName": "postgres-signal-roundtrip-throughput-parallel", + "Tier": "WorkflowPerfThroughput", + "OperationCount": 96, + "Concurrency": 16, + "DurationMilliseconds": 3729.17, + "ThroughputPerSecond": 25.74, + "AverageLatencyMilliseconds": 3603.54, + "P95LatencyMilliseconds": 3635.59, + "MaxLatencyMilliseconds": 3649.96, + "TopWait": "Client:ClientRead", + "CounterDeltas": { + "xact_commit": 1502, + "xact_rollback": 38, + "blks_hit": 21978, + "blks_read": 24, + "tup_inserted": 288, + "tup_updated": 288, + "tup_deleted": 96 + }, + "PhaseLatencySummaries": { + "start": { + "AverageMilliseconds": 16.21, + "P95Milliseconds": 40.31, + "MaxMilliseconds": 47.02 + }, + "signalPublish": { + "AverageMilliseconds": 18.11, + "P95Milliseconds": 23.62, + "MaxMilliseconds": 28.41 + }, + "signalToCompletion": { + "AverageMilliseconds": 3504.24, + "P95Milliseconds": 3530.38, + "MaxMilliseconds": 3531.14 + } + } + }, + { + "ScenarioName": "postgres-signal-roundtrip-soak", + "Tier": "WorkflowPerfSoak", + "OperationCount": 108, + "Concurrency": 8, + "DurationMilliseconds": 25121.68, + "ThroughputPerSecond": 4.30, + "AverageLatencyMilliseconds": 4164.52, + "P95LatencyMilliseconds": 4208.42, + "MaxLatencyMilliseconds": 4209.96, + "TopWait": "Client:ClientRead", + "CounterDeltas": { + "xact_commit": 3313, + "xact_rollback": 352, + "blks_hit": 26548, + "blks_read": 269, + "tup_inserted": 774, + "tup_updated": 339, + "tup_deleted": 108 + } + } + ] +} diff --git a/docs/workflow/engine/11-postgres-performance-baseline-2026-03-17.md b/docs/workflow/engine/11-postgres-performance-baseline-2026-03-17.md new file mode 100644 index 000000000..a953a3a2b --- /dev/null +++ b/docs/workflow/engine/11-postgres-performance-baseline-2026-03-17.md @@ -0,0 +1,191 @@ +# PostgreSQL Performance Baseline 2026-03-17 + +## Purpose + +This document captures the current PostgreSQL-backed load and performance baseline for the Serdica workflow engine. It is the reference point for later MongoDB backend comparisons and the final three-backend decision pack. + +The durable machine-readable companion is [11-postgres-performance-baseline-2026-03-17.json](11-postgres-performance-baseline-2026-03-17.json). + +## Run Metadata + +- Date: `2026-03-17` +- Test command: + - integration performance suite filtered to `PostgresPerformance` +- Suite result: + - `11/11` tests passed + - total wall-clock time: `2 m 16 s` +- Raw artifact directory: + - `TestResults/workflow-performance/` +- PostgreSQL environment: + - Docker image: `postgres:16-alpine` + - database: `workflow` + - version: `PostgreSQL 16.13` + - backend: durable queue tables plus `LISTEN/NOTIFY` wake hints + +## Scenario Summary + +| Scenario | Tier | Ops | Conc | Duration ms | Throughput/s | Avg ms | P95 ms | Max ms | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| `postgres-signal-roundtrip-capacity-c1` | `WorkflowPerfCapacity` | 16 | 1 | 3895.54 | 4.11 | 3738.08 | 3762.51 | 3771.10 | +| `postgres-signal-roundtrip-capacity-c4` | `WorkflowPerfCapacity` | 64 | 4 | 3700.99 | 17.29 | 3577.49 | 3583.70 | 3584.43 | +| `postgres-signal-roundtrip-capacity-c8` | `WorkflowPerfCapacity` | 128 | 8 | 3853.89 | 33.21 | 3713.31 | 3718.66 | 3719.34 | +| `postgres-signal-roundtrip-capacity-c16` | `WorkflowPerfCapacity` | 256 | 16 | 4488.07 | 57.04 | 4251.48 | 4287.87 | 4294.09 | +| `postgres-signal-roundtrip-latency-serial` | `WorkflowPerfLatency` | 16 | 1 | 49290.47 | 0.32 | 3079.33 | 3094.94 | 3101.71 | +| `postgres-bulstrad-quotation-confirm-convert-to-policy-nightly` | `WorkflowPerfNightly` | 12 | 4 | 3598.64 | 3.33 | 3478.52 | 3500.76 | 3503.73 | +| `postgres-delayed-burst-nightly` | `WorkflowPerfNightly` | 48 | 1 | 2449.25 | 19.60 | 2096.34 | 2152.50 | 2157.39 | +| `postgres-immediate-burst-nightly` | `WorkflowPerfNightly` | 120 | 1 | 1711.87 | 70.10 | 849.78 | 1012.13 | 1030.98 | +| `postgres-synthetic-external-resume-nightly` | `WorkflowPerfNightly` | 36 | 8 | 4162.56 | 8.65 | 4026.50 | 4048.09 | 4049.91 | +| `postgres-bulstrad-quote-or-apl-cancel-smoke` | `WorkflowPerfSmoke` | 10 | 4 | 166.99 | 59.88 | 13.51 | 23.87 | 26.35 | +| `postgres-delayed-burst-smoke` | `WorkflowPerfSmoke` | 12 | 1 | 2146.89 | 5.59 | 2032.67 | 2050.20 | 2051.30 | +| `postgres-immediate-burst-smoke` | `WorkflowPerfSmoke` | 24 | 1 | 341.84 | 70.21 | 176.19 | 197.25 | 197.91 | +| `postgres-signal-roundtrip-soak` | `WorkflowPerfSoak` | 108 | 8 | 25121.68 | 4.30 | 4164.52 | 4208.42 | 4209.96 | +| `postgres-signal-roundtrip-throughput-parallel` | `WorkflowPerfThroughput` | 96 | 16 | 3729.17 | 25.74 | 3603.54 | 3635.59 | 3649.96 | + +## Measurement Split + +The synthetic signal round-trip workload is measured in three separate ways: + +- `postgres-signal-roundtrip-latency-serial`: one workflow at a time, one signal worker, used as the single-instance latency baseline. +- `postgres-signal-roundtrip-throughput-parallel`: `96` workflows, `16`-way workload concurrency, `8` signal workers, used as the steady-state throughput baseline. +- `postgres-signal-roundtrip-capacity-c*`: batch-wave capacity ladder used to observe scaling and pressure points. + +The useful PostgreSQL baseline is: + +- serial latency baseline: `3079.33 ms` average end-to-end per workflow +- steady throughput baseline: `25.74 ops/s` with `16` workload concurrency and `8` signal workers +- capacity `c1`: `4.11 ops/s`; this is only the smallest batch-wave rung + +### Serial Latency Baseline + +| Phase | Avg ms | P95 ms | Max ms | +| --- | ---: | ---: | ---: | +| `start` | 6.12 | 9.29 | 11.26 | +| `signalPublish` | 5.63 | 6.82 | 7.53 | +| `signalToCompletion` | 3073.20 | 3086.59 | 3090.44 | + +Interpretation: + +- almost all serial latency is in `signalToCompletion` +- workflow start is very cheap on this backend +- external signal publication is also cheap + +### Steady Throughput Baseline + +| Phase | Avg ms | P95 ms | Max ms | +| --- | ---: | ---: | ---: | +| `start` | 16.21 | 40.31 | 47.02 | +| `signalPublish` | 18.11 | 23.62 | 28.41 | +| `signalToCompletion` | 3504.24 | 3530.38 | 3531.14 | + +Interpretation: + +- the engine sustained `25.74 ops/s` in a `96`-operation wave +- end-to-end average stayed at `3603.54 ms` +- start and signal publication remained small compared to the resume path + +## PostgreSQL Observations + +### Dominant Waits + +- `Client:ClientRead` was the top observed wait class in `13/14` scenario artifacts. +- The serial latency scenario had no distinct competing wait class because the measurement ran with effectively no backend concurrency. +- On this local PostgreSQL profile the wake-up path is not the visible bottleneck; the dominant observed state is clients waiting on the next command while the engine completes work in short transactions. + +### Capacity Ladder + +| Scenario | Throughput/s | P95 ms | Xact Commits | Buffer Hits | Buffer Reads | Tuples Inserted | Tuples Updated | Tuples Deleted | Top Wait | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| `c1` | 4.11 | 3762.51 | 251 | 1654 | 24 | 48 | 48 | 16 | `Client:ClientRead` | +| `c4` | 17.29 | 3583.70 | 1080 | 7084 | 1 | 192 | 192 | 64 | `Client:ClientRead` | +| `c8` | 33.21 | 3718.66 | 2348 | 17069 | 0 | 384 | 384 | 128 | `Client:ClientRead` | +| `c16` | 57.04 | 4287.87 | 4536 | 40443 | 0 | 768 | 768 | 256 | `Client:ClientRead` | + +Interpretation: + +- the capacity ladder scales more smoothly than the Oracle baseline on the same local machine +- `c16` is the fastest tested rung and does not yet show a hard cliff +- the next meaningful PostgreSQL characterization step should test above `c16` before declaring a saturation boundary + +### Transport Baselines + +| Scenario | Throughput/s | Xact Commits | Buffer Hits | Buffer Reads | Tuples Inserted | Tuples Updated | Top Wait | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| `postgres-immediate-burst-nightly` | 70.10 | 801 | 13207 | 4 | 570 | 162 | `Client:ClientRead` | +| `postgres-delayed-burst-nightly` | 19.60 | 269 | 11472 | 3 | 498 | 33 | `Client:ClientRead` | + +Interpretation: + +- immediate transport remains much cheaper than full workflow resume +- delayed transport is still dominated by the intentional delay window, not by raw dequeue speed +- the very short smoke transport runs are useful for end-to-end timing, but they are too brief to rely on as the primary PostgreSQL stat sample + +### Business Flow Baselines + +| Scenario | Throughput/s | Avg ms | Xact Commits | Buffer Hits | Buffer Reads | Tuples Inserted | Tuples Updated | Top Wait | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| `postgres-bulstrad-quote-or-apl-cancel-smoke` | 59.88 | 13.51 | 3 | 93 | 0 | 0 | 0 | `Client:ClientRead` | +| `postgres-bulstrad-quotation-confirm-convert-to-policy-nightly` | 3.33 | 3478.52 | 236 | 12028 | 270 | 546 | 75 | `Client:ClientRead` | + +Interpretation: + +- the short Bulstrad flow is still mostly transport and orchestration overhead +- the heavier `QuotationConfirm -> ConvertToPolicy` flow is a better real-workload pressure baseline because it exercises deeper projection and signal traffic + +### Soak Baseline + +`postgres-signal-roundtrip-soak` completed `108` operations at concurrency `8` with: + +- throughput: `4.30 ops/s` +- average latency: `4164.52 ms` +- P95 latency: `4208.42 ms` +- `0` failures +- `0` dead-lettered signals +- `0` runtime conflicts +- `0` stuck instances + +PostgreSQL metrics for the soak run: + +- `xact_commit`: `3313` +- `xact_rollback`: `352` +- `blks_hit`: `26548` +- `blks_read`: `269` +- `tup_inserted`: `774` +- `tup_updated`: `339` +- `tup_deleted`: `108` +- top wait: + - `Client:ClientRead` + +## What Must Stay Constant For Future Backend Comparisons + +When MongoDB is benchmarked and the final Oracle/PostgreSQL/MongoDB comparison is produced, keep these constant: + +- same scenario names +- same operation counts +- same concurrency levels +- same worker counts for signal drain +- same synthetic workflow definitions +- same Bulstrad workflow families +- same correctness assertions + +Compare these dimensions directly: + +- throughput per second +- latency average, P95, P99, and max +- phase latency summaries for start, signal publish, and signal-to-completion on the synthetic signal round-trip workload +- failures, dead letters, runtime conflicts, and stuck instances +- commit count analogs +- row, tuple, or document movement analogs +- read-hit or read-amplification analogs +- dominant waits, locks, or wake-path contention classes + +## First Sizing Note + +On this local PostgreSQL baseline: + +- immediate queue burst handling is comfortably above the small workflow tiers; the current nightly transport baseline is `70.10 ops/s` +- the separated steady throughput baseline is `25.74 ops/s`, ahead of the current Oracle baseline on the same synthetic workflow profile +- the ladder through `c16` still looks healthy and does not yet expose a sharp pressure rung +- the dominant observed backend state is client read waiting, which suggests the next tuning conversation should focus on queue claim cadence, notification wake-ups, and transaction shape rather than on an obvious storage stall + +This is a baseline, not a production commitment. MongoDB should now reuse the same scenarios and produce the same summary tables before any backend recommendation is declared. + diff --git a/docs/workflow/engine/12-mongo-performance-baseline-2026-03-17.json b/docs/workflow/engine/12-mongo-performance-baseline-2026-03-17.json new file mode 100644 index 000000000..dda678bd6 --- /dev/null +++ b/docs/workflow/engine/12-mongo-performance-baseline-2026-03-17.json @@ -0,0 +1,211 @@ +{ + "Date": "2026-03-17", + "Workspace": "C:\\dev\\serdica-backend4", + "TestCommand": "dotnet test src/Serdica/Ablera.Serdica.Workflow/__Tests/Ablera.Serdica.Workflow.IntegrationTests/Ablera.Serdica.Workflow.IntegrationTests.csproj -c Release --no-build --filter \"FullyQualifiedName~MongoPerformance\"", + "SuiteResult": { + "Passed": 14, + "Total": 14, + "Duration": "48 s" + }, + "RawArtifactDirectory": "src/Serdica/Ablera.Serdica.Workflow/__Tests/Ablera.Serdica.Workflow.IntegrationTests/bin/Release/net9.0/TestResults/workflow-performance/", + "MongoEnvironment": { + "DockerImage": "mongo:7.0", + "Topology": "single-node replica set", + "Version": "7.0.30", + "Backend": "Durable collections plus change-stream wake hints" + }, + "MeasurementViews": { + "SerialLatencyScenario": "mongo-signal-roundtrip-latency-serial", + "SteadyThroughputScenario": "mongo-signal-roundtrip-throughput-parallel", + "CapacityScenarioPrefix": "mongo-signal-roundtrip-capacity-" + }, + "Notes": { + "TopWaitCounts": [ + { + "Name": "(none)", + "Count": 14 + } + ], + "Interpretation": [ + "Serial latency baseline and steady throughput baseline are separated from the capacity ladder.", + "Mongo exposed two backend-correctness issues during the first performance pass: bounded idle receive and explicit collection bootstrap.", + "Mongo scales very strongly through c8 on this local replica-set baseline.", + "c16 is the first visible pressure rung because latency rises materially even though throughput still improves." + ] + }, + "Scenarios": [ + { + "ScenarioName": "mongo-signal-roundtrip-capacity-c1", + "Tier": "WorkflowPerfCapacity", + "OperationCount": 16, + "Concurrency": 1, + "DurationMilliseconds": 2259.99, + "ThroughputPerSecond": 7.08, + "AverageLatencyMilliseconds": 1394.99, + "P95LatencyMilliseconds": 1576.55, + "MaxLatencyMilliseconds": 2063.72, + "CounterDeltas": { + "opcounters.command": 183, + "opcounters.insert": 48, + "opcounters.update": 48, + "opcounters.delete": 16, + "metrics.document.returned": 80, + "metrics.document.inserted": 48, + "metrics.document.updated": 48, + "metrics.document.deleted": 16 + } + }, + { + "ScenarioName": "mongo-signal-roundtrip-capacity-c4", + "Tier": "WorkflowPerfCapacity", + "OperationCount": 64, + "Concurrency": 4, + "DurationMilliseconds": 1668.99, + "ThroughputPerSecond": 38.35, + "AverageLatencyMilliseconds": 1244.81, + "P95LatencyMilliseconds": 1472.61, + "MaxLatencyMilliseconds": 1527.26, + "CounterDeltas": { + "opcounters.command": 684, + "opcounters.insert": 192, + "opcounters.update": 192, + "opcounters.delete": 64, + "metrics.document.returned": 320, + "metrics.document.inserted": 192, + "metrics.document.updated": 192, + "metrics.document.deleted": 64 + } + }, + { + "ScenarioName": "mongo-signal-roundtrip-capacity-c8", + "Tier": "WorkflowPerfCapacity", + "OperationCount": 128, + "Concurrency": 8, + "DurationMilliseconds": 1938.12, + "ThroughputPerSecond": 66.04, + "AverageLatencyMilliseconds": 1477.49, + "P95LatencyMilliseconds": 1743.52, + "MaxLatencyMilliseconds": 1757.88, + "CounterDeltas": { + "opcounters.command": 1349, + "opcounters.insert": 384, + "opcounters.update": 384, + "opcounters.delete": 128, + "metrics.document.returned": 640, + "metrics.document.inserted": 384, + "metrics.document.updated": 384, + "metrics.document.deleted": 128 + } + }, + { + "ScenarioName": "mongo-signal-roundtrip-capacity-c16", + "Tier": "WorkflowPerfCapacity", + "OperationCount": 256, + "Concurrency": 16, + "DurationMilliseconds": 3728.88, + "ThroughputPerSecond": 68.65, + "AverageLatencyMilliseconds": 3203.94, + "P95LatencyMilliseconds": 3507.95, + "MaxLatencyMilliseconds": 3527.96, + "CounterDeltas": { + "opcounters.command": 2515, + "opcounters.insert": 768, + "opcounters.update": 768, + "opcounters.delete": 256, + "metrics.document.returned": 1280, + "metrics.document.inserted": 768, + "metrics.document.updated": 768, + "metrics.document.deleted": 256 + } + }, + { + "ScenarioName": "mongo-signal-roundtrip-latency-serial", + "Tier": "WorkflowPerfLatency", + "OperationCount": 16, + "Concurrency": 1, + "DurationMilliseconds": 1675.77, + "ThroughputPerSecond": 9.55, + "AverageLatencyMilliseconds": 97.88, + "P95LatencyMilliseconds": 149.20, + "MaxLatencyMilliseconds": 324.02, + "PhaseLatencySummaries": { + "start": { + "AverageMilliseconds": 26.34, + "P95Milliseconds": 79.35, + "MaxMilliseconds": 251.36 + }, + "signalPublish": { + "AverageMilliseconds": 8.17, + "P95Milliseconds": 10.75, + "MaxMilliseconds": 12.17 + }, + "signalToCompletion": { + "AverageMilliseconds": 71.54, + "P95Milliseconds": 77.94, + "MaxMilliseconds": 79.48 + } + } + }, + { + "ScenarioName": "mongo-signal-roundtrip-throughput-parallel", + "Tier": "WorkflowPerfThroughput", + "OperationCount": 96, + "Concurrency": 16, + "DurationMilliseconds": 1258.48, + "ThroughputPerSecond": 76.28, + "AverageLatencyMilliseconds": 1110.94, + "P95LatencyMilliseconds": 1121.22, + "MaxLatencyMilliseconds": 1127.11, + "PhaseLatencySummaries": { + "start": { + "AverageMilliseconds": 20.88, + "P95Milliseconds": 28.64, + "MaxMilliseconds": 33.67 + }, + "signalPublish": { + "AverageMilliseconds": 16.01, + "P95Milliseconds": 20.90, + "MaxMilliseconds": 22.71 + }, + "signalToCompletion": { + "AverageMilliseconds": 988.88, + "P95Milliseconds": 1000.12, + "MaxMilliseconds": 1004.92 + } + }, + "CounterDeltas": { + "opcounters.command": 1049, + "opcounters.insert": 288, + "opcounters.update": 288, + "opcounters.delete": 96, + "metrics.document.returned": 480, + "metrics.document.inserted": 288, + "metrics.document.updated": 288, + "metrics.document.deleted": 96 + } + }, + { + "ScenarioName": "mongo-signal-roundtrip-soak", + "Tier": "WorkflowPerfSoak", + "OperationCount": 108, + "Concurrency": 8, + "DurationMilliseconds": 2267.91, + "ThroughputPerSecond": 47.62, + "AverageLatencyMilliseconds": 322.40, + "P95LatencyMilliseconds": 550.50, + "MaxLatencyMilliseconds": 572.73, + "CounterDeltas": { + "opcounters.command": 2264, + "opcounters.insert": 324, + "opcounters.update": 324, + "opcounters.delete": 108, + "metrics.document.returned": 540, + "metrics.document.inserted": 324, + "metrics.document.updated": 324, + "metrics.document.deleted": 108, + "transactions.totalStarted": 216, + "transactions.totalCommitted": 216 + } + } + ] +} diff --git a/docs/workflow/engine/12-mongo-performance-baseline-2026-03-17.md b/docs/workflow/engine/12-mongo-performance-baseline-2026-03-17.md new file mode 100644 index 000000000..187220c24 --- /dev/null +++ b/docs/workflow/engine/12-mongo-performance-baseline-2026-03-17.md @@ -0,0 +1,195 @@ +# MongoDB Performance Baseline 2026-03-17 + +## Purpose + +This document captures the current MongoDB-backed load and performance baseline for the Serdica workflow engine. It completes the per-backend baseline set that will feed the final three-backend comparison. + +The durable machine-readable companion is [12-mongo-performance-baseline-2026-03-17.json](12-mongo-performance-baseline-2026-03-17.json). + +## Run Metadata + +- Date: `2026-03-17` +- Test command: + - integration performance suite filtered to `MongoPerformance` +- Suite result: + - `14/14` tests passed + - total wall-clock time: `48 s` +- Raw artifact directory: + - `TestResults/workflow-performance/` +- MongoDB environment: + - Docker image: `mongo:7.0` + - topology: single-node replica set + - version: `7.0.30` + - backend: durable collections plus change-stream wake hints + +## Scenario Summary + +| Scenario | Tier | Ops | Conc | Duration ms | Throughput/s | Avg ms | P95 ms | Max ms | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| `mongo-signal-roundtrip-capacity-c1` | `WorkflowPerfCapacity` | 16 | 1 | 2259.99 | 7.08 | 1394.99 | 1576.55 | 2063.72 | +| `mongo-signal-roundtrip-capacity-c4` | `WorkflowPerfCapacity` | 64 | 4 | 1668.99 | 38.35 | 1244.81 | 1472.61 | 1527.26 | +| `mongo-signal-roundtrip-capacity-c8` | `WorkflowPerfCapacity` | 128 | 8 | 1938.12 | 66.04 | 1477.49 | 1743.52 | 1757.88 | +| `mongo-signal-roundtrip-capacity-c16` | `WorkflowPerfCapacity` | 256 | 16 | 3728.88 | 68.65 | 3203.94 | 3507.95 | 3527.96 | +| `mongo-signal-roundtrip-latency-serial` | `WorkflowPerfLatency` | 16 | 1 | 1675.77 | 9.55 | 97.88 | 149.20 | 324.02 | +| `mongo-bulstrad-quotation-confirm-convert-to-policy-nightly` | `WorkflowPerfNightly` | 12 | 4 | 1108.42 | 10.83 | 790.30 | 947.21 | 963.16 | +| `mongo-delayed-burst-nightly` | `WorkflowPerfNightly` | 48 | 1 | 2881.66 | 16.66 | 2142.14 | 2265.15 | 2281.04 | +| `mongo-immediate-burst-nightly` | `WorkflowPerfNightly` | 120 | 1 | 2598.57 | 46.18 | 1148.06 | 1530.49 | 1575.98 | +| `mongo-synthetic-external-resume-nightly` | `WorkflowPerfNightly` | 36 | 8 | 976.73 | 36.86 | 633.82 | 770.10 | 772.71 | +| `mongo-bulstrad-quote-or-apl-cancel-smoke` | `WorkflowPerfSmoke` | 10 | 4 | 425.81 | 23.48 | 124.35 | 294.76 | 295.32 | +| `mongo-delayed-burst-smoke` | `WorkflowPerfSmoke` | 12 | 1 | 2416.23 | 4.97 | 2040.30 | 2079.79 | 2084.03 | +| `mongo-immediate-burst-smoke` | `WorkflowPerfSmoke` | 24 | 1 | 747.36 | 32.11 | 264.14 | 339.42 | 400.99 | +| `mongo-signal-roundtrip-soak` | `WorkflowPerfSoak` | 108 | 8 | 2267.91 | 47.62 | 322.40 | 550.50 | 572.73 | +| `mongo-signal-roundtrip-throughput-parallel` | `WorkflowPerfThroughput` | 96 | 16 | 1258.48 | 76.28 | 1110.94 | 1121.22 | 1127.11 | + +## Measurement Split + +The synthetic signal round-trip workload is measured in three separate ways: + +- `mongo-signal-roundtrip-latency-serial`: one workflow at a time, one signal worker, used as the single-instance latency baseline. +- `mongo-signal-roundtrip-throughput-parallel`: `96` workflows, `16`-way workload concurrency, `8` signal workers, used as the steady-state throughput baseline. +- `mongo-signal-roundtrip-capacity-c*`: batch-wave capacity ladder used to observe scaling and pressure points. + +The useful MongoDB baseline is: + +- serial latency baseline: `97.88 ms` average end-to-end per workflow +- steady throughput baseline: `76.28 ops/s` with `16` workload concurrency and `8` signal workers +- capacity `c1`: `7.08 ops/s`; this is only the smallest batch-wave rung + +### Serial Latency Baseline + +| Phase | Avg ms | P95 ms | Max ms | +| --- | ---: | ---: | ---: | +| `start` | 26.34 | 79.35 | 251.36 | +| `signalPublish` | 8.17 | 10.75 | 12.17 | +| `signalToCompletion` | 71.54 | 77.94 | 79.48 | + +Interpretation: + +- serial end-to-end latency is far lower than the Oracle and PostgreSQL baselines on this local setup +- most of the work remains in signal-to-completion, but the absolute time is much smaller +- workflow start is still the most variable of the three measured phases + +### Steady Throughput Baseline + +| Phase | Avg ms | P95 ms | Max ms | +| --- | ---: | ---: | ---: | +| `start` | 20.88 | 28.64 | 33.67 | +| `signalPublish` | 16.01 | 20.90 | 22.71 | +| `signalToCompletion` | 988.88 | 1000.12 | 1004.92 | + +Interpretation: + +- the engine sustained `76.28 ops/s` in a `96`-operation wave +- end-to-end average stayed at `1110.94 ms` +- the dominant cost is still resume processing, but Mongo remains materially faster on this synthetic profile + +## MongoDB Observations + +### Dominant Waits + +- no durable current-op contention class dominated these runs; every scenario finished without a stable top wait entry +- this means the current Mongo baseline should be read primarily through normalized workflow metrics and the Mongo-specific counter set, not through a wait-event headline +- the backend bug exposed by the first perf pass was not storage contention; it was correctness: + - empty-queue receive had to become bounded + - collection bootstrap had to be explicit before transactional concurrency + +### Capacity Ladder + +| Scenario | Throughput/s | P95 ms | Commands | Inserts | Updates | Deletes | Docs Returned | Docs Inserted | Docs Updated | Docs Deleted | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| `c1` | 7.08 | 1576.55 | 183 | 48 | 48 | 16 | 80 | 48 | 48 | 16 | +| `c4` | 38.35 | 1472.61 | 684 | 192 | 192 | 64 | 320 | 192 | 192 | 64 | +| `c8` | 66.04 | 1743.52 | 1349 | 384 | 384 | 128 | 640 | 384 | 384 | 128 | +| `c16` | 68.65 | 3507.95 | 2515 | 768 | 768 | 256 | 1280 | 768 | 768 | 256 | + +Interpretation: + +- Mongo scales very aggressively through `c8` +- `c16` is still the fastest rung, but it is also where latency expands sharply relative to `c8` +- the first visible pressure point is therefore `c16`, even though throughput still rises slightly + +### Transport Baselines + +| Scenario | Throughput/s | Commands | Inserts | Deletes | Network In | Network Out | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| `mongo-immediate-burst-nightly` | 46.18 | 379 | 120 | 120 | 277307 | 296277 | +| `mongo-delayed-burst-nightly` | 16.66 | 1052 | 48 | 48 | 507607 | 450004 | + +Interpretation: + +- immediate transport is still much cheaper than full workflow resume +- delayed transport carries more command and network chatter because the wake path repeatedly checks due work through the change-stream plus due-time model + +### Business Flow Baselines + +| Scenario | Throughput/s | Avg ms | Commands | Queries | Inserts | Updates | Deletes | Tx Started | Tx Committed | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| `mongo-bulstrad-quote-or-apl-cancel-smoke` | 23.48 | 124.35 | 54 | 45 | 20 | 0 | 0 | 10 | 10 | +| `mongo-bulstrad-quotation-confirm-convert-to-policy-nightly` | 10.83 | 790.30 | 189 | 151 | 96 | 48 | 12 | 36 | 36 | + +Interpretation: + +- the short Bulstrad flow is still cheap enough that transport and projection movement dominate +- the heavier `QuotationConfirm -> ConvertToPolicy -> PdfGenerator` path stays comfortably sub-second on this local Mongo baseline + +### Soak Baseline + +`mongo-signal-roundtrip-soak` completed `108` operations at concurrency `8` with: + +- throughput: `47.62 ops/s` +- average latency: `322.40 ms` +- P95 latency: `550.50 ms` +- `0` failures +- `0` dead-lettered signals +- `0` runtime conflicts +- `0` stuck instances + +MongoDB metrics for the soak run: + +- `opcounters.command`: `2264` +- `opcounters.insert`: `324` +- `opcounters.update`: `324` +- `opcounters.delete`: `108` +- `metrics.document.returned`: `540` +- `metrics.document.inserted`: `324` +- `metrics.document.updated`: `324` +- `metrics.document.deleted`: `108` +- `transactions.totalStarted`: `216` +- `transactions.totalCommitted`: `216` + +## What Must Stay Constant For Final Backend Comparison + +When the final Oracle/PostgreSQL/MongoDB comparison is produced, keep these constant: + +- same scenario names +- same operation counts +- same concurrency levels +- same worker counts for signal drain +- same synthetic workflow definitions +- same Bulstrad workflow families +- same correctness assertions + +Compare these dimensions directly: + +- throughput per second +- latency average, P95, P99, and max +- phase latency summaries for start, signal publish, and signal-to-completion on the synthetic signal round-trip workload +- failures, dead letters, runtime conflicts, and stuck instances +- commit, transaction, or mutation count analogs +- row, tuple, or document movement analogs +- read, network, and wake-path cost analogs +- dominant waits, locks, or contention classes when the backend exposes them clearly + +## First Sizing Note + +On this local MongoDB baseline: + +- Mongo is the fastest of the three backends on the synthetic signal round-trip workloads measured so far +- the biggest correctness findings came from backend behavior, not raw throughput: + - bounded empty-queue receive + - explicit collection bootstrap before transactional concurrency +- `c8` is the last clearly comfortable capacity rung +- `c16` is the first rung where latency growth becomes visible, even though throughput still increases slightly + +This is a baseline, not a production commitment. The final recommendation still needs the explicit three-backend comparison pack using the same workloads and the same correctness rules. + diff --git a/docs/workflow/engine/13-backend-comparison-2026-03-17.json b/docs/workflow/engine/13-backend-comparison-2026-03-17.json new file mode 100644 index 000000000..0740a8209 --- /dev/null +++ b/docs/workflow/engine/13-backend-comparison-2026-03-17.json @@ -0,0 +1,125 @@ +{ + "date": "2026-03-17", + "type": "backend-comparison", + "status": "baseline-decision-pack", + "sources": { + "oracle": "10-oracle-performance-baseline-2026-03-17.json", + "postgres": "11-postgres-performance-baseline-2026-03-17.json", + "mongo": "12-mongo-performance-baseline-2026-03-17.json" + }, + "validation": { + "integrationBuild": { + "warnings": 0, + "errors": 0 + }, + "oraclePerformanceSuite": "12/12", + "postgresPerformanceSuite": "11/11", + "mongoPerformanceSuite": "14/14", + "postgresBackendParitySuite": "9/9", + "mongoBackendParitySuite": "23/23" + }, + "normalizedMetrics": { + "signalRoundTrip": { + "oracle": { + "serialLatencyAvgMs": 3104.85, + "serialLatencyP95Ms": 3165.04, + "throughputOpsPerSecond": 20.98, + "throughputAvgMs": 4142.13, + "throughputP95Ms": 4215.64, + "soakOpsPerSecond": 3.91, + "soakAvgMs": 4494.29, + "soakP95Ms": 5589.33 + }, + "postgres": { + "serialLatencyAvgMs": 3079.33, + "serialLatencyP95Ms": 3094.94, + "throughputOpsPerSecond": 25.74, + "throughputAvgMs": 3603.54, + "throughputP95Ms": 3635.59, + "soakOpsPerSecond": 4.30, + "soakAvgMs": 4164.52, + "soakP95Ms": 4208.42 + }, + "mongo": { + "serialLatencyAvgMs": 97.88, + "serialLatencyP95Ms": 149.20, + "throughputOpsPerSecond": 76.28, + "throughputAvgMs": 1110.94, + "throughputP95Ms": 1121.22, + "soakOpsPerSecond": 47.62, + "soakAvgMs": 322.40, + "soakP95Ms": 550.50 + } + }, + "capacity": { + "c1": { + "oracle": 3.37, + "postgres": 4.11, + "mongo": 7.08 + }, + "c4": { + "oracle": 15.22, + "postgres": 17.29, + "mongo": 38.35 + }, + "c8": { + "oracle": 21.34, + "postgres": 33.21, + "mongo": 66.04 + }, + "c16": { + "oracle": 34.03, + "postgres": 57.04, + "mongo": 68.65 + } + }, + "transport": { + "immediateBurstNightlyOpsPerSecond": { + "oracle": 50.18, + "postgres": 70.10, + "mongo": 46.18 + }, + "delayedBurstNightlyOpsPerSecond": { + "oracle": 10.71, + "postgres": 19.60, + "mongo": 16.66 + } + }, + "bulstrad": { + "quoteOrAplCancelSmokeOpsPerSecond": { + "oracle": 19.69, + "postgres": 59.88, + "mongo": 23.48 + }, + "quotationConfirmConvertToPolicyNightlyOpsPerSecond": { + "oracle": 1.77, + "postgres": 3.33, + "mongo": 10.83 + } + } + }, + "backendObservations": { + "oracle": { + "dominantWait": "log file sync", + "strength": "highest validation maturity", + "primaryPressure": "commit pressure" + }, + "postgres": { + "dominantWait": "Client:ClientRead", + "strength": "best relational performance profile", + "primaryPressure": "queue claim and wake cadence" + }, + "mongo": { + "dominantWait": "none-stable", + "strength": "fastest measured synthetic throughput", + "primaryPressure": "latency expansion from c8 to c16 and operational dependence on transactions plus change streams" + } + }, + "recommendation": { + "currentPortabilityChoice": "PostgreSQL", + "reason": "best performance-to-operability compromise with a relational model and competitive backend-native validation", + "fastestMeasuredBackend": "MongoDB", + "highestValidationMaturity": "Oracle", + "caveat": "this is a baseline-level decision pack; Oracle still has the broadest hostile-condition and Bulstrad hardening surface" + } +} diff --git a/docs/workflow/engine/13-backend-comparison-2026-03-17.md b/docs/workflow/engine/13-backend-comparison-2026-03-17.md new file mode 100644 index 000000000..8bbd17791 --- /dev/null +++ b/docs/workflow/engine/13-backend-comparison-2026-03-17.md @@ -0,0 +1,167 @@ +# Backend Comparison 2026-03-17 + +## Purpose + +This document compares the current Oracle, PostgreSQL, and MongoDB workflow-engine backends using the published normalized performance baselines and the currently implemented backend-specific validation suites. + +This is a decision pack for the current local Docker benchmark set. It is not the final production recommendation pack yet, because hostile-condition and Bulstrad hardening depth is still strongest on Oracle. + +The durable machine-readable companion is [13-backend-comparison-2026-03-17.json](13-backend-comparison-2026-03-17.json). + +For the exact six-profile signal-driver matrix, including `Oracle+Redis`, `PostgreSQL+Redis`, and `Mongo+Redis`, see [14-signal-driver-backend-matrix-2026-03-17.md](14-signal-driver-backend-matrix-2026-03-17.md). + +## Source Baselines + +- [10-oracle-performance-baseline-2026-03-17.md](10-oracle-performance-baseline-2026-03-17.md) +- [11-postgres-performance-baseline-2026-03-17.md](11-postgres-performance-baseline-2026-03-17.md) +- [12-mongo-performance-baseline-2026-03-17.md](12-mongo-performance-baseline-2026-03-17.md) + +## Validation Status + +Current comparison-relevant validation state: + +- Oracle performance suite: `12/12` passed +- PostgreSQL performance suite: `11/11` passed +- MongoDB performance suite: `14/14` passed +- PostgreSQL focused backend parity suite: `9/9` passed +- MongoDB focused backend parity suite: `23/23` passed +- integration project build: `0` warnings, `0` errors + +Important caveat: + +- Oracle still has the broadest hostile-condition and Bulstrad E2E matrix. +- PostgreSQL and MongoDB now have backend-native signal/runtime/projection/performance coverage plus curated Bulstrad parity, but they do not yet match Oracle's full reliability surface. + +## Normalized Comparison + +### Synthetic Signal Round-Trip + +| Metric | Oracle | PostgreSQL | MongoDB | +| --- | ---: | ---: | ---: | +| Serial latency avg ms | 3104.85 | 3079.33 | 97.88 | +| Serial latency P95 ms | 3165.04 | 3094.94 | 149.20 | +| Throughput ops/s | 20.98 | 25.74 | 76.28 | +| Throughput avg ms | 4142.13 | 3603.54 | 1110.94 | +| Throughput P95 ms | 4215.64 | 3635.59 | 1121.22 | +| Soak ops/s | 3.91 | 4.30 | 47.62 | +| Soak avg ms | 4494.29 | 4164.52 | 322.40 | +| Soak P95 ms | 5589.33 | 4208.42 | 550.50 | + +### Capacity Ladder + +| Concurrency | Oracle ops/s | PostgreSQL ops/s | MongoDB ops/s | +| --- | ---: | ---: | ---: | +| `c1` | 3.37 | 4.11 | 7.08 | +| `c4` | 15.22 | 17.29 | 38.35 | +| `c8` | 21.34 | 33.21 | 66.04 | +| `c16` | 34.03 | 57.04 | 68.65 | + +### Transport Baselines + +| Scenario | Oracle ops/s | PostgreSQL ops/s | MongoDB ops/s | +| --- | ---: | ---: | ---: | +| Immediate burst nightly | 50.18 | 70.10 | 46.18 | +| Delayed burst nightly | 10.71 | 19.60 | 16.66 | +| Immediate burst smoke | 56.94 | 70.21 | 32.11 | +| Delayed burst smoke | 2.86 | 5.59 | 4.97 | + +### Bulstrad Workloads + +| Scenario | Oracle ops/s | PostgreSQL ops/s | MongoDB ops/s | +| --- | ---: | ---: | ---: | +| `QuoteOrAplCancel` smoke | 19.69 | 59.88 | 23.48 | +| `QuotationConfirm -> ConvertToPolicy` nightly | 1.77 | 3.33 | 10.83 | + +## Backend-Specific Observations + +### Oracle + +- Strongest validation depth and strongest correctness story. +- Main cost center is still commit pressure. +- Dominant wait is `log file sync` in almost every measured scenario. +- `c8` is still the last comfortable rung on the local Oracle Free setup. + +### PostgreSQL + +- Best relational performance profile in the current measurements. +- Immediate transport is the strongest of the three measured backends. +- Dominant wait is `Client:ClientRead`, which points to queue-claim cadence and short-transaction wake behavior, not a clear storage stall. +- `c16` still scales cleanly on this benchmark set and does not yet show a hard saturation cliff. + +### MongoDB + +- Fastest measured backend across the synthetic signal round-trip workloads and the medium Bulstrad nightly flow. +- No stable top wait dominated the measured runs; current analysis is more meaningful through normalized metrics and Mongo counters than through wait classification. +- The significant findings were correctness issues discovered and fixed during perf work: + - bounded empty-queue receive + - explicit collection bootstrap before transactional concurrency +- `c16` is the first rung where latency visibly expands even though throughput still rises. + +## Decision View + +### Raw Performance Ranking + +For the current local benchmark set: + +1. MongoDB +2. PostgreSQL +3. Oracle + +This order is stable for: + +- serial latency +- steady-state synthetic throughput +- soak throughput +- medium Bulstrad nightly flow + +### Validation Maturity Ranking + +For the current implementation state: + +1. Oracle +2. PostgreSQL +3. MongoDB + +Reason: + +- Oracle has the deepest hostile-condition and Bulstrad E2E surface. +- PostgreSQL now has a solid backend-native suite and competitive performance, but less reliability breadth than Oracle. +- MongoDB now has good backend-native and performance coverage, but its operational model is still the most infrastructure-sensitive because it depends on replica-set transactions plus change-stream wake behavior. + +### Current Recommendation + +If the next backend after Oracle must be chosen today: + +- choose PostgreSQL as the next default portability target + +Reason: + +- it materially outperforms Oracle on the normalized workflow workloads +- it preserves the relational operational model for runtime state and projections +- its wake-hint model is simpler to reason about operationally than MongoDB change streams +- it now has enough backend-native correctness and Bulstrad parity coverage to be a credible second backend + +If the decision is based only on benchmark speed: + +- MongoDB is currently fastest + +But that is not the same as the safest operational recommendation yet. + +## What Remains Before A Final Production Recommendation + +- expand PostgreSQL hostile-condition coverage to match the broader Oracle matrix +- expand MongoDB hostile-condition coverage to match the broader Oracle matrix +- widen the curated Bulstrad parity pack further on PostgreSQL and MongoDB +- rerun the shared parity pack on all three backends in one closeout pass +- add environment-to-environment reruns before turning local Docker numbers into sizing guidance + +## Short Conclusion + +The engine is now backend-comparable on normalized performance across Oracle, PostgreSQL, and MongoDB. + +The current picture is: + +- Oracle is the most validated backend +- PostgreSQL is the best performance-to-operability compromise +- MongoDB is the fastest measured backend but not yet the safest backend recommendation + diff --git a/docs/workflow/engine/14-signal-driver-backend-matrix-2026-03-17.json b/docs/workflow/engine/14-signal-driver-backend-matrix-2026-03-17.json new file mode 100644 index 000000000..ace5ca1af --- /dev/null +++ b/docs/workflow/engine/14-signal-driver-backend-matrix-2026-03-17.json @@ -0,0 +1,137 @@ +{ + "date": "2026-03-17", + "type": "signal-driver-backend-matrix", + "sourcePolicy": "artifact-driven-only", + "generatedMatrixArtifact": { + "markdown": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfComparison\\20260317T210643496-workflow-backend-signal-roundtrip-six-profile-matrix.md", + "json": "C:\\dev\\serdica-backend4\\src\\Serdica\\Ablera.Serdica.Workflow\\__Tests\\Ablera.Serdica.Workflow.IntegrationTests\\bin\\Release\\net9.0\\TestResults\\workflow-performance\\WorkflowPerfComparison\\20260317T210643496-workflow-backend-signal-roundtrip-six-profile-matrix.json" + }, + "profiles": [ + "Oracle", + "PostgreSQL", + "Mongo", + "Oracle+Redis", + "PostgreSQL+Redis", + "Mongo+Redis" + ], + "serialLatency": { + "endToEndAvgMs": { + "Oracle": 3091.73, + "PostgreSQL": 3101.57, + "Mongo": 151.36, + "Oracle+Redis": 3223.22, + "PostgreSQL+Redis": 3073.70, + "Mongo+Redis": 3099.51 + }, + "endToEndP95Ms": { + "Oracle": 3492.73, + "PostgreSQL": 3143.39, + "Mongo": 308.90, + "Oracle+Redis": 3644.66, + "PostgreSQL+Redis": 3090.75, + "Mongo+Redis": 3162.04 + }, + "startAvgMs": { + "Oracle": 105.88, + "PostgreSQL": 16.35, + "Mongo": 38.39, + "Oracle+Redis": 110.03, + "PostgreSQL+Redis": 8.32, + "Mongo+Redis": 21.77 + }, + "signalPublishAvgMs": { + "Oracle": 23.39, + "PostgreSQL": 11.47, + "Mongo": 14.30, + "Oracle+Redis": 23.90, + "PostgreSQL+Redis": 7.55, + "Mongo+Redis": 10.43 + }, + "signalToFirstCompletionAvgMs": { + "Oracle": 76.15, + "PostgreSQL": 37.56, + "Mongo": 55.06, + "Oracle+Redis": 81.46, + "PostgreSQL+Redis": 31.77, + "Mongo+Redis": 40.88 + }, + "signalToCompletionAvgMs": { + "Oracle": 2985.81, + "PostgreSQL": 3085.21, + "Mongo": 112.92, + "Oracle+Redis": 3113.11, + "PostgreSQL+Redis": 3065.38, + "Mongo+Redis": 3077.73 + }, + "drainToIdleOverhangAvgMs": { + "Oracle": 2909.65, + "PostgreSQL": 3047.65, + "Mongo": 57.86, + "Oracle+Redis": 3031.66, + "PostgreSQL+Redis": 3033.61, + "Mongo+Redis": 3036.85 + } + }, + "parallelThroughput": { + "throughputOpsPerSecond": { + "Oracle": 24.17, + "PostgreSQL": 26.28, + "Mongo": 119.51, + "Oracle+Redis": 21.88, + "PostgreSQL+Redis": 25.51, + "Mongo+Redis": 25.14 + }, + "endToEndAvgMs": { + "Oracle": 3740.84, + "PostgreSQL": 3546.11, + "Mongo": 688.57, + "Oracle+Redis": 4147.82, + "PostgreSQL+Redis": 3643.70, + "Mongo+Redis": 3701.72 + }, + "endToEndP95Ms": { + "Oracle": 3841.33, + "PostgreSQL": 3554.13, + "Mongo": 701.92, + "Oracle+Redis": 4243.83, + "PostgreSQL+Redis": 3675.15, + "Mongo+Redis": 3721.14 + }, + "startAvgMs": { + "Oracle": 47.44, + "PostgreSQL": 11.32, + "Mongo": 17.89, + "Oracle+Redis": 55.82, + "PostgreSQL+Redis": 17.07, + "Mongo+Redis": 17.04 + }, + "signalPublishAvgMs": { + "Oracle": 15.62, + "PostgreSQL": 15.11, + "Mongo": 10.85, + "Oracle+Redis": 23.80, + "PostgreSQL+Redis": 10.53, + "Mongo+Redis": 12.27 + }, + "signalToCompletionAvgMs": { + "Oracle": 3525.84, + "PostgreSQL": 3469.46, + "Mongo": 590.78, + "Oracle+Redis": 3872.54, + "PostgreSQL+Redis": 3564.43, + "Mongo+Redis": 3598.14 + } + }, + "integrity": { + "allProfilesPassed": true, + "requiredChecks": [ + "Failures = 0", + "DeadLetteredSignals = 0", + "RuntimeConflicts = 0", + "StuckInstances = 0", + "WorkflowsStarted = OperationCount", + "SignalsPublished = OperationCount", + "SignalsProcessed = OperationCount" + ] + } +} diff --git a/docs/workflow/engine/14-signal-driver-backend-matrix-2026-03-17.md b/docs/workflow/engine/14-signal-driver-backend-matrix-2026-03-17.md new file mode 100644 index 000000000..b9df157c4 --- /dev/null +++ b/docs/workflow/engine/14-signal-driver-backend-matrix-2026-03-17.md @@ -0,0 +1,76 @@ +# Signal Driver / Backend Matrix 2026-03-17 + +## Purpose + +This snapshot records the current six-profile synthetic signal round-trip comparison: + +- `Oracle` +- `PostgreSQL` +- `Mongo` +- `Oracle+Redis` +- `PostgreSQL+Redis` +- `Mongo+Redis` + +The matrix is artifact-driven. Every value comes from measured JSON artifacts under `TestResults/workflow-performance/`. No hand-entered metric values are used. + +The exact generated matrix artifact is: +- `WorkflowPerfComparison/20260317T210643496-workflow-backend-signal-roundtrip-six-profile-matrix.md` +- `WorkflowPerfComparison/20260317T210643496-workflow-backend-signal-roundtrip-six-profile-matrix.json` + +## Serial Latency + +Primary comparison rows in this section are: + +- `Signal to first completion avg` +- `Drain-to-idle overhang avg` + +`Signal to completion avg` is a mixed number. +It includes both real resume work and the benchmark drain policy. + +| Metric | Unit | Oracle | PostgreSQL | Mongo | Oracle+Redis | PostgreSQL+Redis | Mongo+Redis | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| End-to-end avg | ms | 3091.73 | 3101.57 | 151.36 | 3223.22 | 3073.70 | 3099.51 | +| End-to-end p95 | ms | 3492.73 | 3143.39 | 308.90 | 3644.66 | 3090.75 | 3162.04 | +| Start avg | ms | 105.88 | 16.35 | 38.39 | 110.03 | 8.32 | 21.77 | +| Signal publish avg | ms | 23.39 | 11.47 | 14.30 | 23.90 | 7.55 | 10.43 | +| Signal to first completion avg | ms | 76.15 | 37.56 | 55.06 | 81.46 | 31.77 | 40.88 | +| Signal to completion avg | ms | 2985.81 | 3085.21 | 112.92 | 3113.11 | 3065.38 | 3077.73 | +| Drain-to-idle overhang avg | ms | 2909.65 | 3047.65 | 57.86 | 3031.66 | 3033.61 | 3036.85 | + +## Parallel Throughput + +| Metric | Unit | Oracle | PostgreSQL | Mongo | Oracle+Redis | PostgreSQL+Redis | Mongo+Redis | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Throughput | ops/s | 24.17 | 26.28 | 119.51 | 21.88 | 25.51 | 25.14 | +| End-to-end avg | ms | 3740.84 | 3546.11 | 688.57 | 4147.82 | 3643.70 | 3701.72 | +| End-to-end p95 | ms | 3841.33 | 3554.13 | 701.92 | 4243.83 | 3675.15 | 3721.14 | +| Start avg | ms | 47.44 | 11.32 | 17.89 | 55.82 | 17.07 | 17.04 | +| Signal publish avg | ms | 15.62 | 15.11 | 10.85 | 23.80 | 10.53 | 12.27 | +| Signal to completion avg | ms | 3525.84 | 3469.46 | 590.78 | 3872.54 | 3564.43 | 3598.14 | + +## Integrity + +The comparison test required all six columns to pass these checks on both the serial-latency and parallel-throughput source artifacts: + +- `Failures = 0` +- `DeadLetteredSignals = 0` +- `RuntimeConflicts = 0` +- `StuckInstances = 0` +- `WorkflowsStarted = OperationCount` +- `SignalsPublished = OperationCount` +- `SignalsProcessed = OperationCount` + +All six columns passed. + +## Interpretation + +The main conclusions from this six-profile matrix are: + +- Native Mongo is still the fastest measured profile for the synthetic signal round-trip. +- Native PostgreSQL remains the best-performing relational profile. +- Oracle+Redis is slower than native Oracle in this benchmark. +- PostgreSQL+Redis is very close to native PostgreSQL, but not clearly better. +- Mongo+Redis is dramatically worse than native Mongo because the Redis path reintroduces the empty-wait overhang that native change streams avoid. + +The most useful row for actual resume speed is `Signal to first completion avg`, not the mixed `Signal to completion avg`, because the latter still includes drain-to-idle policy. + diff --git a/docs/workflow/engine/15-backend-and-signal-driver-usage.md b/docs/workflow/engine/15-backend-and-signal-driver-usage.md new file mode 100644 index 000000000..f0a911340 --- /dev/null +++ b/docs/workflow/engine/15-backend-and-signal-driver-usage.md @@ -0,0 +1,493 @@ +# 15. Backend And Signal Driver Usage + +## Purpose + +This document turns the current backend implementation and measured six-profile matrix into operating guidance. + +It answers three practical questions: + +1. which backend should be the durable workflow system of record +2. whether the signal driver should stay native or use Redis +3. when a given combination should or should not be used + +The reference comparison data comes from: + +- [13-backend-comparison-2026-03-17.md](13-backend-comparison-2026-03-17.md) +- [14-signal-driver-backend-matrix-2026-03-17.md](14-signal-driver-backend-matrix-2026-03-17.md) + +## Two Separate Choices + +There are two distinct infrastructure choices in the current engine. + +### 1. Backend + +The backend is the durable correctness layer. + +It owns: + +- runtime state +- projections +- durable signal persistence +- delayed signal persistence +- dead-letter persistence +- mutation transaction boundary + +The configured backend lives under: + +- `WorkflowBackend:Provider` + +Supported values are defined by the engine backend identifiers. + +Current values: + +- `Oracle` +- `Postgres` +- `Mongo` + +### 2. Signal Driver + +The signal driver is the wake mechanism. + +It owns: + +- wake notification delivery +- receive wait behavior +- claim loop entry path + +It does not own correctness. + +The configured signal driver lives under: + +- `WorkflowSignalDriver:Provider` + +Supported values are defined by the engine signal-driver identifiers. + +Current values: + +- `Native` +- `Redis` + +## Core Rule + +Redis is a wake driver, not a durable workflow queue. + +That means: + +1. the selected backend always remains the durable source of truth +2. runtime state and durable signals commit in the backend transaction boundary +3. Redis only publishes wake hints after commit +4. workers always claim from the durable backend store + +Do not design or describe Redis as the place where workflow correctness lives. + +## Supported Profiles + +| Profile | Durable correctness layer | Wake path | Current recommendation | +| --- | --- | --- | --- | +| `Oracle + Native` | Oracle + AQ | AQ dequeue | Default production profile | +| `Oracle + Redis` | Oracle + AQ | Redis wake, AQ claim | Supported, not preferred | +| `Postgres + Native` | PostgreSQL tables | PostgreSQL native wake | Best relational portability profile | +| `Postgres + Redis` | PostgreSQL tables | Redis wake, PostgreSQL claim | Supported, optional | +| `Mongo + Native` | Mongo collections | Mongo change streams | Fastest measured profile, with operational caveats | +| `Mongo + Redis` | Mongo collections | Redis wake, Mongo claim | Supported, generally not recommended | + +## How To Read The Performance Data + +The six-profile matrix contains both real resume timing and benchmark drain policy timing. + +Use these rows as primary decision inputs: + +- `Signal to first completion avg` +- `Throughput` + +Treat these rows as secondary: + +- `Signal to completion avg` +- `Drain-to-idle overhang avg` + +Reason: + +- `Signal to first completion avg` measures actual wake and resume speed +- `Signal to completion avg` also includes empty-queue drain behavior +- `Drain-to-idle overhang avg` explains how much of the mixed latency is benchmark overhang, not real resume work + +The current matrix shows that clearly: + +| Metric | Oracle | PostgreSQL | Mongo | Oracle+Redis | PostgreSQL+Redis | Mongo+Redis | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Signal to first completion avg ms | 76.15 | 37.56 | 55.06 | 81.46 | 31.77 | 40.88 | +| Throughput ops/s | 24.17 | 26.28 | 119.51 | 21.88 | 25.51 | 25.14 | +| Drain-to-idle overhang avg ms | 2909.65 | 3047.65 | 57.86 | 3031.66 | 3033.61 | 3036.85 | + +Interpretation: + +- native Mongo is fast because the native change-stream wake path also has low empty-receive overhang +- PostgreSQL native and PostgreSQL plus Redis are close in real resume speed +- Oracle native remains slightly better than Oracle plus Redis +- Mongo plus Redis loses most of native Mongo's advantage because Redis mode reintroduces the empty-wait overhang + +## Recommended Default Choices + +### Default Production Choice Today + +Use `Oracle + Native`. + +Use it when: + +- Oracle is already the platform system of record +- strongest validated correctness and restart behavior matter more than portability +- AQ is available and operationally acceptable +- timer precision and native transactional coupling are important + +Why: + +- it has the strongest hostile-condition coverage +- it remains the semantic reference implementation +- it keeps one native durable stack for state, signals, and scheduling + +### Best Relational Non-Oracle Choice + +Use `Postgres + Native`. + +Use it when: + +- a relational backend is required +- Oracle is not desired +- you want the cleanest portability path +- you want performance close to Oracle with simpler infrastructure + +Why: + +- it is the strongest non-Oracle backend in the current relational comparison +- native PostgreSQL wake is already competitive with Redis in the current measurements +- it keeps one backend-native operational story + +### Highest Measured Synthetic Throughput Choice + +Use `Mongo + Native` only when its operational assumptions are acceptable. + +Use it when: + +- throughput and low wake latency matter strongly +- Mongo replica-set transactions are already an accepted platform dependency +- the team is comfortable operating change streams and Mongo-specific failure modes + +Why: + +- it is currently the fastest measured profile +- its native wake path avoids the large empty-wait overhang seen in the other measured paths + +Do not treat this as the universal default. + +Mongo is fast in the current engine workload, but its operational model is still less conservative than the relational profiles. + +## When Redis Should Be Used + +Redis should be selected for operational topology reasons, not by default as a performance assumption. + +Good reasons to use Redis: + +- one shared wake substrate is required across multiple backend profiles +- the deployment already standardizes on Redis for fan-out and worker wake infrastructure +- you want the backend-native wake path disabled intentionally and replaced by one uniform wake mechanism + +Weak reasons to use Redis: + +- "Redis is always faster" +- "Redis should hold the durable signal queue" +- "Redis should replace the backend transaction boundary" + +Those are not valid design assumptions for this engine. + +## Profile-By-Profile Guidance + +### Oracle + Native + +Use when: + +- Oracle is the chosen workflow backend +- AQ is available +- you want the strongest native transactional semantics + +Do not switch away from it just to standardize on Redis. + +Current measured result: + +- native Oracle is slightly better than Oracle plus Redis on both first-completion latency and throughput + +### Oracle + Redis + +Use only when: + +- Oracle remains the durable backend +- Redis is required as a uniform wake topology across the environment +- the small performance loss is acceptable + +Do not use it as the default Oracle profile. + +Current measured result: + +- it works correctly +- it is slower than native Oracle +- it does not improve timer behavior today + +### Postgres + Native + +Use as the first portability target when leaving Oracle. + +Use when: + +- you want a relational durable store +- you want the cleanest alternative to Oracle +- you want the simplest operational story for PostgreSQL + +This should be the default PostgreSQL profile. + +### Postgres + Redis + +Use when: + +- PostgreSQL is the durable backend +- a shared Redis wake topology is required +- a nearly flat performance profile versus native PostgreSQL is acceptable + +Do not assume it is a speed upgrade. + +Current measured result: + +- it is very close to native PostgreSQL +- it is not a compelling performance win on its own + +### Mongo + Native + +Use when: + +- MongoDB is an accepted transactional system of record for workflow runtime state +- replica-set transactions are available +- the team accepts Mongo operational ownership + +This should be the default Mongo profile. + +### Mongo + Redis + +Avoid as the normal Mongo profile. + +Use only when: + +- Mongo must remain the durable backend +- Redis wake standardization is mandatory for the deployment +- the team accepts materially worse measured wake behavior than native Mongo + +Current measured result: + +- native Mongo is much better overall +- first-completion latency stays acceptable, but steady throughput and idle-drain behavior become much worse +- Redis removes the main measured advantage of the native Mongo wake path + +## Timer And Delayed-Signal Guidance + +Timers remain durable in the selected backend. + +That means: + +- Oracle timers remain durable in AQ +- PostgreSQL timers remain durable in PostgreSQL tables +- Mongo timers remain durable in Mongo collections + +Redis does not become the timer authority. + +Current practical rule: + +- if timer behavior is a primary concern, prefer the native signal driver for the selected backend + +Reason: + +- Redis wake currently optimizes wake notification, not durable due-time ownership +- delayed messages still live in the backend store +- due-time wake precision in Redis mode is still bounded by the driver wait policy rather than a separate Redis-native timer authority + +## What Must Not Be Mixed + +Do not mix durable responsibilities across systems. + +Bad combinations: + +- Oracle runtime state with PostgreSQL signals +- PostgreSQL runtime state with Redis as the durable signal queue +- Mongo runtime state with Oracle scheduling +- one backend for runtime state and another backend for projections + +Use one backend profile per deployment. + +The only supported cross-system split is: + +- durable backend +- optional Redis wake driver + +## Operational Decision Matrix + +| Goal | Recommended profile | +| --- | --- | +| strongest production default today | `Oracle + Native` | +| best non-Oracle relational target | `Postgres + Native` | +| one uniform wake substrate across relational backends | `Postgres + Redis` | +| highest measured synthetic wake and throughput | `Mongo + Native` | +| Mongo with forced Redis standardization | `Mongo + Redis`, only if policy requires it | +| Oracle with forced Redis standardization | `Oracle + Redis`, only if policy requires it | + +## Configuration Surface + +### Oracle + Native + +```json +{ + "WorkflowBackend": { + "Provider": "Oracle" + }, + "WorkflowSignalDriver": { + "Provider": "Native" + }, + "WorkflowAq": { + "QueueOwner": "SRD_WFKLW", + "SignalQueueName": "WF_SIGNAL_Q", + "ScheduleQueueName": "WF_SCHEDULE_Q", + "DeadLetterQueueName": "WF_DLQ_Q" + } +} +``` + +### Oracle + Redis + +```json +{ + "WorkflowBackend": { + "Provider": "Oracle" + }, + "WorkflowSignalDriver": { + "Provider": "Redis", + "Redis": { + "ChannelName": "serdica:workflow:signals", + "BlockingWaitSeconds": 5 + } + }, + "WorkflowAq": { + "QueueOwner": "SRD_WFKLW", + "SignalQueueName": "WF_SIGNAL_Q", + "ScheduleQueueName": "WF_SCHEDULE_Q", + "DeadLetterQueueName": "WF_DLQ_Q" + } +} +``` + +### Postgres + Native + +```json +{ + "WorkflowBackend": { + "Provider": "Postgres", + "Postgres": { + "ConnectionStringName": "WorkflowPostgres", + "SchemaName": "srd_wfklw", + "ClaimBatchSize": 32, + "BlockingWaitSeconds": 30 + } + }, + "WorkflowSignalDriver": { + "Provider": "Native" + } +} +``` + +### Postgres + Redis + +```json +{ + "WorkflowBackend": { + "Provider": "Postgres", + "Postgres": { + "ConnectionStringName": "WorkflowPostgres", + "SchemaName": "srd_wfklw" + } + }, + "WorkflowSignalDriver": { + "Provider": "Redis", + "Redis": { + "ChannelName": "serdica:workflow:signals", + "BlockingWaitSeconds": 5 + } + } +} +``` + +### Mongo + Native + +```json +{ + "WorkflowBackend": { + "Provider": "Mongo", + "Mongo": { + "ConnectionStringName": "WorkflowMongo", + "DatabaseName": "serdica_workflow_store", + "BlockingWaitSeconds": 30 + } + }, + "WorkflowSignalDriver": { + "Provider": "Native" + } +} +``` + +### Mongo + Redis + +```json +{ + "WorkflowBackend": { + "Provider": "Mongo", + "Mongo": { + "ConnectionStringName": "WorkflowMongo", + "DatabaseName": "serdica_workflow_store" + } + }, + "WorkflowSignalDriver": { + "Provider": "Redis", + "Redis": { + "ChannelName": "serdica:workflow:signals", + "BlockingWaitSeconds": 5 + } + } +} +``` + +## Plugin Registration Rule + +The host stays backend-neutral. + +That means the selected backend and optional Redis wake plugin must be present in `PluginsConfig:PluginsOrder`. + +Relevant plugin categories are: + +- Oracle backend plugin +- PostgreSQL backend plugin +- MongoDB backend plugin +- Redis wake-driver plugin + +If Redis is not configured, do not register it just because it exists. + +## Recommended Decision Order + +When choosing a deployment profile, use this order: + +1. choose the durable backend based on correctness and platform ownership +2. choose the native signal driver first +3. add Redis only if there is a clear topology or operational reason +4. validate the choice against the six-profile matrix, not assumption + +## Current Bottom Line + +Today the practical recommendation is: + +- `Oracle + Native` for the strongest default production backend +- `Postgres + Native` for the best relational portability target +- `Mongo + Native` only when Mongo operational assumptions are explicitly accepted +- `Redis` as an optional wake standardization layer, not as the default performance answer + diff --git a/docs/workflow/engine/index.md b/docs/workflow/engine/index.md new file mode 100644 index 000000000..e6cf88747 --- /dev/null +++ b/docs/workflow/engine/index.md @@ -0,0 +1,85 @@ +# Serdica Workflow Engine Architecture + +## Purpose + +This folder defines the target architecture for the workflow engine runtime. + +The design in this folder assumes: + +- authored C# workflow classes remain the source of truth +- workflows are already fully declarative and can be compiled to canonical definitions +- the service will run a single engine provider per deployment +- migration and concurrent engine execution are out of scope for v1 +- Oracle is the durable system of record +- Oracle Advanced Queuing is the default signaling and scheduling backend +- Redis is optional and not on the correctness path + +This package is intentionally detailed. It documents engine behavior, persistence, signaling, and runtime structure. Platform transport and command-mapping details are outside its scope. + +## Reading Order + +1. [01-requirements-and-principles.md](01-requirements-and-principles.md) +2. [02-runtime-and-component-architecture.md](02-runtime-and-component-architecture.md) +3. [03-canonical-execution-model.md](03-canonical-execution-model.md) +4. [04-persistence-signaling-and-scheduling.md](04-persistence-signaling-and-scheduling.md) +5. [05-service-surface-hosting-and-operations.md](05-service-surface-hosting-and-operations.md) +6. [06-implementation-structure.md](06-implementation-structure.md) +7. [07-sprint-plan.md](07-sprint-plan.md) +8. [08-load-and-performance-plan.md](08-load-and-performance-plan.md) +9. [09-backend-portability-plan.md](09-backend-portability-plan.md) +10. [10-oracle-performance-baseline-2026-03-17.md](10-oracle-performance-baseline-2026-03-17.md) +11. [11-postgres-performance-baseline-2026-03-17.md](11-postgres-performance-baseline-2026-03-17.md) +12. [12-mongo-performance-baseline-2026-03-17.md](12-mongo-performance-baseline-2026-03-17.md) +13. [13-backend-comparison-2026-03-17.md](13-backend-comparison-2026-03-17.md) +14. [14-signal-driver-backend-matrix-2026-03-17.md](14-signal-driver-backend-matrix-2026-03-17.md) +15. [15-backend-and-signal-driver-usage.md](15-backend-and-signal-driver-usage.md) + +## Executive Summary + +The engine is designed around six core decisions: + +1. Workflow execution moves from the earlier runtime to a canonical interpreter owned by the engine. +2. The interpreter executes canonical workflow definitions compiled from authored C# workflows. +3. Oracle remains the single durable source of truth for workflow runtime state, projections, and host coordination. +4. Oracle AQ provides durable signaling and scheduling with blocking dequeue semantics, which removes polling from the steady-state engine path. +5. The engine uses a run-to-wait model: an instance is loaded, advanced until the next wait boundary, persisted, and released. No node permanently owns an instance. +6. The workflow service surface remains stable. The engine is a runtime replacement, not a transport or UI rewrite. + +## Scope Summary + +### In Scope + +- start workflow +- activate human tasks +- assign, release, and complete tasks +- execute canonical transport calls +- execute canonical conditions, loops, branches, and subworkflows +- schedule delayed resumes and retry wakes without polling +- resume safely after node, service, or full-cluster restart +- support multi-instance deployment with one shared database +- preserve read-side service contracts, projections, authorization, diagrams, retention, and canonical inspection + +### Out of Scope + +- concurrent old-runtime and engine execution +- in-place instance migration between engines +- Redis as a correctness-grade signaling dependency +- user-facing workflow authoring changes +- replacing the public workflow service surface + +## Product Position + +At the workflow-service boundary, the runtime still has to support: + +- workflow and task operations +- the task inbox and assignment system +- the workflow diagram provider +- canonical definition and validation access +- the operational retention owner + +The engine is a runtime subsystem under the workflow service, not a separate product. + +## Design Baseline + +The engine architecture in this folder should be treated as the default implementation plan unless a later ADR explicitly replaces part of it. + diff --git a/docs/workflow/tutorials/01-hello-world/README.md b/docs/workflow/tutorials/01-hello-world/README.md new file mode 100644 index 000000000..5918e6ea9 --- /dev/null +++ b/docs/workflow/tutorials/01-hello-world/README.md @@ -0,0 +1,30 @@ +# Tutorial 1: Hello World + +The simplest possible workflow: initialize state from a start request, activate a single human task, and complete the workflow when the task is done. + +## Concepts Introduced + +- `IDeclarativeWorkflow` — the contract every workflow implements +- `WorkflowSpec.For()` — the builder entry point +- `.InitializeState()` — transforms the start request into workflow state +- `.StartWith(task)` — sets the first task to activate +- `WorkflowHumanTask.For()` — defines a human task +- `.OnComplete(flow => flow.Complete())` — terminal step + +## What Happens at Runtime + +1. Client calls `StartWorkflowAsync` with `WorkflowName = "Greeting"` and payload `{ "customerName": "John" }` +2. State initializes to `{ "customerName": "John" }` +3. Task "Greet Customer" is created with status "Pending" +4. A user assigns the task to themselves, then completes it +5. `OnComplete` executes `.Complete()` — the workflow finishes + +## Variants + +- [C# Fluent DSL](csharp/) +- [Canonical JSON](json/) + +## Next + +[Tutorial 2: Service Tasks](../02-service-tasks/) — call external services before or after human tasks. + diff --git a/docs/workflow/tutorials/01-hello-world/csharp/GreetingWorkflow.cs b/docs/workflow/tutorials/01-hello-world/csharp/GreetingWorkflow.cs new file mode 100644 index 000000000..7c14a58fb --- /dev/null +++ b/docs/workflow/tutorials/01-hello-world/csharp/GreetingWorkflow.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Text.Json; + +using WorkflowEngine.Abstractions; +using WorkflowEngine.Contracts; + +namespace WorkflowEngine.Tutorials; + +// Start request — defines the input contract for the workflow. +public sealed class GreetingRequest +{ + public string CustomerName { get; set; } = string.Empty; +} + +// Workflow definition — implements IDeclarativeWorkflow. +public sealed class GreetingWorkflow : IDeclarativeWorkflow +{ + // Identity: name + version uniquely identify the workflow definition. + public string WorkflowName => "Greeting"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Customer Greeting"; + + // Roles: which user roles can see and interact with this workflow's tasks. + public IReadOnlyCollection WorkflowRoles => ["DBA", "UR_AGENT"]; + + // Spec: the workflow specification built via the fluent DSL. + public WorkflowSpec Spec { get; } = WorkflowSpec + .For() + + // InitializeState: transform the start request into the workflow's mutable state. + // State is a Dictionary — all values are JSON-serialized. + .InitializeState(request => new Dictionary + { + ["customerName"] = JsonSerializer.SerializeToElement(request.CustomerName), + }) + + // StartWith: register and activate this task as the first step. + .StartWith(greetTask) + .Build(); + + // Tasks: expose task descriptors for the registration catalog. + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + // Task definition: defines name, type (UI component), route (navigation), and behavior. + private static readonly WorkflowHumanTaskDefinition greetTask = + WorkflowHumanTask.For( + taskName: "Greet Customer", // unique name within this workflow + taskType: "GreetCustomerTask", // UI component identifier + route: "customers/greet") // navigation route + .WithPayload(context => new Dictionary + { + // Pass state values to the task's UI payload. + ["customerName"] = context.StateValues + .GetRequired("customerName").AsJsonElement(), + }) + // OnComplete: what happens after the user completes this task. + .OnComplete(flow => flow.Complete()); // simply end the workflow +} diff --git a/docs/workflow/tutorials/01-hello-world/json/greeting-workflow.definition.json b/docs/workflow/tutorials/01-hello-world/json/greeting-workflow.definition.json new file mode 100644 index 000000000..e927a913f --- /dev/null +++ b/docs/workflow/tutorials/01-hello-world/json/greeting-workflow.definition.json @@ -0,0 +1,56 @@ +{ + "schemaVersion": "serdica.workflow.definition/v1", + "workflowName": "Greeting", + "workflowVersion": "1.0.0", + "displayName": "Customer Greeting", + + "startRequest": { + "contractName": "GreetingRequest", + "allowAdditionalProperties": true + }, + + "workflowRoles": ["DBA", "UR_AGENT"], + + "start": { + "initializeStateExpression": { + "$type": "object", + "properties": [ + { + "name": "customerName", + "expression": { "$type": "path", "path": "start.customerName" } + } + ] + }, + "sequence": { + "steps": [ + { + "$type": "activate-task", + "taskName": "Greet Customer" + } + ] + } + }, + + "tasks": [ + { + "taskName": "Greet Customer", + "taskType": "GreetCustomerTask", + "routeExpression": { "$type": "string", "value": "customers/greet" }, + "taskRoles": [], + "payloadExpression": { + "$type": "object", + "properties": [ + { + "name": "customerName", + "expression": { "$type": "path", "path": "state.customerName" } + } + ] + }, + "onCompleteSequence": { + "steps": [ + { "$type": "complete" } + ] + } + } + ] +} diff --git a/docs/workflow/tutorials/02-service-tasks/README.md b/docs/workflow/tutorials/02-service-tasks/README.md new file mode 100644 index 000000000..ac291875e --- /dev/null +++ b/docs/workflow/tutorials/02-service-tasks/README.md @@ -0,0 +1,29 @@ +# Tutorial 2: Service Tasks + +Call external services (microservices, HTTP APIs, GraphQL, RabbitMQ) from within a workflow. Handle failures and timeouts gracefully. + +## Concepts Introduced + +- `.Call()` — invoke a transport with payload and optional response capture +- Address types — `LegacyRabbit`, `Microservice`, `Http`, `Graphql`, `Rabbit` +- `resultKey` — store the service response in workflow state +- `whenFailure` / `whenTimeout` — recovery branches +- `WorkflowHandledBranchAction.Complete` — shorthand for "complete on error" +- `timeoutSeconds` — per-step timeout override (default: 1 hour) + +## Key Points + +- Each `Call` step executes synchronously within the workflow +- The per-step timeout wraps the entire call including transport-level retries +- Transport timeouts (30s default) control individual attempt duration +- If no failure/timeout handler is defined, the error propagates and the signal pump retries + +## Variants + +- [C# Fluent DSL](csharp/) +- [Canonical JSON](json/) + +## Next + +[Tutorial 3: Decisions](../03-decisions/) — branch workflow logic based on conditions. + diff --git a/docs/workflow/tutorials/02-service-tasks/csharp/ServiceTaskWorkflow.cs b/docs/workflow/tutorials/02-service-tasks/csharp/ServiceTaskWorkflow.cs new file mode 100644 index 000000000..e523ccef4 --- /dev/null +++ b/docs/workflow/tutorials/02-service-tasks/csharp/ServiceTaskWorkflow.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Text.Json; + +using WorkflowEngine.Abstractions; +using WorkflowEngine.Contracts; + +namespace WorkflowEngine.Tutorials; + +public sealed class PolicyValidationRequest +{ + public long PolicyId { get; set; } +} + +public sealed class PolicyValidationWorkflow : IDeclarativeWorkflow +{ + public string WorkflowName => "PolicyValidation"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Policy Validation"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + + public WorkflowSpec Spec { get; } = WorkflowSpec + .For() + .InitializeState(WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")))) + .StartWith(BuildFlow) + .Build(); + + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + private static void BuildFlow(WorkflowFlowBuilder flow) + { + flow + // --- Example 1: Simple call with shorthand error handling --- + .Call( + "Validate Policy", // step name + Address.LegacyRabbit("pas_policy_validate"), // transport address + WorkflowExpr.Object( // payload (expression-based) + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))), + WorkflowHandledBranchAction.Complete, // on failure: complete workflow + WorkflowHandledBranchAction.Complete) // on timeout: complete workflow + + // --- Example 2: Call with typed response stored in state --- + .Call( + "Load Policy Info", + Address.LegacyRabbit("pas_get_policy_product_info"), + WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))), + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete, + resultKey: "policyInfo") // store response as "policyInfo" + + // Use the result to set state values + .SetIfHasValue("productCode", + WorkflowExpr.Func("upper", WorkflowExpr.Path("result.policyInfo.productCode"))) + .SetIfHasValue("lob", + WorkflowExpr.Path("result.policyInfo.lob")) + + // --- Example 3: Call with custom failure/timeout branches --- + .Call( + "Calculate Premium", + Address.LegacyRabbit("pas_premium_calculate_for_object", + SerdicaLegacyRabbitMode.MicroserviceConsumer), + WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))), + whenFailure: fail => fail + .Set("calculationFailed", WorkflowExpr.Bool(true)) + .Complete(), + whenTimeout: timeout => timeout + .Set("calculationTimedOut", WorkflowExpr.Bool(true)) + .Complete(), + timeoutSeconds: 120) // per-step timeout: 2 minutes + + // --- Example 4: HTTP transport --- + // .Call("Notify External", + // Address.Http("authority", "/api/v1/notifications", "POST"), + // WorkflowExpr.Object( + // WorkflowExpr.Prop("message", WorkflowExpr.String("Policy validated"))), + // WorkflowHandledBranchAction.Complete, + // WorkflowHandledBranchAction.Complete) + + .Complete(); + } +} diff --git a/docs/workflow/tutorials/02-service-tasks/json/service-task-workflow.definition.json b/docs/workflow/tutorials/02-service-tasks/json/service-task-workflow.definition.json new file mode 100644 index 000000000..86343e14a --- /dev/null +++ b/docs/workflow/tutorials/02-service-tasks/json/service-task-workflow.definition.json @@ -0,0 +1,89 @@ +{ + "schemaVersion": "serdica.workflow.definition/v1", + "workflowName": "PolicyValidation", + "workflowVersion": "1.0.0", + "displayName": "Policy Validation", + "workflowRoles": ["DBA"], + + "start": { + "initializeStateExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } } + ] + }, + "sequence": { + "steps": [ + { + "$type": "call-transport", + "stepName": "Validate Policy", + "invocation": { + "address": { "$type": "legacy-rabbit", "command": "pas_policy_validate", "mode": "Envelope" }, + "payloadExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } } + ] + } + }, + "whenFailure": { "steps": [{ "$type": "complete" }] }, + "whenTimeout": { "steps": [{ "$type": "complete" }] } + }, + { + "$type": "call-transport", + "stepName": "Load Policy Info", + "resultKey": "policyInfo", + "invocation": { + "address": { "$type": "legacy-rabbit", "command": "pas_get_policy_product_info", "mode": "Envelope" }, + "payloadExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } } + ] + } + }, + "whenFailure": { "steps": [{ "$type": "complete" }] }, + "whenTimeout": { "steps": [{ "$type": "complete" }] } + }, + { + "$type": "set-state", + "stateKey": "productCode", + "valueExpression": { + "$type": "function", + "functionName": "upper", + "arguments": [{ "$type": "path", "path": "result.policyInfo.productCode" }] + }, + "onlyIfPresent": true + }, + { + "$type": "call-transport", + "stepName": "Calculate Premium", + "timeoutSeconds": 120, + "invocation": { + "address": { "$type": "legacy-rabbit", "command": "pas_premium_calculate_for_object", "mode": "MicroserviceConsumer" }, + "payloadExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } } + ] + } + }, + "whenFailure": { + "steps": [ + { "$type": "set-state", "stateKey": "calculationFailed", "valueExpression": { "$type": "boolean", "value": true } }, + { "$type": "complete" } + ] + }, + "whenTimeout": { + "steps": [ + { "$type": "set-state", "stateKey": "calculationTimedOut", "valueExpression": { "$type": "boolean", "value": true } }, + { "$type": "complete" } + ] + } + }, + { "$type": "complete" } + ] + } + }, + "tasks": [] +} diff --git a/docs/workflow/tutorials/03-decisions/README.md b/docs/workflow/tutorials/03-decisions/README.md new file mode 100644 index 000000000..e91f0f9ba --- /dev/null +++ b/docs/workflow/tutorials/03-decisions/README.md @@ -0,0 +1,28 @@ +# Tutorial 3: Decisions + +Branch workflow logic based on conditions — state values, payload answers, or complex expressions. + +## Concepts Introduced + +- `.WhenExpression()` — branch on any boolean expression +- `.WhenStateFlag()` — shorthand for checking a boolean state value +- `.WhenPayloadEquals()` — shorthand for checking a task completion payload value +- Nested decisions — decisions inside decisions for complex routing + +## Decision Types + +| Method | Use When | +|--------|----------| +| `WhenExpression` | Complex conditions (comparisons, boolean logic, function calls) | +| `WhenStateFlag` | Checking a boolean state key against true/false | +| `WhenPayloadEquals` | Checking a task completion answer (inside OnComplete) | + +## Variants + +- [C# Fluent DSL](csharp/) +- [Canonical JSON](json/) + +## Next + +[Tutorial 4: Human Tasks](../04-human-tasks/) — approve/reject patterns with OnComplete flows. + diff --git a/docs/workflow/tutorials/03-decisions/csharp/DecisionWorkflow.cs b/docs/workflow/tutorials/03-decisions/csharp/DecisionWorkflow.cs new file mode 100644 index 000000000..6360bcc6b --- /dev/null +++ b/docs/workflow/tutorials/03-decisions/csharp/DecisionWorkflow.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; + +using WorkflowEngine.Abstractions; +using WorkflowEngine.Contracts; + +namespace WorkflowEngine.Tutorials; + +public sealed class PolicyRoutingRequest +{ + public long PolicyId { get; set; } + public string AnnexType { get; set; } = string.Empty; + public bool PolicyExistsOnIPAL { get; set; } +} + +public sealed class PolicyRoutingWorkflow : IDeclarativeWorkflow +{ + public string WorkflowName => "PolicyRouting"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Policy Routing Example"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + + public WorkflowSpec Spec { get; } = WorkflowSpec + .For() + .InitializeState(WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")), + WorkflowExpr.Prop("annexType", WorkflowExpr.Path("start.annexType")), + WorkflowExpr.Prop("policyExistsOnIPAL", + WorkflowExpr.Func("coalesce", + WorkflowExpr.Path("start.policyExistsOnIPAL"), + WorkflowExpr.Bool(true))))) + .StartWith(BuildFlow) + .Build(); + + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + private static void BuildFlow(WorkflowFlowBuilder flow) + { + // --- Example 1: State flag decision (boolean shorthand) --- + flow.WhenStateFlag( + "policyExistsOnIPAL", // state key to check + true, // expected value + "Policy exists on IPAL?", // decision name (appears in diagram) + whenTrue: ipal => ipal + + // --- Example 2: Expression decision --- + .WhenExpression( + "Annex Type?", + WorkflowExpr.Eq( + WorkflowExpr.Func("upper", WorkflowExpr.Path("state.annexType")), + WorkflowExpr.String("BENEF")), + benefit => benefit + .Set("route", WorkflowExpr.String("BENEFIT_PROCESSING")) + .Complete(), + + // --- Example 3: Nested decision --- + other => other.WhenExpression( + "Is Equipment?", + WorkflowExpr.Eq( + WorkflowExpr.Func("upper", WorkflowExpr.Path("state.annexType")), + WorkflowExpr.String("ADDEQ")), + equipment => equipment + .Set("route", WorkflowExpr.String("EQUIPMENT_PROCESSING")) + .Complete(), + cover => cover + .Set("route", WorkflowExpr.String("COVER_CHANGE")) + .Complete())), + + whenElse: notIpal => notIpal + .Set("route", WorkflowExpr.String("INSIS_PROCESSING")) + .Complete()); + } +} diff --git a/docs/workflow/tutorials/03-decisions/json/decision-workflow.definition.json b/docs/workflow/tutorials/03-decisions/json/decision-workflow.definition.json new file mode 100644 index 000000000..3ecf8188f --- /dev/null +++ b/docs/workflow/tutorials/03-decisions/json/decision-workflow.definition.json @@ -0,0 +1,83 @@ +{ + "schemaVersion": "serdica.workflow.definition/v1", + "workflowName": "PolicyRouting", + "workflowVersion": "1.0.0", + "displayName": "Policy Routing Example", + "workflowRoles": ["DBA"], + "start": { + "initializeStateExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } }, + { "name": "annexType", "expression": { "$type": "path", "path": "start.annexType" } }, + { "name": "policyExistsOnIPAL", "expression": { + "$type": "function", "functionName": "coalesce", + "arguments": [ + { "$type": "path", "path": "start.policyExistsOnIPAL" }, + { "$type": "boolean", "value": true } + ] + }} + ] + }, + "sequence": { + "steps": [ + { + "$type": "decision", + "decisionName": "Policy exists on IPAL?", + "conditionExpression": { "$type": "path", "path": "state.policyExistsOnIPAL" }, + "whenTrue": { + "steps": [ + { + "$type": "decision", + "decisionName": "Annex Type?", + "conditionExpression": { + "$type": "binary", "operator": "eq", + "left": { "$type": "function", "functionName": "upper", "arguments": [{ "$type": "path", "path": "state.annexType" }] }, + "right": { "$type": "string", "value": "BENEF" } + }, + "whenTrue": { + "steps": [ + { "$type": "set-state", "stateKey": "route", "valueExpression": { "$type": "string", "value": "BENEFIT_PROCESSING" } }, + { "$type": "complete" } + ] + }, + "whenElse": { + "steps": [ + { + "$type": "decision", + "decisionName": "Is Equipment?", + "conditionExpression": { + "$type": "binary", "operator": "eq", + "left": { "$type": "function", "functionName": "upper", "arguments": [{ "$type": "path", "path": "state.annexType" }] }, + "right": { "$type": "string", "value": "ADDEQ" } + }, + "whenTrue": { + "steps": [ + { "$type": "set-state", "stateKey": "route", "valueExpression": { "$type": "string", "value": "EQUIPMENT_PROCESSING" } }, + { "$type": "complete" } + ] + }, + "whenElse": { + "steps": [ + { "$type": "set-state", "stateKey": "route", "valueExpression": { "$type": "string", "value": "COVER_CHANGE" } }, + { "$type": "complete" } + ] + } + } + ] + } + } + ] + }, + "whenElse": { + "steps": [ + { "$type": "set-state", "stateKey": "route", "valueExpression": { "$type": "string", "value": "INSIS_PROCESSING" } }, + { "$type": "complete" } + ] + } + } + ] + } + }, + "tasks": [] +} diff --git a/docs/workflow/tutorials/04-human-tasks/README.md b/docs/workflow/tutorials/04-human-tasks/README.md new file mode 100644 index 000000000..2c8826f4e --- /dev/null +++ b/docs/workflow/tutorials/04-human-tasks/README.md @@ -0,0 +1,34 @@ +# Tutorial 4: Human Tasks with OnComplete Flows + +The approve/reject pattern — the most common human task flow in insurance workflows. + +## Concepts Introduced + +- `WorkflowHumanTask.For()` — define a task with name, type, route, and roles +- `.WithPayload()` — data sent to the UI when the task is displayed +- `.WithTimeout(seconds)` — optional deadline for the task +- `.WithRoles()` — restrict which roles can interact with this task +- `.OnComplete(flow => ...)` — sequence executed after user completes the task +- `.ActivateTask()` — pause workflow and wait for user action +- `.AddTask()` — register a task in the workflow spec (separate from activation) +- Re-activation — send the user back to the same task on validation failure + +## Approve/Reject Pattern + +1. Workflow starts, runs some service tasks +2. `.ActivateTask("Approve")` — workflow pauses +3. User sees the task in their inbox, assigns it, submits an answer +4. `.OnComplete` checks `payload.answer`: + - `"approve"` — run confirmation operations, convert to policy + - `"reject"` — cancel the application +5. If operations fail, re-activate the same task for correction + +## Variants + +- [C# Fluent DSL](csharp/) +- [Canonical JSON](json/) + +## Next + +[Tutorial 5: Sub-Workflows](../05-sub-workflows/) — inline vs fire-and-forget child workflows. + diff --git a/docs/workflow/tutorials/04-human-tasks/csharp/ApprovalWorkflow.cs b/docs/workflow/tutorials/04-human-tasks/csharp/ApprovalWorkflow.cs new file mode 100644 index 000000000..b25195abe --- /dev/null +++ b/docs/workflow/tutorials/04-human-tasks/csharp/ApprovalWorkflow.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using System.Text.Json; + +using WorkflowEngine.Abstractions; +using WorkflowEngine.Contracts; + +namespace WorkflowEngine.Tutorials; + +public sealed class ApprovalRequest +{ + public long PolicyId { get; set; } + public long AnnexId { get; set; } +} + +public sealed class ApprovalWorkflow : IDeclarativeWorkflow +{ + public string WorkflowName => "ApprovalExample"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Approval Example"; + public IReadOnlyCollection WorkflowRoles => ["DBA", "UR_UNDERWRITER"]; + + public WorkflowSpec Spec { get; } = WorkflowSpec + .For() + .InitializeState(WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")), + WorkflowExpr.Prop("annexId", WorkflowExpr.Path("start.annexId")))) + + // Register the task definition (separate from activation). + .AddTask(approveTask) + + // Start flow: validate, then activate the approval task. + .StartWith(flow => flow + .Call("Validate", + Address.LegacyRabbit("pas_policy_validate"), + WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))), + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete) + .ActivateTask("Approve Policy")) // pauses here + .Build(); + + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + // Define the human task with roles, payload, optional deadline, and OnComplete flow. + private static readonly WorkflowHumanTaskDefinition approveTask = + WorkflowHumanTask.For( + "Approve Policy", // task name + "PolicyApproval", // task type (UI component) + "business/policies", // route + taskRoles: ["UR_UNDERWRITER"]) // only underwriters + .WithPayload(WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId")), + WorkflowExpr.Prop("annexId", WorkflowExpr.Path("state.annexId")))) + .WithTimeout(86400) // 24-hour deadline (optional) + .OnComplete(BuildApprovalFlow); + + private static void BuildApprovalFlow(WorkflowFlowBuilder flow) + { + flow + // Store the user's answer in state for auditability. + .Set("answer", WorkflowExpr.Path("payload.answer")) + + // Branch on the answer. + .WhenPayloadEquals("answer", "reject", "Rejected?", + rejected => rejected + .Call("Cancel Application", + Address.LegacyRabbit("pas_annexprocessing_cancelaplorqt"), + WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))), + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete) + .Complete(), + + approved => approved + .Call("Perform Operations", + Address.LegacyRabbit("pas_operations_perform", + SerdicaLegacyRabbitMode.MicroserviceConsumer), + WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId")), + WorkflowExpr.Prop("stages", WorkflowExpr.Array( + WorkflowExpr.String("UNDERWRITING"), + WorkflowExpr.String("CONFIRMATION")))), + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete, + resultKey: "operations") + .Set("passed", WorkflowExpr.Path("result.operations.passed")) + + .WhenStateFlag("passed", true, "Operations Passed?", + passed => passed + .Call("Convert To Policy", + Address.LegacyRabbit("pas_polreg_convertapltopol"), + WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))), + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete) + .Complete(), + + // Operations failed: re-open the same task for the user to fix and retry. + failed => failed.ActivateTask("Approve Policy"))); + } +} diff --git a/docs/workflow/tutorials/04-human-tasks/json/approval-workflow.definition.json b/docs/workflow/tutorials/04-human-tasks/json/approval-workflow.definition.json new file mode 100644 index 000000000..bf3afd305 --- /dev/null +++ b/docs/workflow/tutorials/04-human-tasks/json/approval-workflow.definition.json @@ -0,0 +1,144 @@ +{ + "schemaVersion": "serdica.workflow.definition/v1", + "workflowName": "ApprovalExample", + "workflowVersion": "1.0.0", + "displayName": "Approval Example", + "workflowRoles": ["DBA", "UR_UNDERWRITER"], + "start": { + "initializeStateExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } }, + { "name": "annexId", "expression": { "$type": "path", "path": "start.annexId" } } + ] + }, + "sequence": { + "steps": [ + { + "$type": "call-transport", + "stepName": "Validate", + "invocation": { + "address": { "$type": "legacy-rabbit", "command": "pas_policy_validate", "mode": "Envelope" }, + "payloadExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } } + ] + } + }, + "whenFailure": { "steps": [{ "$type": "complete" }] }, + "whenTimeout": { "steps": [{ "$type": "complete" }] } + }, + { + "$type": "activate-task", + "taskName": "Approve Policy", + "timeoutSeconds": 86400 + } + ] + } + }, + "tasks": [ + { + "taskName": "Approve Policy", + "taskType": "PolicyApproval", + "routeExpression": { "$type": "string", "value": "business/policies" }, + "taskRoles": ["UR_UNDERWRITER"], + "payloadExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }, + { "name": "annexId", "expression": { "$type": "path", "path": "state.annexId" } } + ] + }, + "onCompleteSequence": { + "steps": [ + { "$type": "set-state", "stateKey": "answer", "valueExpression": { "$type": "path", "path": "payload.answer" } }, + { + "$type": "decision", + "decisionName": "Rejected?", + "conditionExpression": { + "$type": "binary", "operator": "eq", + "left": { "$type": "path", "path": "payload.answer" }, + "right": { "$type": "string", "value": "reject" } + }, + "whenTrue": { + "steps": [ + { + "$type": "call-transport", + "stepName": "Cancel Application", + "invocation": { + "address": { "$type": "legacy-rabbit", "command": "pas_annexprocessing_cancelaplorqt", "mode": "Envelope" }, + "payloadExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } } + ] + } + }, + "whenFailure": { "steps": [{ "$type": "complete" }] }, + "whenTimeout": { "steps": [{ "$type": "complete" }] } + }, + { "$type": "complete" } + ] + }, + "whenElse": { + "steps": [ + { + "$type": "call-transport", + "stepName": "Perform Operations", + "resultKey": "operations", + "invocation": { + "address": { "$type": "legacy-rabbit", "command": "pas_operations_perform", "mode": "MicroserviceConsumer" }, + "payloadExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }, + { "name": "stages", "expression": { "$type": "array", "items": [ + { "$type": "string", "value": "UNDERWRITING" }, + { "$type": "string", "value": "CONFIRMATION" } + ]}} + ] + } + }, + "whenFailure": { "steps": [{ "$type": "complete" }] }, + "whenTimeout": { "steps": [{ "$type": "complete" }] } + }, + { "$type": "set-state", "stateKey": "passed", "valueExpression": { "$type": "path", "path": "result.operations.passed" } }, + { + "$type": "decision", + "decisionName": "Operations Passed?", + "conditionExpression": { "$type": "path", "path": "state.passed" }, + "whenTrue": { + "steps": [ + { + "$type": "call-transport", + "stepName": "Convert To Policy", + "invocation": { + "address": { "$type": "legacy-rabbit", "command": "pas_polreg_convertapltopol", "mode": "Envelope" }, + "payloadExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } } + ] + } + }, + "whenFailure": { "steps": [{ "$type": "complete" }] }, + "whenTimeout": { "steps": [{ "$type": "complete" }] } + }, + { "$type": "complete" } + ] + }, + "whenElse": { + "steps": [ + { "$type": "activate-task", "taskName": "Approve Policy" } + ] + } + } + ] + } + } + ] + } + } + ] +} diff --git a/docs/workflow/tutorials/05-sub-workflows/README.md b/docs/workflow/tutorials/05-sub-workflows/README.md new file mode 100644 index 000000000..cbb0dbd5a --- /dev/null +++ b/docs/workflow/tutorials/05-sub-workflows/README.md @@ -0,0 +1,22 @@ +# Tutorial 5: Sub-Workflows & Continuations + +Compose workflows by invoking child workflows — either inline (SubWorkflow) or fire-and-forget (ContinueWith). + +## SubWorkflow vs ContinueWith + +| Feature | `.SubWorkflow()` | `.ContinueWith()` | +|---------|-----------------|-------------------| +| Parent waits | Yes — resumes after child completes | No — parent completes immediately | +| State flows back | Yes — child state merges into parent | No — child is independent | +| Same instance | Yes — tasks appear under parent instance | No — new workflow instance | +| Use when | Steps must complete before parent continues | Fire-and-forget, scheduled work | + +## Variants + +- [C# Fluent DSL](csharp/) +- [Canonical JSON](json/) + +## Next + +[Tutorial 6: Advanced Patterns](../06-advanced-patterns/) — Fork, Repeat, Timer, External Signal. + diff --git a/docs/workflow/tutorials/05-sub-workflows/csharp/SubWorkflowExample.cs b/docs/workflow/tutorials/05-sub-workflows/csharp/SubWorkflowExample.cs new file mode 100644 index 000000000..1273337f7 --- /dev/null +++ b/docs/workflow/tutorials/05-sub-workflows/csharp/SubWorkflowExample.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; + +using WorkflowEngine.Abstractions; +using WorkflowEngine.Contracts; + +namespace WorkflowEngine.Tutorials; + +public sealed class ParentWorkflow : IDeclarativeWorkflow +{ + public string WorkflowName => "ParentWorkflow"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Parent Workflow Example"; + public IReadOnlyCollection WorkflowRoles => []; + + public WorkflowSpec Spec { get; } = WorkflowSpec + .For() + .InitializeState(WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")))) + .StartWith(BuildFlow) + .Build(); + + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + private static void BuildFlow(WorkflowFlowBuilder flow) + { + flow + .Call("Open For Change", + Address.LegacyRabbit("pas_annexprocessing_alterpolicy"), + WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))), + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete) + + // --- SubWorkflow: inline execution, parent waits --- + // The child workflow runs within this execution. + // Its tasks appear under the parent instance. + // State from the child merges back into the parent after completion. + .SubWorkflow( + "Review Policy Changes", + new WorkflowWorkflowInvocationDeclaration + { + WorkflowName = "ReviewPolicyOpenForChange", + PayloadExpression = WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId")), + WorkflowExpr.Prop("productCode", WorkflowExpr.Path("state.productCode"))), + }) + // Execution resumes here after child completes. + + // --- ContinueWith: fire-and-forget --- + // The parent workflow completes immediately. + // A new independent workflow instance is created via the signal bus. + .ContinueWith( + "Start Transfer Process", + new WorkflowWorkflowInvocationDeclaration + { + WorkflowName = "TransferPolicy", + PayloadExpression = WorkflowExpr.Path("state"), + }); + // Parent is now complete. TransferPolicy runs independently. + } +} diff --git a/docs/workflow/tutorials/05-sub-workflows/json/sub-workflow.definition.json b/docs/workflow/tutorials/05-sub-workflows/json/sub-workflow.definition.json new file mode 100644 index 000000000..7b7cddfa4 --- /dev/null +++ b/docs/workflow/tutorials/05-sub-workflows/json/sub-workflow.definition.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": "serdica.workflow.definition/v1", + "workflowName": "ParentWorkflow", + "workflowVersion": "1.0.0", + "displayName": "Parent Workflow Example", + "workflowRoles": [], + "start": { + "initializeStateExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } } + ] + }, + "sequence": { + "steps": [ + { + "$type": "call-transport", + "stepName": "Open For Change", + "invocation": { + "address": { "$type": "legacy-rabbit", "command": "pas_annexprocessing_alterpolicy", "mode": "Envelope" }, + "payloadExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } } + ] + } + }, + "whenFailure": { "steps": [{ "$type": "complete" }] }, + "whenTimeout": { "steps": [{ "$type": "complete" }] } + }, + { + "$type": "sub-workflow", + "stepName": "Review Policy Changes", + "invocation": { + "workflowName": "ReviewPolicyOpenForChange", + "payloadExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }, + { "name": "productCode", "expression": { "$type": "path", "path": "state.productCode" } } + ] + } + } + }, + { + "$type": "continue-with-workflow", + "stepName": "Start Transfer Process", + "invocation": { + "workflowName": "TransferPolicy", + "payloadExpression": { "$type": "path", "path": "state" } + } + } + ] + } + }, + "tasks": [] +} diff --git a/docs/workflow/tutorials/06-advanced-patterns/README.md b/docs/workflow/tutorials/06-advanced-patterns/README.md new file mode 100644 index 000000000..35282fa15 --- /dev/null +++ b/docs/workflow/tutorials/06-advanced-patterns/README.md @@ -0,0 +1,22 @@ +# Tutorial 6: Advanced Patterns + +Fork (parallel branches), Repeat (retry loops), Timer (delays), and External Signal (wait for events). + +## Patterns + +| Pattern | Use When | +|---------|----------| +| **Fork** | Multiple independent operations that should run concurrently | +| **Repeat** | Retry a service call with backoff, poll until condition met | +| **Timer** | Delay between steps (backoff, scheduled processing) | +| **External Signal** | Wait for an external event (document upload, approval from another system) | + +## Variants + +- [C# Fluent DSL](csharp/) +- [Canonical JSON](json/) + +## Next + +[Tutorial 7: Shared Helpers](../07-shared-helpers/) — organizing reusable workflow components. + diff --git a/docs/workflow/tutorials/06-advanced-patterns/csharp/AdvancedPatternsWorkflow.cs b/docs/workflow/tutorials/06-advanced-patterns/csharp/AdvancedPatternsWorkflow.cs new file mode 100644 index 000000000..fd555fb47 --- /dev/null +++ b/docs/workflow/tutorials/06-advanced-patterns/csharp/AdvancedPatternsWorkflow.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; + +using WorkflowEngine.Abstractions; +using WorkflowEngine.Contracts; + +namespace WorkflowEngine.Tutorials; + +public sealed class AdvancedPatternsWorkflow : IDeclarativeWorkflow +{ + public string WorkflowName => "AdvancedPatterns"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Advanced Patterns Example"; + public IReadOnlyCollection WorkflowRoles => []; + + public WorkflowSpec Spec { get; } = WorkflowSpec + .For() + .InitializeState(WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")), + WorkflowExpr.Prop("retryAttempt", WorkflowExpr.Number(0)), + WorkflowExpr.Prop("integrationFailed", WorkflowExpr.Bool(false)))) + .StartWith(BuildFlow) + .Build(); + + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + private static void BuildFlow(WorkflowFlowBuilder flow) + { + flow + // ═══════════════════════════════════════════════ + // FORK: parallel branches + // ═══════════════════════════════════════════════ + // Both branches run concurrently. Workflow resumes after all complete. + .Fork("Process Documents and Notify", + documents => documents + .Call("Generate PDF", + Address.LegacyRabbit("pas_pdf_generate"), + WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))), + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete), + notification => notification + .Call("Send Email", + Address.LegacyRabbit("notifications_send_email", + SerdicaLegacyRabbitMode.MicroserviceConsumer), + WorkflowExpr.Object( + WorkflowExpr.Prop("to", WorkflowExpr.String("agent@company.com")), + WorkflowExpr.Prop("subject", WorkflowExpr.String("Policy processed"))), + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete)) + + // ═══════════════════════════════════════════════ + // REPEAT: retry loop with backoff + // ═══════════════════════════════════════════════ + // Retries up to 3 times while integrationFailed is true. + .Repeat( + "Retry Integration", + WorkflowExpr.Number(3), // max iterations + "retryAttempt", // counter state key + WorkflowExpr.Or( // continue while: + WorkflowExpr.Eq( // first attempt (counter == 0) + WorkflowExpr.Path("state.retryAttempt"), + WorkflowExpr.Number(0)), + WorkflowExpr.Path("state.integrationFailed")), // or previous attempt failed + body => body + .Set("integrationFailed", WorkflowExpr.Bool(false)) + .Call("Transfer Policy", + Address.Http("integration", "/api/transfer", "POST"), + WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))), + whenFailure: fail => fail + .Set("integrationFailed", WorkflowExpr.Bool(true)) + // TIMER: wait before retrying + .Wait("Backoff", WorkflowExpr.String("00:05:00")), + whenTimeout: timeout => timeout + .Set("integrationFailed", WorkflowExpr.Bool(true)))) + + // ═══════════════════════════════════════════════ + // EXTERNAL SIGNAL: wait for event + // ═══════════════════════════════════════════════ + // Workflow pauses until an external system raises the named signal. + .WaitForSignal( + "Wait for Document Upload", + signalName: "documents-ready", + resultKey: "uploadedDocs") + + // Use the signal payload in subsequent steps. + .Set("documentCount", + WorkflowExpr.Func("length", + WorkflowExpr.Path("result.uploadedDocs.fileIds"))) + .Complete(); + } +} diff --git a/docs/workflow/tutorials/06-advanced-patterns/json/advanced-patterns.definition.json b/docs/workflow/tutorials/06-advanced-patterns/json/advanced-patterns.definition.json new file mode 100644 index 000000000..76052535d --- /dev/null +++ b/docs/workflow/tutorials/06-advanced-patterns/json/advanced-patterns.definition.json @@ -0,0 +1,127 @@ +{ + "schemaVersion": "serdica.workflow.definition/v1", + "workflowName": "AdvancedPatterns", + "workflowVersion": "1.0.0", + "displayName": "Advanced Patterns Example", + "workflowRoles": [], + "start": { + "initializeStateExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } }, + { "name": "retryAttempt", "expression": { "$type": "number", "value": 0 } }, + { "name": "integrationFailed", "expression": { "$type": "boolean", "value": false } } + ] + }, + "sequence": { + "steps": [ + { + "$type": "fork", + "stepName": "Process Documents and Notify", + "branches": [ + { + "steps": [ + { + "$type": "call-transport", + "stepName": "Generate PDF", + "invocation": { + "address": { "$type": "legacy-rabbit", "command": "pas_pdf_generate", "mode": "Envelope" }, + "payloadExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } } + ] + } + }, + "whenFailure": { "steps": [{ "$type": "complete" }] }, + "whenTimeout": { "steps": [{ "$type": "complete" }] } + } + ] + }, + { + "steps": [ + { + "$type": "call-transport", + "stepName": "Send Email", + "invocation": { + "address": { "$type": "legacy-rabbit", "command": "notifications_send_email", "mode": "MicroserviceConsumer" }, + "payloadExpression": { + "$type": "object", + "properties": [ + { "name": "to", "expression": { "$type": "string", "value": "agent@company.com" } }, + { "name": "subject", "expression": { "$type": "string", "value": "Policy processed" } } + ] + } + }, + "whenFailure": { "steps": [{ "$type": "complete" }] }, + "whenTimeout": { "steps": [{ "$type": "complete" }] } + } + ] + } + ] + }, + { + "$type": "repeat", + "stepName": "Retry Integration", + "maxIterationsExpression": { "$type": "number", "value": 3 }, + "iterationStateKey": "retryAttempt", + "continueWhileExpression": { + "$type": "binary", "operator": "or", + "left": { + "$type": "binary", "operator": "eq", + "left": { "$type": "path", "path": "state.retryAttempt" }, + "right": { "$type": "number", "value": 0 } + }, + "right": { "$type": "path", "path": "state.integrationFailed" } + }, + "body": { + "steps": [ + { "$type": "set-state", "stateKey": "integrationFailed", "valueExpression": { "$type": "boolean", "value": false } }, + { + "$type": "call-transport", + "stepName": "Transfer Policy", + "invocation": { + "address": { "$type": "http", "target": "integration", "path": "/api/transfer", "method": "POST" }, + "payloadExpression": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } } + ] + } + }, + "whenFailure": { + "steps": [ + { "$type": "set-state", "stateKey": "integrationFailed", "valueExpression": { "$type": "boolean", "value": true } }, + { "$type": "timer", "stepName": "Backoff", "delayExpression": { "$type": "string", "value": "00:05:00" } } + ] + }, + "whenTimeout": { + "steps": [ + { "$type": "set-state", "stateKey": "integrationFailed", "valueExpression": { "$type": "boolean", "value": true } } + ] + } + } + ] + } + }, + { + "$type": "external-signal", + "stepName": "Wait for Document Upload", + "signalNameExpression": { "$type": "string", "value": "documents-ready" }, + "resultKey": "uploadedDocs" + }, + { + "$type": "set-state", + "stateKey": "documentCount", + "valueExpression": { + "$type": "function", + "functionName": "length", + "arguments": [{ "$type": "path", "path": "result.uploadedDocs.fileIds" }] + } + }, + { "$type": "complete" } + ] + } + }, + "tasks": [] +} diff --git a/docs/workflow/tutorials/07-shared-helpers/README.md b/docs/workflow/tutorials/07-shared-helpers/README.md new file mode 100644 index 000000000..41553f19c --- /dev/null +++ b/docs/workflow/tutorials/07-shared-helpers/README.md @@ -0,0 +1,24 @@ +# Tutorial 7: Shared Support Helpers + +When building many workflows for the same domain (e.g., 50+ policy change workflows), extract reusable components into a support helper class. + +## What to Extract + +| Component | Example | +|-----------|---------| +| **Address constants** | `LegacyRabbitAddress`, `HttpAddress` — centralized routing | +| **Workflow references** | `WorkflowReference` — for SubWorkflow/ContinueWith targets | +| **Payload builders** | Static methods returning `WorkflowExpressionDefinition` | +| **State initializers** | Base state + override pattern | +| **Flow extensions** | Extension methods on `WorkflowFlowBuilder` for common sequences | + +## C#-Only Tutorial + +This tutorial has no JSON equivalent — it covers C# code organization patterns. + +- [C# Example](csharp/) + +## Next + +[Tutorial 8: Expressions](../08-expressions/) — path navigation, functions, and operators. + diff --git a/docs/workflow/tutorials/07-shared-helpers/csharp/PolicyWorkflowSupport.cs b/docs/workflow/tutorials/07-shared-helpers/csharp/PolicyWorkflowSupport.cs new file mode 100644 index 000000000..d38675166 --- /dev/null +++ b/docs/workflow/tutorials/07-shared-helpers/csharp/PolicyWorkflowSupport.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; + +using WorkflowEngine.Abstractions; +using WorkflowEngine.Contracts; + +namespace WorkflowEngine.Tutorials; + +/// +/// Shared support helper for policy change workflows. +/// Centralizes addresses, payload builders, and reusable flow patterns. +/// +internal static class PolicyWorkflowSupport +{ + // ═══════════════════════════════════════════════════════════ + // ADDRESS REGISTRY + // Centralize all service routing in one place. + // ═══════════════════════════════════════════════════════════ + + internal static readonly LegacyRabbitAddress ValidatePolicyAddress = + new("pas_policy_validate"); + + internal static readonly LegacyRabbitAddress AlterPolicyAddress = + new("pas_annexprocessing_alterpolicy"); + + internal static readonly LegacyRabbitAddress CalculatePremiumAddress = + new("pas_premium_calculate_for_object", + SerdicaLegacyRabbitMode.MicroserviceConsumer); + + internal static readonly LegacyRabbitAddress GetAnnexDescAddress = + new("pas_polannexes_get"); + + internal static readonly LegacyRabbitAddress NotificationEmailAddress = + new("notifications_send_email", + SerdicaLegacyRabbitMode.MicroserviceConsumer); + + // ═══════════════════════════════════════════════════════════ + // WORKFLOW REFERENCES + // For SubWorkflow and ContinueWith invocations. + // ═══════════════════════════════════════════════════════════ + + internal static readonly WorkflowReference ReviewPolicyReference = + new("ReviewPolicyOpenForChange"); + + internal static readonly WorkflowReference TransferPolicyReference = + new("TransferPolicy"); + + // ═══════════════════════════════════════════════════════════ + // STATE INITIALIZATION + // Base state + override pattern for workflow families. + // ═══════════════════════════════════════════════════════════ + + /// + /// Builds a state initialization expression with common policy fields + /// and optional per-workflow overrides. + /// + internal static WorkflowExpressionDefinition BuildInitializeState( + params WorkflowNamedExpressionDefinition[] overrides) + { + var properties = new List + { + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")), + WorkflowExpr.Prop("srAnnexId", WorkflowExpr.Path("start.srAnnexId")), + WorkflowExpr.Prop("srCustId", WorkflowExpr.Path("start.srCustId")), + WorkflowExpr.Prop("annexType", WorkflowExpr.Path("start.annexType")), + WorkflowExpr.Prop("beginDate", WorkflowExpr.Path("start.beginDate")), + WorkflowExpr.Prop("endDate", WorkflowExpr.Path("start.endDate")), + }; + + // Apply overrides: replace existing or add new properties. + foreach (var o in overrides) + { + var existing = properties.FindIndex( + p => string.Equals(p.Name, o.Name, StringComparison.OrdinalIgnoreCase)); + if (existing >= 0) + { + properties[existing] = o; + } + else + { + properties.Add(o); + } + } + + return WorkflowExpr.Object(properties); + } + + // ═══════════════════════════════════════════════════════════ + // PAYLOAD BUILDERS + // Reusable expressions for common service call payloads. + // ═══════════════════════════════════════════════════════════ + + internal static WorkflowExpressionDefinition BuildAlterPolicyPayload() + { + return WorkflowExpr.Object( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId")), + WorkflowExpr.Prop("beginDate", WorkflowExpr.Path("state.beginDate")), + WorkflowExpr.Prop("endDate", WorkflowExpr.Path("state.endDate")), + WorkflowExpr.Prop("annexType", WorkflowExpr.Path("state.annexType"))); + } + + internal static WorkflowExpressionDefinition BuildAnnexTypeEquals(string type) + { + return WorkflowExpr.Eq( + WorkflowExpr.Func("upper", WorkflowExpr.Path("state.annexType")), + WorkflowExpr.String(type)); + } + + internal static WorkflowExpressionDefinition BuildPolicyIdPayload() + { + return WorkflowExpr.Object( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId"))); + } + + // ═══════════════════════════════════════════════════════════ + // WORKFLOW INVOCATION BUILDERS + // ═══════════════════════════════════════════════════════════ + + internal static WorkflowWorkflowInvocationDeclaration BuildReviewInvocation() + { + return new WorkflowWorkflowInvocationDeclaration + { + WorkflowName = ReviewPolicyReference.WorkflowName, + PayloadExpression = WorkflowExpr.Path("state"), + }; + } +} + +/// +/// Extension methods for common flow patterns. +/// Used across multiple workflows for DRY step sequences. +/// +internal static class PolicyWorkflowFlowExtensions +{ + /// + /// Applies product info from a service call result into workflow state. + /// + internal static WorkflowFlowBuilder ApplyProductInfo( + this WorkflowFlowBuilder flow, + string resultKey = "productInfo") + where T : class + { + return flow + .SetIfHasValue("productCode", + WorkflowExpr.Func("upper", + WorkflowExpr.Path($"result.{resultKey}.productCode"))) + .SetIfHasValue("lob", + WorkflowExpr.Func("upper", + WorkflowExpr.Path($"result.{resultKey}.lob"))) + .SetIfHasValue("contractType", + WorkflowExpr.Path($"result.{resultKey}.contractType")); + } + + /// + /// Standard "load product info and apply" pattern. + /// + internal static WorkflowFlowBuilder LoadAndApplyProductInfo( + this WorkflowFlowBuilder flow) + where T : class + { + return flow + .Call("Load Product Info", + Address.LegacyRabbit("pas_get_policy_product_info"), + PolicyWorkflowSupport.BuildPolicyIdPayload(), + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete, + resultKey: "productInfo") + .ApplyProductInfo(); + } +} diff --git a/docs/workflow/tutorials/08-expressions/README.md b/docs/workflow/tutorials/08-expressions/README.md new file mode 100644 index 000000000..506fdc383 --- /dev/null +++ b/docs/workflow/tutorials/08-expressions/README.md @@ -0,0 +1,36 @@ +# Tutorial 8: Expressions + +The expression system enables declarative logic that compiles to portable canonical JSON. All expressions are evaluable at runtime without recompilation. + +## Path Navigation + +| Prefix | Source | Example | +|--------|--------|---------| +| `start.*` | Start request fields | `start.policyId` | +| `state.*` | Mutable workflow state | `state.customerName` | +| `payload.*` | Task completion payload | `payload.answer` | +| `result.*` | Step result (by resultKey) | `result.productInfo.lob` | + +## Built-in Functions + +| Function | Description | Example | +|----------|-------------|---------| +| `coalesce` | First non-null | `coalesce(state.id, start.id, 0)` | +| `concat` | String join | `concat("Policy #", state.policyNo)` | +| `add` | Sum | `add(state.attempt, 1)` | +| `if` | Conditional | `if(state.isVip, "VIP", "Standard")` | +| `isNullOrWhiteSpace` | Null/empty check | `isNullOrWhiteSpace(state.name)` | +| `length` | String/array length | `length(state.items)` | +| `upper` | Uppercase | `upper(state.annexType)` | +| `first` | First array element | `first(state.objects)` | +| `mergeObjects` | Deep merge | `mergeObjects(state, payload)` | + +## Variants + +- [C# Expression Builder](csharp/) +- [JSON Expression Format](json/) + +## Next + +[Tutorial 9: Testing](../09-testing/) — unit test setup with recording transports. + diff --git a/docs/workflow/tutorials/08-expressions/csharp/ExpressionExamples.cs b/docs/workflow/tutorials/08-expressions/csharp/ExpressionExamples.cs new file mode 100644 index 000000000..73ce4f070 --- /dev/null +++ b/docs/workflow/tutorials/08-expressions/csharp/ExpressionExamples.cs @@ -0,0 +1,131 @@ +using WorkflowEngine.Abstractions; + +namespace WorkflowEngine.Tutorials; + +/// +/// Expression builder examples showing all expression types. +/// These examples are not a runnable workflow — they demonstrate the WorkflowExpr API. +/// +internal static class ExpressionExamples +{ + // ═══════════════════════════════════════════════ + // LITERALS + // ═══════════════════════════════════════════════ + + static readonly WorkflowExpressionDefinition nullExpr = WorkflowExpr.Null(); + static readonly WorkflowExpressionDefinition stringExpr = WorkflowExpr.String("hello"); + static readonly WorkflowExpressionDefinition numberExpr = WorkflowExpr.Number(42); + static readonly WorkflowExpressionDefinition boolExpr = WorkflowExpr.Bool(true); + + // ═══════════════════════════════════════════════ + // PATH NAVIGATION + // ═══════════════════════════════════════════════ + + // Read from start request + static readonly WorkflowExpressionDefinition fromStart = WorkflowExpr.Path("start.policyId"); + + // Read from workflow state + static readonly WorkflowExpressionDefinition fromState = WorkflowExpr.Path("state.customerName"); + + // Read from task completion payload + static readonly WorkflowExpressionDefinition fromPayload = WorkflowExpr.Path("payload.answer"); + + // Read from step result (requires resultKey on the Call step) + static readonly WorkflowExpressionDefinition fromResult = WorkflowExpr.Path("result.productInfo.lob"); + + // Nested path navigation + static readonly WorkflowExpressionDefinition nested = WorkflowExpr.Path("state.entityData.address.city"); + + // ═══════════════════════════════════════════════ + // OBJECT & ARRAY CONSTRUCTION + // ═══════════════════════════════════════════════ + + static readonly WorkflowExpressionDefinition obj = WorkflowExpr.Object( + WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId")), + WorkflowExpr.Prop("status", WorkflowExpr.String("ACTIVE")), + WorkflowExpr.Prop("tags", WorkflowExpr.Array( + WorkflowExpr.String("motor"), + WorkflowExpr.String("casco")))); + + // ═══════════════════════════════════════════════ + // COMPARISONS + // ═══════════════════════════════════════════════ + + static readonly WorkflowExpressionDefinition eq = WorkflowExpr.Eq( + WorkflowExpr.Path("state.status"), WorkflowExpr.String("APPROVED")); + static readonly WorkflowExpressionDefinition ne = WorkflowExpr.Ne( + WorkflowExpr.Path("state.status"), WorkflowExpr.String("REJECTED")); + static readonly WorkflowExpressionDefinition gt = WorkflowExpr.Gt( + WorkflowExpr.Path("state.premium"), WorkflowExpr.Number(1000)); + + // ═══════════════════════════════════════════════ + // BOOLEAN LOGIC + // ═══════════════════════════════════════════════ + + static readonly WorkflowExpressionDefinition not = WorkflowExpr.Not( + WorkflowExpr.Path("state.isRejected")); + static readonly WorkflowExpressionDefinition and = WorkflowExpr.And( + WorkflowExpr.Path("state.isValid"), + WorkflowExpr.Not(WorkflowExpr.Path("state.isRejected"))); + static readonly WorkflowExpressionDefinition or = WorkflowExpr.Or( + WorkflowExpr.Eq(WorkflowExpr.Path("state.status"), WorkflowExpr.String("APPROVED")), + WorkflowExpr.Eq(WorkflowExpr.Path("state.status"), WorkflowExpr.String("OVERRIDE"))); + + // ═══════════════════════════════════════════════ + // FUNCTION CALLS + // ═══════════════════════════════════════════════ + + // Coalesce: first non-null value + static readonly WorkflowExpressionDefinition coalesce = WorkflowExpr.Func("coalesce", + WorkflowExpr.Path("state.customerId"), + WorkflowExpr.Path("start.customerId"), + WorkflowExpr.Number(0)); + + // String concatenation + static readonly WorkflowExpressionDefinition concat = WorkflowExpr.Func("concat", + WorkflowExpr.String("Policy #"), + WorkflowExpr.Path("state.policyNo")); + + // Arithmetic + static readonly WorkflowExpressionDefinition increment = WorkflowExpr.Func("add", + WorkflowExpr.Func("coalesce", + WorkflowExpr.Path("state.attempt"), WorkflowExpr.Number(0)), + WorkflowExpr.Number(1)); + + // Conditional value + static readonly WorkflowExpressionDefinition conditional = WorkflowExpr.Func("if", + WorkflowExpr.Path("state.isVip"), + WorkflowExpr.String("VIP"), + WorkflowExpr.String("Standard")); + + // Uppercase + static readonly WorkflowExpressionDefinition upper = WorkflowExpr.Func("upper", + WorkflowExpr.Path("state.annexType")); + + // Null check + static readonly WorkflowExpressionDefinition nullCheck = WorkflowExpr.Func("isNullOrWhiteSpace", + WorkflowExpr.Path("state.integrationId")); + + // Array length + static readonly WorkflowExpressionDefinition length = WorkflowExpr.Func("length", + WorkflowExpr.Path("state.documents")); + + // ═══════════════════════════════════════════════ + // COMBINING EXPRESSIONS (real-world patterns) + // ═══════════════════════════════════════════════ + + // "Use integration customer ID if present, otherwise use lookup ID" + static readonly WorkflowExpressionDefinition resolveCustomerId = WorkflowExpr.Func("if", + WorkflowExpr.Not( + WorkflowExpr.Func("isNullOrWhiteSpace", + WorkflowExpr.Path("state.integrationCustomerId"))), + WorkflowExpr.Path("state.integrationCustomerId"), + WorkflowExpr.Path("state.lookupCustomerId")); + + // "Should we retry? (first attempt or previous failed, and not timed out)" + static readonly WorkflowExpressionDefinition shouldRetry = WorkflowExpr.Or( + WorkflowExpr.Eq(WorkflowExpr.Path("state.retryAttempt"), WorkflowExpr.Number(0)), + WorkflowExpr.And( + WorkflowExpr.Not(WorkflowExpr.Path("state.timedOut")), + WorkflowExpr.Path("state.integrationFailed"))); +} diff --git a/docs/workflow/tutorials/08-expressions/json/expression-examples.json b/docs/workflow/tutorials/08-expressions/json/expression-examples.json new file mode 100644 index 000000000..3a62ef226 --- /dev/null +++ b/docs/workflow/tutorials/08-expressions/json/expression-examples.json @@ -0,0 +1,166 @@ +{ + "_comment": "Expression examples in canonical JSON format. Each key shows a different expression pattern.", + + "literals": { + "null": { "$type": "null" }, + "string": { "$type": "string", "value": "hello" }, + "number": { "$type": "number", "value": 42 }, + "boolean": { "$type": "boolean", "value": true } + }, + + "paths": { + "fromStartRequest": { "$type": "path", "path": "start.policyId" }, + "fromState": { "$type": "path", "path": "state.customerName" }, + "fromPayload": { "$type": "path", "path": "payload.answer" }, + "fromResult": { "$type": "path", "path": "result.productInfo.lob" }, + "nestedPath": { "$type": "path", "path": "state.entityData.address.city" } + }, + + "objectConstruction": { + "$type": "object", + "properties": [ + { "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }, + { "name": "status", "expression": { "$type": "string", "value": "ACTIVE" } }, + { "name": "tags", "expression": { + "$type": "array", + "items": [ + { "$type": "string", "value": "motor" }, + { "$type": "string", "value": "casco" } + ] + }} + ] + }, + + "comparisons": { + "equals": { + "$type": "binary", "operator": "eq", + "left": { "$type": "path", "path": "state.status" }, + "right": { "$type": "string", "value": "APPROVED" } + }, + "notEquals": { + "$type": "binary", "operator": "ne", + "left": { "$type": "path", "path": "state.status" }, + "right": { "$type": "string", "value": "REJECTED" } + }, + "greaterThan": { + "$type": "binary", "operator": "gt", + "left": { "$type": "path", "path": "state.premium" }, + "right": { "$type": "number", "value": 1000 } + } + }, + + "booleanLogic": { + "not": { + "$type": "unary", "operator": "not", + "operand": { "$type": "path", "path": "state.isRejected" } + }, + "and": { + "$type": "binary", "operator": "and", + "left": { "$type": "path", "path": "state.isValid" }, + "right": { + "$type": "unary", "operator": "not", + "operand": { "$type": "path", "path": "state.isRejected" } + } + }, + "or": { + "$type": "binary", "operator": "or", + "left": { + "$type": "binary", "operator": "eq", + "left": { "$type": "path", "path": "state.status" }, + "right": { "$type": "string", "value": "APPROVED" } + }, + "right": { + "$type": "binary", "operator": "eq", + "left": { "$type": "path", "path": "state.status" }, + "right": { "$type": "string", "value": "OVERRIDE" } + } + } + }, + + "functions": { + "coalesce": { + "$type": "function", "functionName": "coalesce", + "arguments": [ + { "$type": "path", "path": "state.customerId" }, + { "$type": "path", "path": "start.customerId" }, + { "$type": "number", "value": 0 } + ] + }, + "concat": { + "$type": "function", "functionName": "concat", + "arguments": [ + { "$type": "string", "value": "Policy #" }, + { "$type": "path", "path": "state.policyNo" } + ] + }, + "increment": { + "$type": "function", "functionName": "add", + "arguments": [ + { + "$type": "function", "functionName": "coalesce", + "arguments": [ + { "$type": "path", "path": "state.attempt" }, + { "$type": "number", "value": 0 } + ] + }, + { "$type": "number", "value": 1 } + ] + }, + "conditional": { + "$type": "function", "functionName": "if", + "arguments": [ + { "$type": "path", "path": "state.isVip" }, + { "$type": "string", "value": "VIP" }, + { "$type": "string", "value": "Standard" } + ] + }, + "uppercase": { + "$type": "function", "functionName": "upper", + "arguments": [{ "$type": "path", "path": "state.annexType" }] + }, + "nullCheck": { + "$type": "function", "functionName": "isNullOrWhiteSpace", + "arguments": [{ "$type": "path", "path": "state.integrationId" }] + }, + "arrayLength": { + "$type": "function", "functionName": "length", + "arguments": [{ "$type": "path", "path": "state.documents" }] + } + }, + + "realWorldPatterns": { + "resolveCustomerId_comment": "Use integration customer ID if present, otherwise use lookup ID", + "resolveCustomerId": { + "$type": "function", "functionName": "if", + "arguments": [ + { + "$type": "unary", "operator": "not", + "operand": { + "$type": "function", "functionName": "isNullOrWhiteSpace", + "arguments": [{ "$type": "path", "path": "state.integrationCustomerId" }] + } + }, + { "$type": "path", "path": "state.integrationCustomerId" }, + { "$type": "path", "path": "state.lookupCustomerId" } + ] + }, + + "shouldRetry_comment": "First attempt or previous failed and not timed out", + "shouldRetry": { + "$type": "binary", "operator": "or", + "left": { + "$type": "binary", "operator": "eq", + "left": { "$type": "path", "path": "state.retryAttempt" }, + "right": { "$type": "number", "value": 0 } + }, + "right": { + "$type": "binary", "operator": "and", + "left": { + "$type": "unary", "operator": "not", + "operand": { "$type": "path", "path": "state.timedOut" } + }, + "right": { "$type": "path", "path": "state.integrationFailed" } + } + } + } +} diff --git a/docs/workflow/tutorials/09-testing/README.md b/docs/workflow/tutorials/09-testing/README.md new file mode 100644 index 000000000..49f1aafe4 --- /dev/null +++ b/docs/workflow/tutorials/09-testing/README.md @@ -0,0 +1,29 @@ +# Tutorial 9: Testing Your Workflow + +Write unit tests for workflows using `RecordingSerdicaLegacyRabbitTransport` and `TechnicalStyleWorkflowTestHelpers`. + +## Test Setup Pattern + +1. Create a recording transport with pre-configured responses +2. Build a test service provider via `TechnicalStyleWorkflowTestHelpers.CreateServiceProvider` +3. Resolve `WorkflowRuntimeService` from DI +4. Call `StartWorkflowAsync` with test payload +5. Assert: tasks created, transport calls made, state values correct +6. Optionally complete tasks and verify downstream behavior + +## What to Test + +| Scenario | Approach | +|----------|----------| +| Workflow starts correctly | Assert single open task after start | +| Service calls made in order | `transport.Invocations.Select(x => x.Command).Should().Equal(...)` | +| Rejection flow | Complete task with `"answer": "reject"`, verify cancel call | +| Approval flow | Complete with `"answer": "approve"`, verify conversion calls | +| Operations failure re-opens task | Check same task re-appears after operations return `passed: false` | +| Sub-workflow creates child tasks | Query tasks by child workflow name | +| Business reference set | `startResponse.BusinessReference.Key.Should().Be(...)` | + +## C#-Only Tutorial + +- [C# Test Examples](csharp/) + diff --git a/docs/workflow/tutorials/09-testing/csharp/WorkflowTests.cs b/docs/workflow/tutorials/09-testing/csharp/WorkflowTests.cs new file mode 100644 index 000000000..3d15eab47 --- /dev/null +++ b/docs/workflow/tutorials/09-testing/csharp/WorkflowTests.cs @@ -0,0 +1,196 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using WorkflowEngine.Abstractions; +using WorkflowEngine.Contracts; +using WorkflowEngine.Services; + +using FluentAssertions; + +using Microsoft.Extensions.DependencyInjection; + +using NUnit.Framework; + +namespace WorkflowEngine.Tutorials.Tests; + +[TestFixture] +public class WorkflowTestExamples +{ + // ═══════════════════════════════════════════════ + // BASIC: Start workflow and verify task created + // ═══════════════════════════════════════════════ + + [Test] + public async Task Workflow_WhenStarted_ShouldCreateOpenTask() + { + // 1. Configure fake transport responses + var transport = new RecordingSerdicaLegacyRabbitTransport() + .Respond("pas_policy_validate", new { valid = true }) + .Respond("pas_get_policy_product_info", new + { + productCode = "4704", + lob = "MOT", + contractType = "STANDARD", + }); + + // 2. Build test service provider + using var provider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider( + transport, + WorkflowRuntimeProviderNames.SerdicaEngine); + var runtimeService = provider.GetRequiredService(); + + // 3. Start the workflow + var start = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 12345L, + ["srAnnexId"] = 67890L, + ["srCustId"] = 11111L, + }, + }); + + // 4. Assert task was created + var tasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = start.WorkflowInstanceId, + Status = "Open", + }); + tasks.Tasks.Should().ContainSingle(); + tasks.Tasks.Single().TaskName.Should().Be("Approve Application"); + } + + // ═══════════════════════════════════════════════ + // VERIFY: Service calls made in order + // ═══════════════════════════════════════════════ + + [Test] + public async Task Workflow_WhenStarted_ShouldCallServicesInOrder() + { + var transport = new RecordingSerdicaLegacyRabbitTransport() + .Respond("pas_annexprocessing_alterpolicy", new + { + srPolicyId = 1L, + srAnnexId = 2L, + previouslyOpened = false, + }) + .Respond("pas_polclmparticipants_create", new { ok = true }) + .Respond("pas_premium_calculate_for_object", new { ok = true }, + SerdicaLegacyRabbitMode.MicroserviceConsumer) + .Respond("pas_polannexes_get", new + { + shortDescription = "Test annex", + policyNo = "POL-001", + }); + + using var provider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider( + transport, + WorkflowRuntimeProviderNames.SerdicaEngine); + var runtimeService = provider.GetRequiredService(); + + await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "AssistantAddAnnex", + Payload = new Dictionary + { + ["srPolicyId"] = 1L, + ["srAnnexId"] = 2L, + ["policyExistsOnIPAL"] = true, + ["annexPreviouslyOpened"] = false, + ["annexType"] = "BENEF", + ["entityData"] = new { srCustId = 3L }, + }, + }); + + // Verify exact call sequence + transport.Invocations.Select(x => x.Command) + .Should().Equal( + "pas_annexprocessing_alterpolicy", + "pas_polclmparticipants_create", + "pas_premium_calculate_for_object", + "pas_polannexes_get"); + } + + // ═══════════════════════════════════════════════ + // TASK COMPLETION: Approve/reject flows + // ═══════════════════════════════════════════════ + + [Test] + public async Task Workflow_WhenTaskCompleted_ShouldExecuteOnCompleteFlow() + { + var transport = new RecordingSerdicaLegacyRabbitTransport() + .Respond("pas_operations_perform", new { passed = true, nextStep = "CONTINUE" }, + SerdicaLegacyRabbitMode.MicroserviceConsumer) + .Respond("pas_polreg_convertapltopol", new { ok = true }) + .Respond("pas_annexprocessing_generatepolicyno", new { ok = true }) + .Respond("pas_get_policy_product_info", new + { + productCode = "4704", + lob = "MOT", + contractType = "STANDARD", + }); + + using var provider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider( + transport, + WorkflowRuntimeProviderNames.SerdicaEngine); + var runtimeService = provider.GetRequiredService(); + + // Start workflow + var start = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 1L, + ["srAnnexId"] = 2L, + ["srCustId"] = 3L, + }, + }); + + // Get the open task + var task = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = start.WorkflowInstanceId, + Status = "Open", + })).Tasks.Single(); + + // Complete with "approve" + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = task.WorkflowTaskId, + ActorId = "test-user", + ActorRoles = ["DBA"], + Payload = new Dictionary { ["answer"] = "approve" }, + }); + + // Verify operations and conversion were called + transport.Invocations.Should().Contain(x => x.Command == "pas_operations_perform"); + transport.Invocations.Should().Contain(x => x.Command == "pas_polreg_convertapltopol"); + } + + // ═══════════════════════════════════════════════ + // RECORDING TRANSPORT: multiple responses + // ═══════════════════════════════════════════════ + + [Test] + public void RecordingTransport_CanConfigureMultipleResponses() + { + var transport = new RecordingSerdicaLegacyRabbitTransport() + // Default mode (Envelope) + .Respond("command_a", new { result = "first" }) + // Specific mode + .Respond("command_b", new { result = "second" }, + SerdicaLegacyRabbitMode.MicroserviceConsumer) + // Same command, different responses (returned in order) + .Respond("command_c", new { attempt = 1 }) + .Respond("command_c", new { attempt = 2 }); + + // After workflow execution, inspect: + // transport.Invocations — list of all calls made + // transport.Invocations[0].Command — command name + // transport.Invocations[0].Payload — request payload + transport.Should().NotBeNull(); + } +} diff --git a/docs/workflow/tutorials/README.md b/docs/workflow/tutorials/README.md new file mode 100644 index 000000000..8ea6dcc97 --- /dev/null +++ b/docs/workflow/tutorials/README.md @@ -0,0 +1,32 @@ +# Workflow Declaration Tutorials + +Step-by-step tutorials for building workflows with the Serdica Workflow Engine. Each tutorial is available in both **C# fluent DSL** and **canonical JSON** variants. + +## Reference Documentation + +- [Engine Reference Manual](../ENGINE.md) - Architecture, configuration, service surface, timeout model, signal system +- [Fluent DSL Syntax Guide](../workflow-fluent-syntax-guide.md) - Complete DSL method reference + +## Tutorials + +| # | Tutorial | C# | JSON | Topics | +|---|---------|-----|------|--------| +| 01 | [Hello World](01-hello-world/) | [C#](01-hello-world/csharp/) | [JSON](01-hello-world/json/) | Minimal workflow, single task, state init | +| 02 | [Service Tasks](02-service-tasks/) | [C#](02-service-tasks/csharp/) | [JSON](02-service-tasks/json/) | Transport calls, addresses, failure/timeout handling | +| 03 | [Decisions](03-decisions/) | [C#](03-decisions/csharp/) | [JSON](03-decisions/json/) | WhenExpression, WhenStateFlag, nested branching | +| 04 | [Human Tasks](04-human-tasks/) | [C#](04-human-tasks/csharp/) | [JSON](04-human-tasks/json/) | Approve/reject, OnComplete, re-activation, deadlines | +| 05 | [Sub-Workflows](05-sub-workflows/) | [C#](05-sub-workflows/csharp/) | [JSON](05-sub-workflows/json/) | SubWorkflow vs ContinueWith, state flow | +| 06 | [Advanced Patterns](06-advanced-patterns/) | [C#](06-advanced-patterns/csharp/) | [JSON](06-advanced-patterns/json/) | Fork, Repeat, Timer, External Signal | +| 07 | [Shared Helpers](07-shared-helpers/) | [C#](07-shared-helpers/csharp/) | - | Address registries, payload builders, extensions | +| 08 | [Expressions](08-expressions/) | [C#](08-expressions/csharp/) | [JSON](08-expressions/json/) | Path navigation, functions, operators | +| 09 | [Testing](09-testing/) | [C#](09-testing/csharp/) | - | Recording transports, task completion, assertions | + +## How to Read + +Each tutorial folder contains: +- **`README.md`** - Explanation, concepts, and what to expect +- **`csharp/`** - C# fluent DSL examples +- **`json/`** - Equivalent canonical JSON definitions (where applicable) + +Start with Tutorial 01 and progress sequentially. Tutorials 07 (Shared Helpers) and 09 (Testing) are C#-only since they cover code organization and test infrastructure. + diff --git a/src/Workflow/StellaOps.Workflow.WebService/Endpoints/WorkflowEndpoints.cs b/src/Workflow/StellaOps.Workflow.WebService/Endpoints/WorkflowEndpoints.cs new file mode 100644 index 000000000..da6b9fd5b --- /dev/null +++ b/src/Workflow/StellaOps.Workflow.WebService/Endpoints/WorkflowEndpoints.cs @@ -0,0 +1,436 @@ +using System; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; +using StellaOps.Workflow.Engine.Exceptions; +using StellaOps.Workflow.Engine.Services; + +namespace StellaOps.Workflow.WebService.Endpoints; + +internal static class WorkflowEndpoints +{ + public static RouteGroupBuilder MapWorkflowEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/workflow"); + + // ──────────────────────────────────────────────────── + // Workflow runtime + // ──────────────────────────────────────────────────── + group.MapPost("/start", StartWorkflow); + group.MapGet("/instances/{id}", GetInstance); + group.MapGet("/instances", GetInstances); + group.MapGet("/tasks/{id}", GetTask); + group.MapGet("/tasks", GetTasks); + group.MapPost("/tasks/{id}/complete", CompleteTask); + group.MapPost("/tasks/{id}/assign", AssignTask); + group.MapPost("/tasks/{id}/release", ReleaseTask); + group.MapPost("/signals/raise", RaiseSignal); + + // ──────────────────────────────────────────────────── + // Definition query (in-memory catalog) + // ──────────────────────────────────────────────────── + group.MapGet("/definitions", GetDefinitions); + + // ──────────────────────────────────────────────────── + // Definition deployment (store-backed) + // ──────────────────────────────────────────────────── + group.MapGet("/definitions/{id}", GetDefinitionById); + group.MapPost("/definitions/import", ImportDefinition); + group.MapPost("/definitions/export", ExportDefinition); + group.MapGet("/definitions/{name}/versions", GetDefinitionVersions); + group.MapPost("/definitions/{name}/activate", ActivateDefinition); + + // ──────────────────────────────────────────────────── + // Diagrams + // ──────────────────────────────────────────────────── + group.MapGet("/diagrams/{name}", GetDiagram); + + // ──────────────────────────────────────────────────── + // Canonical schema & validation + // ──────────────────────────────────────────────────── + group.MapGet("/canonical/schema", GetCanonicalSchema); + group.MapPost("/canonical/validate", ValidateCanonical); + + // ──────────────────────────────────────────────────── + // Operational: functions, metadata, retention + // ──────────────────────────────────────────────────── + group.MapGet("/functions", GetFunctionCatalog); + group.MapGet("/metadata", GetServiceMetadata); + group.MapPost("/retention/run", RunRetention); + + // ──────────────────────────────────────────────────── + // Signals: dead letters & pump telemetry + // ──────────────────────────────────────────────────── + group.MapGet("/signals/dead-letters", GetDeadLetters); + group.MapPost("/signals/dead-letters/replay", ReplayDeadLetters); + group.MapGet("/signals/pump/stats", GetSignalPumpStats); + + return group; + } + + // ════════════════════════════════════════════════════════ + // Runtime handlers + // ════════════════════════════════════════════════════════ + + private static async Task StartWorkflow( + StartWorkflowRequest request, + WorkflowRuntimeService runtimeService, + CancellationToken cancellationToken) + { + try + { + var response = await runtimeService.StartWorkflowAsync(request, cancellationToken); + return Results.Ok(response); + } + catch (BaseResultException ex) + { + return Results.BadRequest(new { error = ex.MessageId, message = ex.Message }); + } + } + + private static async Task GetInstance( + string id, + WorkflowRuntimeService runtimeService, + string? actorId, + CancellationToken cancellationToken) + { + var response = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = id, + ActorId = actorId, + }, cancellationToken); + return Results.Ok(response); + } + + private static async Task GetInstances( + WorkflowRuntimeService runtimeService, + string? workflowName, + string? workflowVersion, + string? workflowInstanceId, + string? businessReferenceKey, + string? status, + bool? includeDetails, + CancellationToken cancellationToken) + { + var response = await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest + { + WorkflowName = workflowName, + WorkflowVersion = workflowVersion, + WorkflowInstanceId = workflowInstanceId, + BusinessReferenceKey = businessReferenceKey, + Status = status, + IncludeDetails = includeDetails ?? false, + }, cancellationToken); + return Results.Ok(response); + } + + private static async Task GetTask( + string id, + WorkflowRuntimeService runtimeService, + string? actorId, + CancellationToken cancellationToken) + { + var response = await runtimeService.GetTaskAsync(new WorkflowTaskGetRequest + { + WorkflowTaskId = id, + ActorId = actorId, + }, cancellationToken); + return Results.Ok(response); + } + + private static async Task GetTasks( + WorkflowRuntimeService runtimeService, + string? workflowName, + string? workflowVersion, + string? workflowInstanceId, + string? businessReferenceKey, + string? assignee, + string? status, + string? actorId, + CancellationToken cancellationToken) + { + var response = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowName = workflowName, + WorkflowVersion = workflowVersion, + WorkflowInstanceId = workflowInstanceId, + BusinessReferenceKey = businessReferenceKey, + Assignee = assignee, + Status = status, + ActorId = actorId, + }, cancellationToken); + return Results.Ok(response); + } + + private static async Task CompleteTask( + string id, + WorkflowTaskCompleteRequest request, + WorkflowRuntimeService runtimeService, + CancellationToken cancellationToken) + { + var response = await runtimeService.CompleteTaskAsync(request with + { + WorkflowTaskId = id, + }, cancellationToken); + return Results.Ok(response); + } + + private static async Task AssignTask( + string id, + WorkflowTaskAssignRequest request, + WorkflowRuntimeService runtimeService, + CancellationToken cancellationToken) + { + var response = await runtimeService.AssignTaskAsync(request with + { + WorkflowTaskId = id, + }, cancellationToken); + return Results.Ok(response); + } + + private static async Task ReleaseTask( + string id, + WorkflowTaskReleaseRequest request, + WorkflowRuntimeService runtimeService, + CancellationToken cancellationToken) + { + var response = await runtimeService.ReleaseTaskAsync(request with + { + WorkflowTaskId = id, + }, cancellationToken); + return Results.Ok(response); + } + + private static async Task RaiseSignal( + WorkflowSignalRaiseRequest request, + WorkflowRuntimeService runtimeService, + CancellationToken cancellationToken) + { + var response = await runtimeService.RaiseExternalSignalAsync(request, cancellationToken); + return Results.Ok(response); + } + + // ════════════════════════════════════════════════════════ + // Definition query handlers (in-memory catalog) + // ════════════════════════════════════════════════════════ + + private static IResult GetDefinitions( + IWorkflowDefinitionCatalog definitionCatalog, + string? workflowName, + string? workflowVersion) + { + var definitions = definitionCatalog.GetDefinitions().AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(workflowName)) + { + definitions = definitions.Where(x => + string.Equals(x.WorkflowName, workflowName, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(workflowVersion)) + { + definitions = definitions.Where(x => + string.Equals(x.WorkflowVersion, workflowVersion, StringComparison.OrdinalIgnoreCase)); + } + + return Results.Ok(new WorkflowDefinitionGetResponse + { + Definitions = definitions.ToArray(), + }); + } + + // ════════════════════════════════════════════════════════ + // Definition deployment handlers (store-backed) + // ════════════════════════════════════════════════════════ + + private static async Task GetDefinitionById( + string id, + WorkflowDefinitionDeploymentService deploymentService, + bool? includeRendering, + CancellationToken cancellationToken) + { + var response = await deploymentService.GetDefinitionByIdAsync(new WorkflowDefinitionByIdRequest + { + WorkflowName = id, + IncludeRendering = includeRendering ?? false, + }, cancellationToken); + return Results.Ok(response); + } + + private static async Task ImportDefinition( + WorkflowDefinitionImportRequest request, + WorkflowDefinitionDeploymentService deploymentService, + CancellationToken cancellationToken) + { + var response = await deploymentService.ImportAsync(request, cancellationToken); + return Results.Ok(response); + } + + private static async Task ExportDefinition( + WorkflowDefinitionExportRequest request, + WorkflowDefinitionDeploymentService deploymentService, + CancellationToken cancellationToken) + { + var response = await deploymentService.ExportAsync(request, cancellationToken); + return Results.Ok(response); + } + + private static async Task GetDefinitionVersions( + string name, + WorkflowDefinitionDeploymentService deploymentService, + CancellationToken cancellationToken) + { + var response = await deploymentService.GetVersionsAsync(new WorkflowDefinitionVersionsGetRequest + { + WorkflowName = name, + }, cancellationToken); + return Results.Ok(response); + } + + private static async Task ActivateDefinition( + string name, + WorkflowDefinitionActivateRequest request, + WorkflowDefinitionDeploymentService deploymentService, + CancellationToken cancellationToken) + { + var response = await deploymentService.ActivateAsync(request with + { + WorkflowName = name, + }, cancellationToken); + return Results.Ok(response); + } + + // ════════════════════════════════════════════════════════ + // Diagram handlers + // ════════════════════════════════════════════════════════ + + private static async Task GetDiagram( + string name, + WorkflowDiagramService diagramService, + string? workflowVersion, + string? workflowInstanceId, + string? layoutProvider, + string? layoutEffort, + int? layoutOrderingIterations, + int? layoutPlacementIterations, + CancellationToken cancellationToken) + { + var response = await diagramService.GetDiagramAsync(new WorkflowDiagramGetRequest + { + WorkflowName = name, + WorkflowVersion = workflowVersion, + WorkflowInstanceId = workflowInstanceId, + LayoutProvider = layoutProvider, + LayoutEffort = layoutEffort, + LayoutOrderingIterations = layoutOrderingIterations, + LayoutPlacementIterations = layoutPlacementIterations, + }, cancellationToken); + return Results.Ok(response); + } + + // ════════════════════════════════════════════════════════ + // Canonical schema & validation handlers + // ════════════════════════════════════════════════════════ + + private static IResult GetCanonicalSchema( + WorkflowCanonicalDefinitionService canonicalService) + { + var response = canonicalService.GetSchema(); + return Results.Ok(response); + } + + private static IResult ValidateCanonical( + WorkflowCanonicalValidateRequest request, + WorkflowCanonicalDefinitionService canonicalService) + { + var response = canonicalService.Validate(request); + return Results.Ok(response); + } + + // ════════════════════════════════════════════════════════ + // Operational handlers + // ════════════════════════════════════════════════════════ + + private static IResult GetFunctionCatalog( + WorkflowFunctionCatalogService functionCatalogService) + { + var response = functionCatalogService.GetCatalog(); + return Results.Ok(response); + } + + private static IResult GetServiceMetadata() + { + var response = new WorkflowServiceMetadataGetResponse + { + Metadata = new WorkflowServiceMetadata + { + ServiceName = "StellaOps.Workflow.WebService", + DiagramProvider = "ElkSharp", + SupportsDefinitionInspection = true, + SupportsInstanceInspection = true, + SupportsCanonicalSchemaInspection = true, + SupportsCanonicalImportValidation = true, + }, + }; + return Results.Ok(response); + } + + private static async Task RunRetention( + WorkflowRetentionRunRequest request, + WorkflowRetentionService retentionService, + CancellationToken cancellationToken) + { + var result = await retentionService.RunAsync(request.ReferenceUtc, cancellationToken); + var response = new WorkflowRetentionRunResponse + { + ExecutedOnUtc = DateTime.UtcNow, + StaleInstancesMarked = result.StaleInstancesMarked, + StaleTasksMarked = result.StaleTasksMarked, + PurgedInstances = result.PurgedInstances, + PurgedTasks = result.PurgedTasks, + PurgedTaskEvents = result.PurgedTaskEvents, + PurgedRuntimeStates = result.PurgedRuntimeStates, + }; + return Results.Ok(response); + } + + // ════════════════════════════════════════════════════════ + // Signal dead-letter & pump telemetry handlers + // ════════════════════════════════════════════════════════ + + private static async Task GetDeadLetters( + WorkflowSignalDeadLetterService deadLetterService, + string? signalId, + string? workflowInstanceId, + string? signalType, + int? maxMessages, + bool? includeRawPayload, + CancellationToken cancellationToken) + { + var response = await deadLetterService.GetMessagesAsync(new WorkflowSignalDeadLettersGetRequest + { + SignalId = signalId, + WorkflowInstanceId = workflowInstanceId, + SignalType = signalType, + MaxMessages = maxMessages ?? 50, + IncludeRawPayload = includeRawPayload ?? false, + }, cancellationToken); + return Results.Ok(response); + } + + private static async Task ReplayDeadLetters( + WorkflowSignalDeadLetterReplayRequest request, + WorkflowSignalDeadLetterService deadLetterService, + CancellationToken cancellationToken) + { + var response = await deadLetterService.ReplayAsync(request, cancellationToken); + return Results.Ok(response); + } + + private static IResult GetSignalPumpStats( + WorkflowSignalPumpTelemetryService telemetryService) + { + var response = telemetryService.GetStats(); + return Results.Ok(response); + } +} diff --git a/src/Workflow/StellaOps.Workflow.WebService/Program.cs b/src/Workflow/StellaOps.Workflow.WebService/Program.cs new file mode 100644 index 000000000..47c3163e0 --- /dev/null +++ b/src/Workflow/StellaOps.Workflow.WebService/Program.cs @@ -0,0 +1,35 @@ +using StellaOps.Workflow.DataStore.MongoDB; +using StellaOps.Workflow.Engine.Authorization; +using StellaOps.Workflow.Engine.Services; +using StellaOps.Workflow.Signaling.Redis; +using StellaOps.Workflow.WebService.Endpoints; + +var builder = WebApplication.CreateBuilder(args); + +// Core workflow engine services (runtime, definitions, signals, rendering, etc.) +builder.Services.AddWorkflowEngineCoreServices(builder.Configuration); + +// Workflow engine hosted services (signal pump, retention background jobs) +builder.Services.AddWorkflowEngineHostedServices(); + +// Authorization service (required by WorkflowRuntimeService) +builder.Services.AddScoped(); + +// MongoDB data store (projection store, runtime state, signals, dead letters, etc.) +builder.Services.AddWorkflowMongoDataStore(builder.Configuration); + +// Redis signaling driver (wake notifications across instances) +builder.Services.AddWorkflowRedisSignaling(builder.Configuration); + +// Rendering layout engines can be registered here when available: +// builder.Services.AddWorkflowElkSharpRenderer(); +// builder.Services.AddWorkflowSvgRenderer(); + +var app = builder.Build(); + +// Map all workflow API endpoints under /api/workflow +app.MapWorkflowEndpoints(); + +await app.RunAsync(); + +public partial class Program { } diff --git a/src/Workflow/StellaOps.Workflow.WebService/Properties/launchSettings.json b/src/Workflow/StellaOps.Workflow.WebService/Properties/launchSettings.json new file mode 100644 index 000000000..36ccd4dec --- /dev/null +++ b/src/Workflow/StellaOps.Workflow.WebService/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "StellaOps.Workflow.Host": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:49940;http://localhost:49941" + } + } +} \ No newline at end of file diff --git a/src/Workflow/StellaOps.Workflow.WebService/StellaOps.Workflow.WebService.csproj b/src/Workflow/StellaOps.Workflow.WebService/StellaOps.Workflow.WebService.csproj new file mode 100644 index 000000000..99f65e746 --- /dev/null +++ b/src/Workflow/StellaOps.Workflow.WebService/StellaOps.Workflow.WebService.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + Exe + + + + + + + + + + + + + + + + + + + diff --git a/src/Workflow/StellaOps.Workflow.WebService/appsettings.json b/src/Workflow/StellaOps.Workflow.WebService/appsettings.json new file mode 100644 index 000000000..bf5bcc462 --- /dev/null +++ b/src/Workflow/StellaOps.Workflow.WebService/appsettings.json @@ -0,0 +1,33 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { "Name": "Console" } + ] + }, + "WorkflowBackend": { + "Provider": "Mongo", + "Mongo": { + "ConnectionStringName": "WorkflowMongo", + "DatabaseName": "stellaops_workflow" + } + }, + "WorkflowSignalDriver": { + "Provider": "Native" + }, + "ConnectionStrings": { + "WorkflowMongo": "mongodb://localhost:27017" + } +} diff --git a/src/Workflow/StellaOps.Workflow.slnx b/src/Workflow/StellaOps.Workflow.slnx new file mode 100644 index 000000000..67e2f4912 --- /dev/null +++ b/src/Workflow/StellaOps.Workflow.slnx @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowCanonicalDefinitionApi.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowCanonicalDefinitionApi.cs new file mode 100644 index 000000000..5bbd8d7c0 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowCanonicalDefinitionApi.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// Canonical workflow definition operations: retrieving the JSON schema and +/// validating canonical definition payloads. +/// +public interface IWorkflowCanonicalDefinitionApi +{ + Task GetSchemaAsync( + CancellationToken cancellationToken = default); + + Task ValidateAsync( + WorkflowCanonicalValidateRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowDefinitionDeploymentApi.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowDefinitionDeploymentApi.cs new file mode 100644 index 000000000..89baf9a7c --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowDefinitionDeploymentApi.cs @@ -0,0 +1,37 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// Workflow definition deployment operations: export, import, activate, version listing, +/// single definition lookup, and rendering. +/// +public interface IWorkflowDefinitionDeploymentApi +{ + Task ExportAsync( + WorkflowDefinitionExportRequest request, + CancellationToken cancellationToken = default); + + Task ImportAsync( + WorkflowDefinitionImportRequest request, + CancellationToken cancellationToken = default); + + Task ActivateAsync( + WorkflowDefinitionActivateRequest request, + CancellationToken cancellationToken = default); + + Task GetVersionsAsync( + WorkflowDefinitionVersionsGetRequest request, + CancellationToken cancellationToken = default); + + Task GetDefinitionByIdAsync( + WorkflowDefinitionByIdRequest request, + CancellationToken cancellationToken = default); + + Task RenderAsync( + WorkflowRenderingRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowDefinitionQueryApi.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowDefinitionQueryApi.cs new file mode 100644 index 000000000..d4019af24 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowDefinitionQueryApi.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// Workflow definition query operations: listing registered workflow definitions. +/// +public interface IWorkflowDefinitionQueryApi +{ + Task GetDefinitionsAsync( + WorkflowDefinitionGetRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowDiagramApi.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowDiagramApi.cs new file mode 100644 index 000000000..ed8e7f1b9 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowDiagramApi.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// Workflow diagram operations: retrieving the visual graph representation of a workflow. +/// +public interface IWorkflowDiagramApi +{ + Task GetDiagramAsync( + WorkflowDiagramGetRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowFunctionCatalogApi.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowFunctionCatalogApi.cs new file mode 100644 index 000000000..dab96e23b --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowFunctionCatalogApi.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// Workflow function catalog operations: retrieving the canonical function catalog +/// and installed workflow modules. +/// +public interface IWorkflowFunctionCatalogApi +{ + Task GetCatalogAsync( + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowRetentionApi.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowRetentionApi.cs new file mode 100644 index 000000000..b5353c49a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowRetentionApi.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// Workflow retention operations: running cleanup and purge of stale workflow data. +/// +public interface IWorkflowRetentionApi +{ + Task RunRetentionAsync( + WorkflowRetentionRunRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowRuntimeApi.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowRuntimeApi.cs new file mode 100644 index 000000000..7ef9122d9 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowRuntimeApi.cs @@ -0,0 +1,49 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// Core workflow runtime operations: starting workflows, querying instances and tasks, +/// completing/assigning/releasing tasks, and raising external signals. +/// +public interface IWorkflowRuntimeApi +{ + Task StartWorkflowAsync( + StartWorkflowRequest request, + CancellationToken cancellationToken = default); + + Task GetInstanceAsync( + WorkflowInstanceGetRequest request, + CancellationToken cancellationToken = default); + + Task GetInstancesAsync( + WorkflowInstancesGetRequest request, + CancellationToken cancellationToken = default); + + Task GetTaskAsync( + WorkflowTaskGetRequest request, + CancellationToken cancellationToken = default); + + Task GetTasksAsync( + WorkflowTasksGetRequest request, + CancellationToken cancellationToken = default); + + Task CompleteTaskAsync( + WorkflowTaskCompleteRequest request, + CancellationToken cancellationToken = default); + + Task AssignTaskAsync( + WorkflowTaskAssignRequest request, + CancellationToken cancellationToken = default); + + Task ReleaseTaskAsync( + WorkflowTaskReleaseRequest request, + CancellationToken cancellationToken = default); + + Task RaiseExternalSignalAsync( + WorkflowSignalRaiseRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowServiceMetadataApi.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowServiceMetadataApi.cs new file mode 100644 index 000000000..87683a647 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowServiceMetadataApi.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// Workflow service metadata operations: retrieving operational metadata about +/// the running workflow service instance. +/// +public interface IWorkflowServiceMetadataApi +{ + Task GetMetadataAsync( + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowSignalDeadLetterApi.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowSignalDeadLetterApi.cs new file mode 100644 index 000000000..6a3ee63c5 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowSignalDeadLetterApi.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// Workflow signal dead-letter operations: listing and replaying dead-lettered signals. +/// +public interface IWorkflowSignalDeadLetterApi +{ + Task GetMessagesAsync( + WorkflowSignalDeadLettersGetRequest request, + CancellationToken cancellationToken = default); + + Task ReplayAsync( + WorkflowSignalDeadLetterReplayRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowSignalPumpTelemetryApi.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowSignalPumpTelemetryApi.cs new file mode 100644 index 000000000..e128d1bc7 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowSignalPumpTelemetryApi.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// Workflow signal pump telemetry operations: retrieving in-process statistics +/// for the workflow signal pump. +/// +public interface IWorkflowSignalPumpTelemetryApi +{ + Task GetStatsAsync( + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/StellaOps.Workflow.Abstractions.csproj b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/StellaOps.Workflow.Abstractions.csproj new file mode 100644 index 000000000..feac6a866 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/StellaOps.Workflow.Abstractions.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowAuthorizationAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowAuthorizationAbstractions.cs new file mode 100644 index 000000000..9d87c89d7 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowAuthorizationAbstractions.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace StellaOps.Workflow.Abstractions; + +public enum WorkflowTaskAction +{ + AssignSelf, + AssignOther, + AssignRoles, + Release, + Complete, +} + +public sealed record WorkflowAssignmentPermissionContext +{ + public required WorkflowTaskAction Action { get; init; } + public required string ActorId { get; init; } + public string? CurrentAssignee { get; init; } + public string? TargetUserId { get; init; } + public IReadOnlyCollection TargetRoles { get; init; } = []; + public IReadOnlyCollection ActorRoles { get; init; } = []; + public IReadOnlyCollection EffectiveRoles { get; init; } = []; +} + +public sealed record WorkflowAssignmentPermissionDecision +{ + public required bool Allowed { get; init; } + public string? Reason { get; init; } +} + +public interface IWorkflowAssignmentPermissionEvaluator +{ + WorkflowAssignmentPermissionDecision Evaluate(WorkflowAssignmentPermissionContext context); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendConfigurationExtensions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendConfigurationExtensions.cs new file mode 100644 index 000000000..32feae3a8 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendConfigurationExtensions.cs @@ -0,0 +1,18 @@ +using System; + +using Microsoft.Extensions.Configuration; + +namespace StellaOps.Workflow.Abstractions; + +public static class WorkflowBackendConfigurationExtensions +{ + public static string GetWorkflowBackendProvider(this IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + var providerName = configuration.GetSection(WorkflowBackendOptions.SectionName)[nameof(WorkflowBackendOptions.Provider)]; + return string.IsNullOrWhiteSpace(providerName) + ? WorkflowBackendNames.Oracle + : providerName; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendModuleAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendModuleAbstractions.cs new file mode 100644 index 000000000..19a9af87a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendModuleAbstractions.cs @@ -0,0 +1,8 @@ +namespace StellaOps.Workflow.Abstractions; + +public interface IWorkflowBackendRegistrationMarker +{ + string BackendName { get; } +} + +public sealed record WorkflowBackendRegistrationMarker(string BackendName) : IWorkflowBackendRegistrationMarker; diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendNames.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendNames.cs new file mode 100644 index 000000000..4a744a6ce --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendNames.cs @@ -0,0 +1,8 @@ +namespace StellaOps.Workflow.Abstractions; + +public static class WorkflowBackendNames +{ + public const string Oracle = "Oracle"; + public const string Postgres = "Postgres"; + public const string Mongo = "Mongo"; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendOptions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendOptions.cs new file mode 100644 index 000000000..3ce3425f3 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBackendOptions.cs @@ -0,0 +1,8 @@ +namespace StellaOps.Workflow.Abstractions; + +public sealed class WorkflowBackendOptions +{ + public const string SectionName = "WorkflowBackend"; + + public string Provider { get; set; } = WorkflowBackendNames.Oracle; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBusinessReferenceExtensions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBusinessReferenceExtensions.cs new file mode 100644 index 000000000..57c7ebc76 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowBusinessReferenceExtensions.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.Json; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public static class WorkflowBusinessReferenceExtensions +{ + public static WorkflowBusinessReference? NormalizeBusinessReference(WorkflowBusinessReference? businessReference) + { + if (businessReference is null) + { + return null; + } + + var normalizedParts = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var part in businessReference.Parts) + { + if (!string.IsNullOrWhiteSpace(part.Key)) + { + normalizedParts[part.Key] = NormalizePartValue(part.Value); + } + } + + var key = string.IsNullOrWhiteSpace(businessReference.Key) + ? BuildCanonicalBusinessReferenceKey(normalizedParts) + : ConvertBusinessReferenceValueToString(businessReference.Key); + + if (string.IsNullOrWhiteSpace(key) && normalizedParts.Count == 0) + { + return null; + } + + return new WorkflowBusinessReference + { + Key = key, + Parts = normalizedParts, + }; + } + + public static string? BuildCanonicalBusinessReferenceKey(IDictionary parts) + { + var normalizedParts = parts + .Where(x => !string.IsNullOrWhiteSpace(x.Key)) + .Select(x => new KeyValuePair( + x.Key, + ConvertBusinessReferenceValueToString(x.Value))) + .Where(x => !string.IsNullOrWhiteSpace(x.Value)) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .ToArray(); + + if (normalizedParts.Length == 0) + { + return null; + } + + var builder = new StringBuilder(); + for (var index = 0; index < normalizedParts.Length; index++) + { + if (index > 0) + { + builder.Append('|'); + } + + builder.Append(Uri.EscapeDataString(normalizedParts[index].Key)); + builder.Append('='); + builder.Append(Uri.EscapeDataString(normalizedParts[index].Value!)); + } + + return builder.ToString(); + } + + public static bool MatchesBusinessReferenceFilter( + this WorkflowBusinessReference? businessReference, + string? key, + IDictionary parts) + { + var normalizedReference = NormalizeBusinessReference(businessReference); + if (!string.IsNullOrWhiteSpace(key) + && !string.Equals(normalizedReference?.Key, key, StringComparison.Ordinal)) + { + return false; + } + + if (parts.Count == 0) + { + return true; + } + + if (normalizedReference?.Parts is null || normalizedReference.Parts.Count == 0) + { + return false; + } + + foreach (var filterPart in parts) + { + if (!normalizedReference.Parts.TryGetValue(filterPart.Key, out var referenceValue)) + { + return false; + } + + if (!string.Equals( + ConvertBusinessReferenceValueToString(referenceValue), + ConvertBusinessReferenceValueToString(filterPart.Value), + StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + public static IDictionary ToObjectDictionary(this WorkflowBusinessReference? businessReference) + { + var normalizedReference = NormalizeBusinessReference(businessReference); + if (normalizedReference is null) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["key"] = normalizedReference.Key, + ["parts"] = new Dictionary(normalizedReference.Parts, StringComparer.OrdinalIgnoreCase), + }; + } + + public static WorkflowBusinessReference? ToBusinessReference(this IDictionary value) + { + if (value.Count == 0) + { + return null; + } + + value.TryGetValue("key", out var key); + value.TryGetValue("parts", out var parts); + + return NormalizeBusinessReference(new WorkflowBusinessReference + { + Key = ConvertBusinessReferenceValueToString(key), + Parts = ConvertToPartsDictionary(parts), + }); + } + + public static string? ConvertBusinessReferenceValueToString(object? value) + { + return value switch + { + null => null, + string text => text, + JsonElement element when element.ValueKind == JsonValueKind.String => element.GetString(), + JsonElement element when element.ValueKind == JsonValueKind.Number => element.ToString(), + JsonElement element when element.ValueKind == JsonValueKind.True => bool.TrueString, + JsonElement element when element.ValueKind == JsonValueKind.False => bool.FalseString, + JsonElement element when element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined => null, + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString(), + }; + } + + private static object? NormalizePartValue(object? value) + { + return value is JsonElement element + ? JsonSerializer.Deserialize(element.GetRawText()) + : value; + } + + private static IDictionary ConvertToPartsDictionary(object? value) + { + return value switch + { + IDictionary dictionary => new Dictionary(dictionary, StringComparer.OrdinalIgnoreCase), + JsonElement element when element.ValueKind == JsonValueKind.Object => + JsonSerializer.Deserialize>(element.GetRawText()) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase), + _ => new Dictionary(StringComparer.OrdinalIgnoreCase), + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDecompiler.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDecompiler.cs new file mode 100644 index 000000000..24ac17df4 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDecompiler.cs @@ -0,0 +1,984 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using StellaOps.Workflow.Contracts; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// Reverse compiler: converts a back to C# fluent DSL source code +/// using Roslyn for guaranteed syntactic correctness and consistent formatting. +/// Used for round-trip verification (compile -> decompile -> re-compile -> compare). +/// +public static class WorkflowCanonicalDecompiler +{ + /// + /// Decompiles a canonical definition to syntactically valid, formatted C# source code. + /// Uses Roslyn SyntaxFactory to build an AST, then NormalizeWhitespace() for formatting. + /// + public static string Decompile(WorkflowCanonicalDefinition definition) + { + ArgumentNullException.ThrowIfNull(definition); + + var requestClassName = SafeIdentifier(definition.WorkflowName) + "Request"; + var requestClass = BuildRequestClass(requestClassName, definition.StartRequest); + var classDecl = BuildClassDeclaration(definition, requestClassName); + + var compilationUnit = CompilationUnit() + .AddUsings( + UsingDirective(ParseName("System")), + UsingDirective(ParseName("System.Collections.Generic")), + UsingDirective(ParseName("System.Text.Json")), + UsingDirective(ParseName("StellaOps.Workflow.Abstractions")), + UsingDirective(ParseName("StellaOps.Workflow.Contracts"))) + .AddMembers(requestClass, classDecl) + .NormalizeWhitespace(); + + var source = compilationUnit.ToFullString().Replace("DynamicWorkflowRequest", requestClassName); + return FormatFluentChains(source); + } + + /// + /// Reconstructs a from the declaration model itself. + /// Performs a deep clone for round-trip fidelity testing. + /// + public static WorkflowCanonicalDefinition Reconstruct(WorkflowCanonicalDefinition definition) + { + ArgumentNullException.ThrowIfNull(definition); + + return new WorkflowCanonicalDefinition + { + SchemaVersion = definition.SchemaVersion, + WorkflowName = definition.WorkflowName, + WorkflowVersion = definition.WorkflowVersion, + DisplayName = definition.DisplayName, + StartRequest = definition.StartRequest, + WorkflowRoles = definition.WorkflowRoles.ToArray(), + BusinessReference = ReconstructBusinessReference(definition.BusinessReference), + Start = new WorkflowStartDeclaration + { + InitializeStateExpression = ReconstructExpression(definition.Start.InitializeStateExpression), + InitialTaskName = definition.Start.InitialTaskName, + InitialSequence = ReconstructSequence(definition.Start.InitialSequence), + }, + Tasks = definition.Tasks.Select(ReconstructTask).ToArray(), + RequiredModules = definition.RequiredModules.ToArray(), + RequiredCapabilities = definition.RequiredCapabilities.ToArray(), + }; + } + + // ═══════════════════════════════════════════════════════════ + // ROSLYN AST: Class structure + // ═══════════════════════════════════════════════════════════ + + private static ClassDeclarationSyntax BuildRequestClass( + string className, + WorkflowRequestContractDeclaration? startRequest) + { + var members = new List(); + + if (startRequest?.Schema is { } schema + && schema.TryGetValue("properties", out var propsObj)) + { + var propEntries = ExtractPropertyEntries(propsObj); + foreach (var (propName, jsonType) in propEntries) + { + + var clrType = jsonType switch + { + "string" => "string", + "number" => "long", + "boolean" => "bool", + "array" => "object[]", + _ => "object", + }; + + var isNullable = clrType is "string" or "object" or "object[]"; + var typeName = isNullable ? clrType + "?" : clrType; + + // PascalCase the property name + var pascalName = char.ToUpperInvariant(propName[0]) + propName[1..]; + + var prop = PropertyDeclaration(ParseTypeName(typeName), pascalName) + .AddModifiers(Token(SyntaxKind.PublicKeyword)) + .AddAccessorListAccessors( + AccessorDeclaration(SyntaxKind.GetAccessorDeclaration).WithSemicolonToken(Token(SyntaxKind.SemicolonToken)), + AccessorDeclaration(SyntaxKind.SetAccessorDeclaration).WithSemicolonToken(Token(SyntaxKind.SemicolonToken))); + + members.Add(prop); + } + } + + // If no schema, add a generic payload property + if (members.Count == 0) + { + members.Add(PropertyDeclaration(ParseTypeName("IDictionary"), "Payload") + .AddModifiers(Token(SyntaxKind.PublicKeyword)) + .AddAccessorListAccessors( + AccessorDeclaration(SyntaxKind.GetAccessorDeclaration).WithSemicolonToken(Token(SyntaxKind.SemicolonToken)), + AccessorDeclaration(SyntaxKind.SetAccessorDeclaration).WithSemicolonToken(Token(SyntaxKind.SemicolonToken)))); + } + + return ClassDeclaration(className) + .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.SealedKeyword)) + .WithLeadingTrivia(Comment($"/// Start request model for the workflow.")) + .AddMembers(members.ToArray()); + } + + private static ClassDeclarationSyntax BuildClassDeclaration(WorkflowCanonicalDefinition def, string requestClassName) + { + var className = SafeIdentifier(def.WorkflowName) + "Workflow"; + + var members = new List + { + BuildPropertyArrow("WorkflowName", Str(def.WorkflowName)), + BuildPropertyArrow("WorkflowVersion", Str(def.WorkflowVersion)), + BuildPropertyArrow("DisplayName", Str(def.DisplayName)), + BuildRolesProperty(def.WorkflowRoles), + }; + + // Spec property + var specInit = BuildSpecInitializer(def); + members.Add(PropertyDeclaration(ParseTypeName("WorkflowSpec"), "Spec") + .AddModifiers(Token(SyntaxKind.PublicKeyword)) + .AddAccessorListAccessors( + AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken))) + .WithInitializer(EqualsValueClause(specInit)) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken))); + + // Tasks property + members.Add(BuildPropertyArrow("Tasks", + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("Spec"), IdentifierName("TaskDescriptors")), + "IReadOnlyCollection")); + + return ClassDeclaration(className) + .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.SealedKeyword)) + .AddBaseListTypes(SimpleBaseType(ParseTypeName("IDeclarativeWorkflow"))) + .AddMembers(members.ToArray()); + } + + private static ExpressionSyntax BuildSpecInitializer(WorkflowCanonicalDefinition def) + { + ExpressionSyntax chain = InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("WorkflowSpec"), + GenericName("For").AddTypeArgumentListArguments(ParseTypeName("DynamicWorkflowRequest"))), + IdentifierName("Invoke"))) + .WithArgumentList(ArgumentList()); + + // Simplify: WorkflowSpec.For() + chain = InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("WorkflowSpec"), + GenericName("For").AddTypeArgumentListArguments(ParseTypeName("DynamicWorkflowRequest")))); + + // .InitializeState(expr) + chain = Invoke(chain, "InitializeState", EmitExpression(def.Start.InitializeStateExpression)); + + // .AddTask(...) for each task + foreach (var task in def.Tasks) + { + chain = Invoke(chain, "AddTask", EmitTaskDefinition(task)); + } + + // .StartWith(...) + if (!string.IsNullOrWhiteSpace(def.Start.InitialTaskName)) + { + chain = Invoke(chain, "StartWith", + SimpleLambdaExpression(Parameter(Identifier("flow")), + EmitFlowChain(IdentifierName("flow"), def.Start.InitialSequence, def.BusinessReference))); + } + else if (def.Start.InitialSequence.Steps.Count > 0) + { + chain = Invoke(chain, "StartWith", + SimpleLambdaExpression(Parameter(Identifier("flow")), + EmitFlowChain(IdentifierName("flow"), def.Start.InitialSequence, def.BusinessReference))); + } + + // .Build() + chain = Invoke(chain, "Build"); + + return chain; + } + + // ═══════════════════════════════════════════════════════════ + // ROSLYN AST: Flow chain (step sequences) + // ═══════════════════════════════════════════════════════════ + + private static ExpressionSyntax EmitFlowChain( + ExpressionSyntax target, + WorkflowStepSequenceDeclaration sequence, + WorkflowBusinessReferenceDeclaration? businessRef = null) + { + var chain = target; + + if (businessRef is not null) + { + chain = Invoke(chain, "SetBusinessReference", EmitBusinessReference(businessRef)); + } + + foreach (var step in sequence.Steps) + { + chain = EmitStep(chain, step); + } + + return chain; + } + + private static ExpressionSyntax EmitStep(ExpressionSyntax chain, WorkflowStepDeclaration step) + { + return step switch + { + WorkflowSetStateStepDeclaration s => Invoke(chain, + s.OnlyIfPresent ? "SetIfHasValue" : "Set", + Str(s.StateKey), EmitExpression(s.ValueExpression)), + + WorkflowAssignBusinessReferenceStepDeclaration s => + Invoke(chain, "SetBusinessReference", EmitBusinessReference(s.BusinessReference)), + + WorkflowTransportCallStepDeclaration s => EmitTransportCall(chain, s), + WorkflowDecisionStepDeclaration s => EmitDecision(chain, s), + + WorkflowActivateTaskStepDeclaration s => s.RuntimeRolesExpression is not null + ? Invoke(chain, "ActivateTask", Str(s.TaskName), EmitExpression(s.RuntimeRolesExpression)) + : Invoke(chain, "ActivateTask", Str(s.TaskName)), + + WorkflowContinueWithWorkflowStepDeclaration s => + Invoke(chain, "ContinueWith", Str(s.StepName), EmitWorkflowInvocation(s.Invocation)), + + WorkflowSubWorkflowStepDeclaration s => + Invoke(chain, "SubWorkflow", Str(s.StepName), EmitWorkflowInvocation(s.Invocation)), + + WorkflowRepeatStepDeclaration s => EmitRepeat(chain, s), + + WorkflowTimerStepDeclaration s => + Invoke(chain, "Wait", Str(s.StepName), EmitExpression(s.DelayExpression)), + + WorkflowExternalSignalStepDeclaration s => EmitExternalSignal(chain, s), + + WorkflowForkStepDeclaration s => EmitFork(chain, s), + + WorkflowCompleteStepDeclaration => Invoke(chain, "Complete"), + + _ => chain, + }; + } + + private static ExpressionSyntax EmitTransportCall(ExpressionSyntax chain, WorkflowTransportCallStepDeclaration call) + { + var args = new List + { + Argument(Str(call.StepName)), + Argument(EmitAddress(call.Invocation.Address)), + Argument(call.Invocation.PayloadExpression is not null + ? EmitExpression(call.Invocation.PayloadExpression) + : Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Null))), + }; + + if (call.WhenFailure is { Steps.Count: > 0 }) + { + args.Add(Argument( + SimpleLambdaExpression(Parameter(Identifier("fail")), + EmitFlowChain(IdentifierName("fail"), call.WhenFailure))) + .WithNameColon(NameColon("whenFailure"))); + } + + if (call.WhenTimeout is { Steps.Count: > 0 }) + { + args.Add(Argument( + SimpleLambdaExpression(Parameter(Identifier("timeout")), + EmitFlowChain(IdentifierName("timeout"), call.WhenTimeout))) + .WithNameColon(NameColon("whenTimeout"))); + } + + if (!string.IsNullOrWhiteSpace(call.ResultKey)) + { + args.Add(Argument(Str(call.ResultKey)).WithNameColon(NameColon("resultKey"))); + } + + if (call.TimeoutSeconds.HasValue) + { + args.Add(Argument(Num(call.TimeoutSeconds.Value)).WithNameColon(NameColon("timeoutSeconds"))); + } + + // When resultKey is set, we need Call to satisfy the generic overload + SimpleNameSyntax callName = !string.IsNullOrWhiteSpace(call.ResultKey) + ? GenericName("Call").AddTypeArgumentListArguments(ParseTypeName("object")) + : IdentifierName("Call"); + + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, chain, callName), + ArgumentList(SeparatedList(args))); + } + + private static ExpressionSyntax EmitDecision(ExpressionSyntax chain, WorkflowDecisionStepDeclaration decision) + { + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, chain, IdentifierName("WhenExpression")), + ArgumentList(SeparatedList(new[] + { + Argument(Str(decision.DecisionName)), + Argument(EmitExpression(decision.ConditionExpression)), + Argument(EmitLambdaForSequence("whenTrue", decision.WhenTrue)), + Argument(EmitLambdaForSequence("whenElse", decision.WhenElse)), + }))); + } + + private static ExpressionSyntax EmitRepeat(ExpressionSyntax chain, WorkflowRepeatStepDeclaration repeat) + { + var args = new List + { + Argument(Str(repeat.StepName)), + Argument(EmitExpression(repeat.MaxIterationsExpression)), + }; + + if (!string.IsNullOrWhiteSpace(repeat.IterationStateKey)) + { + args.Add(Argument(Str(repeat.IterationStateKey))); + } + + if (repeat.ContinueWhileExpression is not null) + { + args.Add(Argument(EmitExpression(repeat.ContinueWhileExpression))); + } + + args.Add(Argument( + SimpleLambdaExpression(Parameter(Identifier("body")), + EmitFlowChain(IdentifierName("body"), repeat.Body)))); + + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, chain, IdentifierName("Repeat")), + ArgumentList(SeparatedList(args))); + } + + private static ExpressionSyntax EmitFork(ExpressionSyntax chain, WorkflowForkStepDeclaration fork) + { + var args = new List { Argument(Str(fork.StepName)) }; + + var i = 0; + foreach (var branch in fork.Branches) + { + args.Add(Argument(EmitLambdaForSequence($"branch{++i}", branch))); + } + + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, chain, IdentifierName("Fork")), + ArgumentList(SeparatedList(args))); + } + + private static ExpressionSyntax EmitExternalSignal(ExpressionSyntax chain, WorkflowExternalSignalStepDeclaration signal) + { + var args = new List + { + Argument(Str(signal.StepName)), + Argument(EmitExpression(signal.SignalNameExpression)), + }; + + if (!string.IsNullOrWhiteSpace(signal.ResultKey)) + { + args.Add(Argument(Str(signal.ResultKey)).WithNameColon(NameColon("resultKey"))); + } + + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, chain, IdentifierName("WaitForSignal")), + ArgumentList(SeparatedList(args))); + } + + // ═══════════════════════════════════════════════════════════ + // ROSLYN AST: Expressions + // ═══════════════════════════════════════════════════════════ + + private static ExpressionSyntax EmitExpression(WorkflowExpressionDefinition expr) + { + return expr switch + { + WorkflowNullExpressionDefinition => + Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Null)), + WorkflowStringExpressionDefinition s => + Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.String), Str(s.Value)), + WorkflowNumberExpressionDefinition n => + Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Number), + LiteralExpression(SyntaxKind.NumericLiteralExpression, + Literal(n.Value, decimal.TryParse(n.Value, out var d) ? (long)d : 0))), + WorkflowBooleanExpressionDefinition b => + Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Bool), + LiteralExpression(b.Value ? SyntaxKind.TrueLiteralExpression : SyntaxKind.FalseLiteralExpression)), + WorkflowPathExpressionDefinition p => + Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Path), Str(p.Path)), + WorkflowObjectExpressionDefinition o => EmitObjectExpression(o), + WorkflowArrayExpressionDefinition a => EmitArrayExpression(a), + WorkflowFunctionExpressionDefinition f => EmitFunctionExpression(f), + WorkflowGroupExpressionDefinition g => EmitExpression(g.Expression), + WorkflowUnaryExpressionDefinition u => + Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Not), EmitExpression(u.Operand)), + WorkflowBinaryExpressionDefinition b => EmitBinaryExpression(b), + _ => Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Null)), + }; + } + + private static ExpressionSyntax EmitObjectExpression(WorkflowObjectExpressionDefinition obj) + { + if (obj.Properties.Count == 0) + { + return Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Obj)); + } + + var args = obj.Properties.Select(p => + Argument(InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName(nameof(WorkflowExpr)), IdentifierName(nameof(WorkflowExpr.Prop))), + ArgumentList(SeparatedList(new[] + { + Argument(Str(p.Name)), + Argument(EmitExpression(p.Expression)), + }))))).ToArray(); + + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName(nameof(WorkflowExpr)), IdentifierName(nameof(WorkflowExpr.Obj))), + ArgumentList(SeparatedList(args))); + } + + private static ExpressionSyntax EmitArrayExpression(WorkflowArrayExpressionDefinition arr) + { + if (arr.Items.Count == 0) + { + return Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Array)); + } + + var args = arr.Items.Select(i => Argument(EmitExpression(i))).ToArray(); + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName(nameof(WorkflowExpr)), IdentifierName(nameof(WorkflowExpr.Array))), + ArgumentList(SeparatedList(args))); + } + + private static ExpressionSyntax EmitFunctionExpression(WorkflowFunctionExpressionDefinition func) + { + var args = new List { Argument(Str(func.FunctionName)) }; + args.AddRange(func.Arguments.Select(a => Argument(EmitExpression(a)))); + + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName(nameof(WorkflowExpr)), IdentifierName(nameof(WorkflowExpr.Func))), + ArgumentList(SeparatedList(args))); + } + + private static ExpressionSyntax EmitBinaryExpression(WorkflowBinaryExpressionDefinition binary) + { + var method = binary.Operator switch + { + "eq" => "Eq", "ne" => "Ne", "gt" => "Gt", "gte" => "Gte", + "lt" => "Lt", "lte" => "Lte", "and" => "And", "or" => "Or", + _ => null, + }; + + if (method is not null) + { + return Invoke(IdentifierName(nameof(WorkflowExpr)), method, + EmitExpression(binary.Left), EmitExpression(binary.Right)); + } + + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName(nameof(WorkflowExpr)), IdentifierName(nameof(WorkflowExpr.Binary))), + ArgumentList(SeparatedList(new[] + { + Argument(Str(binary.Operator)), + Argument(EmitExpression(binary.Left)), + Argument(EmitExpression(binary.Right)), + }))); + } + + // ═══════════════════════════════════════════════════════════ + // ROSLYN AST: Addresses + // ═══════════════════════════════════════════════════════════ + + private static ExpressionSyntax EmitAddress(WorkflowTransportAddressDeclaration address) + { + return address switch + { + WorkflowLegacyRabbitAddressDeclaration a => a.Mode == WorkflowLegacyRabbitMode.Envelope + ? New(nameof(LegacyRabbitAddress), Str(a.Command)) + : New(nameof(LegacyRabbitAddress), Str(a.Command), + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName(nameof(WorkflowLegacyRabbitMode)), IdentifierName(a.Mode.ToString()))), + WorkflowMicroserviceAddressDeclaration a => + New(nameof(Address), Str(a.MicroserviceName), Str(a.Command)), + WorkflowRabbitAddressDeclaration a => + New(nameof(Address), Str(a.Exchange), Str(a.RoutingKey)), + WorkflowHttpAddressDeclaration a => string.Equals(a.Method, "POST", StringComparison.OrdinalIgnoreCase) + ? New(nameof(HttpAddress), Str(a.Target), Str(a.Path)) + : New(nameof(HttpAddress), Str(a.Target), Str(a.Path), Str(a.Method)), + WorkflowGraphqlAddressDeclaration a => string.IsNullOrWhiteSpace(a.OperationName) + ? New(nameof(GraphqlAddress), Str(a.Target), Str(a.Query)) + : New(nameof(GraphqlAddress), Str(a.Target), Str(a.Query), Str(a.OperationName)), + _ => Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Null)), + }; + } + + // ═══════════════════════════════════════════════════════════ + // ROSLYN AST: Tasks, business ref, workflow invocation + // ═══════════════════════════════════════════════════════════ + + private static ExpressionSyntax EmitTaskDefinition(WorkflowTaskDeclaration task) + { + ExpressionSyntax chain = InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("WorkflowHumanTask"), + GenericName("For").AddTypeArgumentListArguments(ParseTypeName("DynamicWorkflowRequest"))), + ArgumentList(SeparatedList(new[] + { + Argument(Str(task.TaskName)), + Argument(Str(task.TaskType)), + Argument(Str("default")), + }))); + + if (task.TaskRoles.Count > 0) + { + chain = InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, chain, IdentifierName("WithRoles")), + ArgumentList(SeparatedList(task.TaskRoles.Select(r => Argument(Str(r)))))); + } + + chain = Invoke(chain, "WithPayload", EmitExpression(task.PayloadExpression)); + + if (task.OnComplete.Steps.Count > 0) + { + chain = InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, chain, IdentifierName("OnComplete")), + ArgumentList(SingletonSeparatedList(Argument( + SimpleLambdaExpression(Parameter(Identifier("flow")), + EmitFlowChain(IdentifierName("flow"), task.OnComplete)))))); + } + else + { + chain = Invoke(chain, "Build"); + } + + return chain; + } + + private static ExpressionSyntax EmitBusinessReference(WorkflowBusinessReferenceDeclaration businessRef) + { + var props = new List(); + + if (businessRef.KeyExpression is not null) + { + props.Add(AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + IdentifierName("KeyExpression"), EmitExpression(businessRef.KeyExpression))); + } + + if (businessRef.Parts.Count > 0) + { + var partElements = businessRef.Parts.Select(p => + (ExpressionSyntax)ObjectCreationExpression(ParseTypeName(nameof(WorkflowNamedExpressionDefinition))) + .WithArgumentList(ArgumentList()) + .WithInitializer(InitializerExpression(SyntaxKind.ObjectInitializerExpression, + SeparatedList(new[] + { + AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + IdentifierName("Name"), Str(p.Name)), + AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + IdentifierName("Expression"), EmitExpression(p.Expression)), + })))).ToArray(); + + props.Add(AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + IdentifierName("Parts"), + ArrayCreationExpression( + ArrayType(ParseTypeName($"{nameof(WorkflowNamedExpressionDefinition)}[]"))) + .WithInitializer(InitializerExpression(SyntaxKind.ArrayInitializerExpression, + SeparatedList(partElements))))); + } + + return ObjectCreationExpression(ParseTypeName(nameof(WorkflowBusinessReferenceDeclaration))) + .WithArgumentList(ArgumentList()) + .WithInitializer(InitializerExpression(SyntaxKind.ObjectInitializerExpression, + SeparatedList(props))); + } + + private static ExpressionSyntax EmitWorkflowInvocation(WorkflowWorkflowInvocationDeclaration invocation) + { + var props = new List + { + AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + IdentifierName("WorkflowNameExpression"), EmitExpression(invocation.WorkflowNameExpression)), + }; + + if (invocation.PayloadExpression is not null) + { + props.Add(AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + IdentifierName("PayloadExpression"), EmitExpression(invocation.PayloadExpression))); + } + + return ObjectCreationExpression(ParseTypeName(nameof(WorkflowWorkflowInvocationDeclaration))) + .WithArgumentList(ArgumentList()) + .WithInitializer(InitializerExpression(SyntaxKind.ObjectInitializerExpression, + SeparatedList(props))); + } + + // ═══════════════════════════════════════════════════════════ + // ROSLYN AST: Helpers + // ═══════════════════════════════════════════════════════════ + + private static PropertyDeclarationSyntax BuildPropertyArrow(string name, ExpressionSyntax value, string? type = null) + { + return PropertyDeclaration(ParseTypeName(type ?? "string"), name) + .AddModifiers(Token(SyntaxKind.PublicKeyword)) + .WithExpressionBody(ArrowExpressionClause(value)) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)); + } + + private static PropertyDeclarationSyntax BuildRolesProperty(IReadOnlyCollection roles) + { + ExpressionSyntax value = roles.Count > 0 + ? CollectionExpression(SeparatedList( + roles.Select(r => (CollectionElementSyntax)ExpressionElement(Str(r))))) + : CollectionExpression(); + + return PropertyDeclaration(ParseTypeName("IReadOnlyCollection"), "WorkflowRoles") + .AddModifiers(Token(SyntaxKind.PublicKeyword)) + .WithExpressionBody(ArrowExpressionClause(value)) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)); + } + + private static ExpressionSyntax Str(string value) + { + return LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(value)); + } + + private static ExpressionSyntax Num(int value) + { + return LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal(value)); + } + + private static InvocationExpressionSyntax Invoke(ExpressionSyntax target, string method, params ExpressionSyntax[] args) + { + return InvocationExpression( + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, target, IdentifierName(method)), + ArgumentList(SeparatedList(args.Select(Argument)))); + } + + /// + /// Emits a lambda for a step sequence. If the sequence is empty, emits _ => { } (block body) + /// instead of a bare expression lambda which would be invalid as a statement. + /// + private static LambdaExpressionSyntax EmitLambdaForSequence(string paramName, WorkflowStepSequenceDeclaration sequence) + { + if (sequence.Steps.Count == 0) + { + return SimpleLambdaExpression(Parameter(Identifier("_")), Block()); + } + + return SimpleLambdaExpression(Parameter(Identifier(paramName)), + EmitFlowChain(IdentifierName(paramName), sequence)); + } + + private static ObjectCreationExpressionSyntax New(string className, params ExpressionSyntax[] args) + { + return ObjectCreationExpression(ParseTypeName(className)) + .WithArgumentList(ArgumentList(SeparatedList(args.Select(Argument)))); + } + + /// + /// Post-processes Roslyn output to break long lines with proper indentation. + /// Handles fluent chains, WorkflowExpr.Object/Array/Func arguments, and Prop calls. + /// + private static string FormatFluentChains(string source) + { + var lines = source.Split('\n'); + var result = new global::System.Text.StringBuilder(source.Length * 2); + + foreach (var rawLine in lines) + { + var line = rawLine.TrimEnd('\r'); + + if (line.Length > 100) + { + try + { + var indent = line.Length - line.TrimStart().Length; + var formatted = FormatLongLine(line.TrimStart(), indent); + result.AppendLine(formatted); + } + catch + { + // If formatting fails, keep the original line + result.AppendLine(line); + } + } + else + { + result.AppendLine(line); + } + } + + return result.ToString().TrimEnd() + Environment.NewLine; + } + + private static string FormatLongLine(string line, int baseIndent) + { + var sb = new global::System.Text.StringBuilder(line.Length * 3); + var indentStr = new string(' ', baseIndent); + var depth = 0; + var col = baseIndent; + + sb.Append(indentStr); + + for (var i = 0; i < line.Length; i++) + { + var ch = line[i]; + + if (ch == '(') + { + sb.Append(ch); + depth++; + col++; + + // Break after "(" if followed by a long argument list + if (ShouldBreakAfterOpen(line, i)) + { + sb.AppendLine(); + var inner = new string(' ', baseIndent + depth * 4); + sb.Append(inner); + col = inner.Length; + } + } + else if (ch == ')') + { + depth--; + + // Break before ")" if we broke after the matching "(" + if (i > 0 && col > 100 && depth >= 0) + { + // Only add newline if previous char is not already a newline + var prev = sb.Length > 0 ? sb[sb.Length - 1] : ' '; + if (prev != '\n') + { + sb.AppendLine(); + sb.Append(new string(' ', baseIndent + depth * 4)); + } + } + + sb.Append(ch); + col++; + + // Break after ")." for fluent chains at low depth + if (depth <= 1 && i + 1 < line.Length && line[i + 1] == '.') + { + sb.AppendLine(); + sb.Append(new string(' ', baseIndent + depth * 4)); + col = baseIndent + depth * 4; + } + } + else if (ch == ',' && depth >= 1) + { + sb.Append(ch); + col++; + + // Break after comma in argument lists when line is getting long + if (col > 80 && i + 1 < line.Length && line[i + 1] == ' ') + { + sb.AppendLine(); + var inner = new string(' ', baseIndent + depth * 4); + sb.Append(inner); + col = inner.Length; + + // Skip the space after comma since we're adding a newline + if (i + 1 < line.Length && line[i + 1] == ' ') + { + i++; + } + } + } + else + { + sb.Append(ch); + col++; + } + } + + return sb.ToString(); + } + + private static bool ShouldBreakAfterOpen(string line, int openPos) + { + // Count chars until matching close paren + var depth = 1; + var len = 0; + for (var j = openPos + 1; j < line.Length && depth > 0; j++) + { + if (line[j] == '(') depth++; + else if (line[j] == ')') depth--; + len++; + } + + // Break if the content between parens is long + return len > 60; + } + + private static IEnumerable<(string Name, string JsonType)> ExtractPropertyEntries(object? propsObj) + { + // Handle direct dictionary (from compiler) + if (propsObj is IDictionary dict) + { + foreach (var (key, value) in dict) + { + var jsonType = "object"; + if (value is IDictionary propSchema + && propSchema.TryGetValue("type", out var typeVal)) + { + jsonType = typeVal?.ToString() ?? "object"; + } + + yield return (key, jsonType); + } + + yield break; + } + + // Handle JsonElement (after JSON round-trip deserialization) + if (propsObj is global::System.Text.Json.JsonElement jsonElement + && jsonElement.ValueKind == global::System.Text.Json.JsonValueKind.Object) + { + foreach (var prop in jsonElement.EnumerateObject()) + { + var jsonType = "object"; + if (prop.Value.ValueKind == global::System.Text.Json.JsonValueKind.Object + && prop.Value.TryGetProperty("type", out var typeProp) + && typeProp.ValueKind == global::System.Text.Json.JsonValueKind.String) + { + jsonType = typeProp.GetString() ?? "object"; + } + + yield return (prop.Name, jsonType); + } + } + } + + private static string SafeIdentifier(string name) + { + return new string(name.Where(c => char.IsLetterOrDigit(c) || c == '_').ToArray()); + } + + // ═══════════════════════════════════════════════════════════ + // RECONSTRUCTION (Definition → Definition deep clone) + // ═══════════════════════════════════════════════════════════ + + private static WorkflowBusinessReferenceDeclaration? ReconstructBusinessReference( + WorkflowBusinessReferenceDeclaration? source) + { + if (source is null) return null; + return new WorkflowBusinessReferenceDeclaration + { + KeyExpression = source.KeyExpression is not null ? ReconstructExpression(source.KeyExpression) : null, + Parts = source.Parts.Select(p => new WorkflowNamedExpressionDefinition + { + Name = p.Name, Expression = ReconstructExpression(p.Expression), + }).ToArray(), + }; + } + + private static WorkflowTaskDeclaration ReconstructTask(WorkflowTaskDeclaration s) => new() + { + TaskName = s.TaskName, TaskType = s.TaskType, + RouteExpression = ReconstructExpression(s.RouteExpression), + PayloadExpression = ReconstructExpression(s.PayloadExpression), + TaskRoles = s.TaskRoles.ToArray(), OnComplete = ReconstructSequence(s.OnComplete), + }; + + private static WorkflowStepSequenceDeclaration ReconstructSequence(WorkflowStepSequenceDeclaration s) => new() + { + Steps = s.Steps.Select(ReconstructStep).ToArray(), + }; + + private static WorkflowStepDeclaration ReconstructStep(WorkflowStepDeclaration step) => step switch + { + WorkflowSetStateStepDeclaration s => new WorkflowSetStateStepDeclaration + { StateKey = s.StateKey, ValueExpression = ReconstructExpression(s.ValueExpression), OnlyIfPresent = s.OnlyIfPresent }, + WorkflowAssignBusinessReferenceStepDeclaration s => new WorkflowAssignBusinessReferenceStepDeclaration + { BusinessReference = ReconstructBusinessReference(s.BusinessReference)! }, + WorkflowTransportCallStepDeclaration s => new WorkflowTransportCallStepDeclaration + { + StepName = s.StepName, ResultKey = s.ResultKey, TimeoutSeconds = s.TimeoutSeconds, + Invocation = new WorkflowTransportInvocationDeclaration + { + Address = s.Invocation.Address, + PayloadExpression = s.Invocation.PayloadExpression is not null ? ReconstructExpression(s.Invocation.PayloadExpression) : null, + }, + WhenFailure = s.WhenFailure is not null ? ReconstructSequence(s.WhenFailure) : null, + WhenTimeout = s.WhenTimeout is not null ? ReconstructSequence(s.WhenTimeout) : null, + }, + WorkflowDecisionStepDeclaration s => new WorkflowDecisionStepDeclaration + { + DecisionName = s.DecisionName, ConditionExpression = ReconstructExpression(s.ConditionExpression), + WhenTrue = ReconstructSequence(s.WhenTrue), WhenElse = ReconstructSequence(s.WhenElse), + }, + WorkflowActivateTaskStepDeclaration s => new WorkflowActivateTaskStepDeclaration + { + TaskName = s.TaskName, TimeoutSeconds = s.TimeoutSeconds, + RuntimeRolesExpression = s.RuntimeRolesExpression is not null ? ReconstructExpression(s.RuntimeRolesExpression) : null, + }, + WorkflowContinueWithWorkflowStepDeclaration s => new WorkflowContinueWithWorkflowStepDeclaration + { StepName = s.StepName, Invocation = ReconstructWorkflowInvocation(s.Invocation) }, + WorkflowSubWorkflowStepDeclaration s => new WorkflowSubWorkflowStepDeclaration + { StepName = s.StepName, Invocation = ReconstructWorkflowInvocation(s.Invocation), ResultKey = s.ResultKey }, + WorkflowRepeatStepDeclaration s => new WorkflowRepeatStepDeclaration + { + StepName = s.StepName, MaxIterationsExpression = ReconstructExpression(s.MaxIterationsExpression), + IterationStateKey = s.IterationStateKey, Body = ReconstructSequence(s.Body), + ContinueWhileExpression = s.ContinueWhileExpression is not null ? ReconstructExpression(s.ContinueWhileExpression) : null, + }, + WorkflowTimerStepDeclaration s => new WorkflowTimerStepDeclaration + { StepName = s.StepName, DelayExpression = ReconstructExpression(s.DelayExpression) }, + WorkflowExternalSignalStepDeclaration s => new WorkflowExternalSignalStepDeclaration + { StepName = s.StepName, SignalNameExpression = ReconstructExpression(s.SignalNameExpression), ResultKey = s.ResultKey }, + WorkflowForkStepDeclaration s => new WorkflowForkStepDeclaration + { StepName = s.StepName, Branches = s.Branches.Select(ReconstructSequence).ToArray() }, + WorkflowCompleteStepDeclaration => new WorkflowCompleteStepDeclaration(), + _ => step, + }; + + private static WorkflowWorkflowInvocationDeclaration ReconstructWorkflowInvocation(WorkflowWorkflowInvocationDeclaration s) => new() + { + WorkflowNameExpression = ReconstructExpression(s.WorkflowNameExpression), + WorkflowVersionExpression = s.WorkflowVersionExpression is not null ? ReconstructExpression(s.WorkflowVersionExpression) : null, + PayloadExpression = s.PayloadExpression is not null ? ReconstructExpression(s.PayloadExpression) : null, + BusinessReference = ReconstructBusinessReference(s.BusinessReference), + }; + + private static WorkflowExpressionDefinition ReconstructExpression(WorkflowExpressionDefinition expr) => expr switch + { + WorkflowNullExpressionDefinition => new WorkflowNullExpressionDefinition(), + WorkflowStringExpressionDefinition s => new WorkflowStringExpressionDefinition { Value = s.Value }, + WorkflowNumberExpressionDefinition n => new WorkflowNumberExpressionDefinition { Value = n.Value }, + WorkflowBooleanExpressionDefinition b => new WorkflowBooleanExpressionDefinition { Value = b.Value }, + WorkflowPathExpressionDefinition p => new WorkflowPathExpressionDefinition { Path = p.Path }, + WorkflowObjectExpressionDefinition o => new WorkflowObjectExpressionDefinition + { + Properties = o.Properties.Select(p => new WorkflowNamedExpressionDefinition + { Name = p.Name, Expression = ReconstructExpression(p.Expression) }).ToArray(), + }, + WorkflowArrayExpressionDefinition a => new WorkflowArrayExpressionDefinition + { Items = a.Items.Select(ReconstructExpression).ToArray() }, + WorkflowFunctionExpressionDefinition f => new WorkflowFunctionExpressionDefinition + { FunctionName = f.FunctionName, Arguments = f.Arguments.Select(ReconstructExpression).ToArray() }, + WorkflowGroupExpressionDefinition g => new WorkflowGroupExpressionDefinition + { Expression = ReconstructExpression(g.Expression) }, + WorkflowUnaryExpressionDefinition u => new WorkflowUnaryExpressionDefinition + { Operator = u.Operator, Operand = ReconstructExpression(u.Operand) }, + WorkflowBinaryExpressionDefinition b => new WorkflowBinaryExpressionDefinition + { Operator = b.Operator, Left = ReconstructExpression(b.Left), Right = ReconstructExpression(b.Right) }, + _ => expr, + }; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionCompiler.Helpers.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionCompiler.Helpers.cs new file mode 100644 index 000000000..a39bde064 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionCompiler.Helpers.cs @@ -0,0 +1,533 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public static partial class WorkflowCanonicalDefinitionCompiler +{ + private static WorkflowTaskDeclaration BuildTask( + WorkflowHumanTaskDefinition task) + where TStartRequest : class + { + return new WorkflowTaskDeclaration + { + TaskName = task.TaskName, + TaskType = task.Descriptor.TaskType, + RouteExpression = task.RouteExpression ?? WorkflowExpr.String(task.Descriptor.Route), + PayloadExpression = task.PayloadExpression ?? WorkflowExpr.Null(), + TaskRoles = task.Descriptor.TaskRoles.ToArray(), + OnComplete = BuildSequence(task.OnComplete), + }; + } + + private static WorkflowStepSequenceDeclaration BuildSequence( + WorkflowStepSequence sequence) + where TStartRequest : class + { + return new WorkflowStepSequenceDeclaration + { + Steps = sequence.Steps.Select(BuildStep).ToArray(), + }; + } + + private static WorkflowStepDeclaration BuildStep( + WorkflowStepDefinition step) + where TStartRequest : class + { + return step switch + { + WorkflowStateAssignmentStepDefinition assignment => new WorkflowSetStateStepDeclaration + { + StateKey = assignment.Key, + ValueExpression = assignment.ValueExpression ?? WorkflowExpr.Null(), + OnlyIfPresent = assignment.OnlyWhenHasValue, + }, + WorkflowBusinessReferenceAssignmentStepDefinition businessReferenceAssignment => + new WorkflowAssignBusinessReferenceStepDeclaration + { + BusinessReference = businessReferenceAssignment.BusinessReferenceDeclaration + ?? new WorkflowBusinessReferenceDeclaration(), + }, + WorkflowMicroserviceCallStepDefinition microserviceCall => BuildTransportCall( + microserviceCall.StepName, + new WorkflowMicroserviceAddressDeclaration + { + MicroserviceName = microserviceCall.MicroserviceName, + Command = microserviceCall.Command, + }, + microserviceCall.PayloadExpression, + microserviceCall.ResultKey, + microserviceCall.FailureHandlers, + microserviceCall.TimeoutSeconds), + WorkflowLegacyRabbitCallStepDefinition legacyRabbitCall => BuildTransportCall( + legacyRabbitCall.StepName, + new WorkflowLegacyRabbitAddressDeclaration + { + Command = legacyRabbitCall.Command, + Mode = legacyRabbitCall.Mode, + }, + legacyRabbitCall.PayloadExpression, + legacyRabbitCall.ResultKey, + legacyRabbitCall.FailureHandlers, + legacyRabbitCall.TimeoutSeconds), + WorkflowGraphqlCallStepDefinition graphqlCall => BuildTransportCall( + graphqlCall.StepName, + new WorkflowGraphqlAddressDeclaration + { + Target = graphqlCall.Target, + Query = graphqlCall.Query, + OperationName = graphqlCall.OperationName, + }, + graphqlCall.VariablesExpression, + graphqlCall.ResultKey, + graphqlCall.FailureHandlers, + graphqlCall.TimeoutSeconds), + WorkflowHttpCallStepDefinition httpCall => BuildTransportCall( + httpCall.StepName, + new WorkflowHttpAddressDeclaration + { + Target = httpCall.Target, + Path = httpCall.Path, + Method = httpCall.Method, + }, + httpCall.PayloadExpression, + httpCall.ResultKey, + httpCall.FailureHandlers, + httpCall.TimeoutSeconds), + WorkflowDecisionStepDefinition decisionStep => new WorkflowDecisionStepDeclaration + { + DecisionName = decisionStep.Condition.DisplayName, + ConditionExpression = decisionStep.Condition.CanonicalExpression ?? WorkflowExpr.Null(), + WhenTrue = BuildSequence(decisionStep.WhenTrue), + WhenElse = BuildSequence(decisionStep.WhenFalse), + }, + WorkflowConditionalStepDefinition conditionalStep => new WorkflowDecisionStepDeclaration + { + DecisionName = conditionalStep.Condition.DisplayName, + ConditionExpression = conditionalStep.Condition.CanonicalExpression ?? WorkflowExpr.Null(), + WhenTrue = BuildSequence(conditionalStep.WhenTrue), + WhenElse = BuildSequence(conditionalStep.WhenElse), + }, + WorkflowActivateTaskStepDefinition activateTask => new WorkflowActivateTaskStepDeclaration + { + TaskName = activateTask.TaskName, + RuntimeRolesExpression = activateTask.RuntimeRolesExpression, + }, + WorkflowContinueWithStepDefinition continueWithStep => new WorkflowContinueWithWorkflowStepDeclaration + { + StepName = continueWithStep.StepName, + Invocation = continueWithStep.InvocationDeclaration ?? CreateEmptyInvocation(), + }, + WorkflowSubWorkflowStepDefinition subWorkflowStep => new WorkflowSubWorkflowStepDeclaration + { + StepName = subWorkflowStep.StepName, + Invocation = subWorkflowStep.InvocationDeclaration ?? CreateEmptyInvocation(), + ResultKey = subWorkflowStep.ResultKey, + }, + WorkflowRepeatStepDefinition repeatStep => new WorkflowRepeatStepDeclaration + { + StepName = repeatStep.StepName, + MaxIterationsExpression = repeatStep.MaxIterationsExpression ?? WorkflowExpr.Number(1), + IterationStateKey = repeatStep.IterationStateKey, + ContinueWhileExpression = repeatStep.ContinueWhileExpression, + Body = BuildSequence(repeatStep.Body), + }, + WorkflowTimerStepDefinition timerStep => new WorkflowTimerStepDeclaration + { + StepName = timerStep.StepName, + DelayExpression = timerStep.DelayExpression ?? WorkflowExpr.Null(), + }, + WorkflowExternalSignalStepDefinition externalSignalStep => new WorkflowExternalSignalStepDeclaration + { + StepName = externalSignalStep.StepName, + SignalNameExpression = externalSignalStep.SignalNameExpression ?? WorkflowExpr.Null(), + ResultKey = externalSignalStep.ResultKey, + }, + WorkflowForkStepDefinition forkStep => new WorkflowForkStepDeclaration + { + StepName = forkStep.StepName, + Branches = forkStep.Branches.Select(BuildSequence).ToArray(), + }, + WorkflowCompleteStepDefinition => new WorkflowCompleteStepDeclaration(), + _ => throw new InvalidOperationException( + $"Workflow step '{step.GetType().FullName}' cannot be converted to a canonical declaration."), + }; + } + + private static WorkflowTransportCallStepDeclaration BuildTransportCall( + string stepName, + WorkflowTransportAddressDeclaration address, + WorkflowExpressionDefinition? payloadExpression, + string? resultKey, + WorkflowFailureHandlers? failureHandlers, + int? timeoutSeconds = null) + where TStartRequest : class + { + return new WorkflowTransportCallStepDeclaration + { + StepName = stepName, + Invocation = new WorkflowTransportInvocationDeclaration + { + Address = address, + PayloadExpression = payloadExpression, + }, + ResultKey = resultKey, + TimeoutSeconds = timeoutSeconds, + WhenFailure = failureHandlers?.HasFailureBranch == true ? BuildSequence(failureHandlers.WhenFailure) : null, + WhenTimeout = failureHandlers?.HasTimeoutBranch == true ? BuildSequence(failureHandlers.WhenTimeout) : null, + }; + } + + private static WorkflowWorkflowInvocationDeclaration CreateEmptyInvocation() + { + return new WorkflowWorkflowInvocationDeclaration + { + WorkflowNameExpression = WorkflowExpr.String(string.Empty), + }; + } + + private static WorkflowBusinessReferenceDeclaration? BuildStartRequestBusinessReference(Type startRequestType) + { + var keyProperty = startRequestType + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .SingleOrDefault(property => property.GetCustomAttribute() is not null); + var partProperties = startRequestType + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Select(property => new + { + Property = property, + Attribute = property.GetCustomAttribute(), + }) + .Where(x => x.Attribute is not null) + .Select(x => new WorkflowNamedExpressionDefinition + { + Name = x.Attribute!.PartName ?? x.Property.Name, + Expression = WorkflowExpr.Path($"start.{ResolveJsonPropertyName(x.Property)}"), + }) + .ToArray(); + + if (keyProperty is null && partProperties.Length == 0) + { + return null; + } + + return new WorkflowBusinessReferenceDeclaration + { + KeyExpression = keyProperty is null ? null : WorkflowExpr.Path($"start.{ResolveJsonPropertyName(keyProperty)}"), + Parts = partProperties, + }; + } + + private static string ResolveJsonPropertyName(PropertyInfo property) + { + return property.GetCustomAttribute()?.Name + ?? JsonNamingPolicy.CamelCase.ConvertName(property.Name); + } + + private static WorkflowRequiredModuleDeclaration[] InferRequiredModules( + WorkflowCanonicalDefinition definition, + IWorkflowFunctionCatalog? functionCatalog) + { + var modules = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["workflow.dsl.core"] = new() { ModuleName = "workflow.dsl.core" }, + }; + + VisitDefinition(definition, expression => + { + if (expression is not WorkflowFunctionExpressionDefinition functionExpression) + { + return; + } + + if (functionCatalog?.TryGetFunction(functionExpression.FunctionName, out var functionDescriptor) == true) + { + modules[functionDescriptor.ModuleName] = new WorkflowRequiredModuleDeclaration + { + ModuleName = functionDescriptor.ModuleName, + VersionExpression = $">={functionDescriptor.ModuleVersion}", + }; + return; + } + + if (ExpressionUsesFunction(expression)) + { + modules["workflow.functions.core"] = new WorkflowRequiredModuleDeclaration + { + ModuleName = "workflow.functions.core", + }; + } + }); + + foreach (var address in EnumerateAddresses(definition)) + { + var moduleName = address switch + { + WorkflowMicroserviceAddressDeclaration => "transport.microservice", + WorkflowRabbitAddressDeclaration => "transport.rabbit", + WorkflowLegacyRabbitAddressDeclaration => "transport.legacy-rabbit", + WorkflowGraphqlAddressDeclaration => "transport.graphql", + WorkflowHttpAddressDeclaration => "transport.http", + _ => null, + }; + + if (!string.IsNullOrWhiteSpace(moduleName)) + { + modules[moduleName] = new WorkflowRequiredModuleDeclaration + { + ModuleName = moduleName, + }; + } + } + + return modules.Values.OrderBy(x => x.ModuleName, StringComparer.OrdinalIgnoreCase).ToArray(); + } + + private static IEnumerable EnumerateAddresses( + WorkflowCanonicalDefinition definition) + { + foreach (var address in EnumerateAddresses(definition.Start.InitialSequence)) + { + yield return address; + } + + foreach (var task in definition.Tasks) + { + foreach (var address in EnumerateAddresses(task.OnComplete)) + { + yield return address; + } + } + } + + private static IEnumerable EnumerateAddresses( + WorkflowStepSequenceDeclaration sequence) + { + foreach (var step in sequence.Steps) + { + switch (step) + { + case WorkflowTransportCallStepDeclaration transportCall: + yield return transportCall.Invocation.Address; + if (transportCall.WhenFailure is not null) + { + foreach (var nestedAddress in EnumerateAddresses(transportCall.WhenFailure)) + { + yield return nestedAddress; + } + } + + if (transportCall.WhenTimeout is not null) + { + foreach (var nestedAddress in EnumerateAddresses(transportCall.WhenTimeout)) + { + yield return nestedAddress; + } + } + + break; + case WorkflowDecisionStepDeclaration decisionStep: + foreach (var nestedAddress in EnumerateAddresses(decisionStep.WhenTrue)) + { + yield return nestedAddress; + } + + foreach (var nestedAddress in EnumerateAddresses(decisionStep.WhenElse)) + { + yield return nestedAddress; + } + + break; + case WorkflowRepeatStepDeclaration repeatStep: + foreach (var nestedAddress in EnumerateAddresses(repeatStep.Body)) + { + yield return nestedAddress; + } + + break; + case WorkflowForkStepDeclaration forkStep: + foreach (var branch in forkStep.Branches) + { + foreach (var nestedAddress in EnumerateAddresses(branch)) + { + yield return nestedAddress; + } + } + + break; + } + } + } + + private static void VisitDefinition( + WorkflowCanonicalDefinition definition, + Action visitor) + { + VisitExpression(definition.BusinessReference?.KeyExpression, visitor); + foreach (var part in definition.BusinessReference?.Parts ?? []) + { + VisitExpression(part.Expression, visitor); + } + + VisitExpression(definition.Start.InitializeStateExpression, visitor); + VisitSequence(definition.Start.InitialSequence, visitor); + + foreach (var task in definition.Tasks) + { + VisitExpression(task.RouteExpression, visitor); + VisitExpression(task.PayloadExpression, visitor); + VisitSequence(task.OnComplete, visitor); + } + } + + private static void VisitSequence( + WorkflowStepSequenceDeclaration sequence, + Action visitor) + { + foreach (var step in sequence.Steps) + { + switch (step) + { + case WorkflowSetStateStepDeclaration setStateStep: + VisitExpression(setStateStep.ValueExpression, visitor); + break; + case WorkflowAssignBusinessReferenceStepDeclaration businessReferenceStep: + VisitExpression(businessReferenceStep.BusinessReference.KeyExpression, visitor); + foreach (var part in businessReferenceStep.BusinessReference.Parts) + { + VisitExpression(part.Expression, visitor); + } + + break; + case WorkflowTransportCallStepDeclaration transportCall: + VisitExpression(transportCall.Invocation.PayloadExpression, visitor); + if (transportCall.WhenFailure is not null) + { + VisitSequence(transportCall.WhenFailure, visitor); + } + + if (transportCall.WhenTimeout is not null) + { + VisitSequence(transportCall.WhenTimeout, visitor); + } + + break; + case WorkflowDecisionStepDeclaration decisionStep: + VisitExpression(decisionStep.ConditionExpression, visitor); + VisitSequence(decisionStep.WhenTrue, visitor); + VisitSequence(decisionStep.WhenElse, visitor); + break; + case WorkflowActivateTaskStepDeclaration activateTaskStep: + VisitExpression(activateTaskStep.RuntimeRolesExpression, visitor); + break; + case WorkflowContinueWithWorkflowStepDeclaration continueWithStep: + VisitInvocation(continueWithStep.Invocation, visitor); + break; + case WorkflowSubWorkflowStepDeclaration subWorkflowStep: + VisitInvocation(subWorkflowStep.Invocation, visitor); + break; + case WorkflowRepeatStepDeclaration repeatStep: + VisitExpression(repeatStep.MaxIterationsExpression, visitor); + VisitExpression(repeatStep.ContinueWhileExpression, visitor); + VisitSequence(repeatStep.Body, visitor); + break; + case WorkflowTimerStepDeclaration timerStep: + VisitExpression(timerStep.DelayExpression, visitor); + break; + case WorkflowExternalSignalStepDeclaration externalSignalStep: + VisitExpression(externalSignalStep.SignalNameExpression, visitor); + break; + case WorkflowForkStepDeclaration forkStep: + foreach (var branch in forkStep.Branches) + { + VisitSequence(branch, visitor); + } + + break; + } + } + } + + private static void VisitInvocation( + WorkflowWorkflowInvocationDeclaration invocation, + Action visitor) + { + VisitExpression(invocation.WorkflowNameExpression, visitor); + VisitExpression(invocation.WorkflowVersionExpression, visitor); + VisitExpression(invocation.PayloadExpression, visitor); + VisitExpression(invocation.BusinessReference?.KeyExpression, visitor); + + foreach (var part in invocation.BusinessReference?.Parts ?? []) + { + VisitExpression(part.Expression, visitor); + } + } + + private static void VisitExpression( + WorkflowExpressionDefinition? expression, + Action visitor) + { + if (expression is null) + { + return; + } + + visitor(expression); + + switch (expression) + { + case WorkflowObjectExpressionDefinition objectExpression: + foreach (var property in objectExpression.Properties) + { + VisitExpression(property.Expression, visitor); + } + + break; + case WorkflowArrayExpressionDefinition arrayExpression: + foreach (var item in arrayExpression.Items) + { + VisitExpression(item, visitor); + } + + break; + case WorkflowFunctionExpressionDefinition functionExpression: + foreach (var argument in functionExpression.Arguments) + { + VisitExpression(argument, visitor); + } + + break; + case WorkflowGroupExpressionDefinition groupExpression: + VisitExpression(groupExpression.Expression, visitor); + break; + case WorkflowUnaryExpressionDefinition unaryExpression: + VisitExpression(unaryExpression.Operand, visitor); + break; + case WorkflowBinaryExpressionDefinition binaryExpression: + VisitExpression(binaryExpression.Left, visitor); + VisitExpression(binaryExpression.Right, visitor); + break; + } + } + + private static bool ExpressionUsesFunction(WorkflowExpressionDefinition expression) + { + return expression switch + { + WorkflowFunctionExpressionDefinition => true, + WorkflowObjectExpressionDefinition objectExpression => objectExpression.Properties.Any(property => ExpressionUsesFunction(property.Expression)), + WorkflowArrayExpressionDefinition arrayExpression => arrayExpression.Items.Any(ExpressionUsesFunction), + WorkflowGroupExpressionDefinition groupExpression => ExpressionUsesFunction(groupExpression.Expression), + WorkflowUnaryExpressionDefinition unaryExpression => ExpressionUsesFunction(unaryExpression.Operand), + WorkflowBinaryExpressionDefinition binaryExpression => + ExpressionUsesFunction(binaryExpression.Left) || ExpressionUsesFunction(binaryExpression.Right), + _ => false, + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionCompiler.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionCompiler.cs new file mode 100644 index 000000000..c48d43afd --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionCompiler.cs @@ -0,0 +1,398 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public sealed record WorkflowCanonicalizationDiagnostic +{ + public required string Code { get; init; } + public required string Path { get; init; } + public required string Message { get; init; } +} + +public sealed record WorkflowCanonicalCompilationResult +{ + public required string WorkflowName { get; init; } + public required string WorkflowVersion { get; init; } + public WorkflowCanonicalDefinition? Definition { get; init; } + public IReadOnlyCollection Diagnostics { get; init; } = []; + public bool Succeeded => Definition is not null && Diagnostics.Count == 0; +} + +public static partial class WorkflowCanonicalDefinitionCompiler +{ + public static WorkflowCanonicalCompilationResult Compile( + IDeclarativeWorkflow workflow, + IWorkflowFunctionCatalog? functionCatalog = null) + where TStartRequest : class + { + ArgumentNullException.ThrowIfNull(workflow); + + var diagnostics = new List(); + AnalyzeWorkflow(workflow, diagnostics); + + return new WorkflowCanonicalCompilationResult + { + WorkflowName = workflow.WorkflowName, + WorkflowVersion = workflow.WorkflowVersion, + Definition = diagnostics.Count == 0 ? BuildDefinition(workflow, functionCatalog) : null, + Diagnostics = diagnostics, + }; + } + + private static WorkflowCanonicalDefinition BuildDefinition( + IDeclarativeWorkflow workflow, + IWorkflowFunctionCatalog? functionCatalog) + where TStartRequest : class + { + var definition = new WorkflowCanonicalDefinition + { + WorkflowName = workflow.WorkflowName, + WorkflowVersion = workflow.WorkflowVersion, + DisplayName = workflow.DisplayName, + StartRequest = BuildStartRequestContract(), + WorkflowRoles = workflow.WorkflowRoles.ToArray(), + BusinessReference = BuildStartRequestBusinessReference(typeof(TStartRequest)), + Start = new WorkflowStartDeclaration + { + InitializeStateExpression = workflow.Spec.InitializeStateExpression ?? WorkflowExpr.Null(), + InitialTaskName = workflow.Spec.InitialTaskName, + InitialSequence = BuildSequence(workflow.Spec.InitialSequence), + }, + Tasks = workflow.Spec.TasksByName.Values.Select(BuildTask).ToArray(), + }; + + return definition with + { + RequiredModules = InferRequiredModules(definition, functionCatalog), + }; + } + + private static void AnalyzeWorkflow( + IDeclarativeWorkflow workflow, + List diagnostics) + where TStartRequest : class + { + if (workflow.Spec.InitializeStateExpression is null) + { + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD001", + Path = "$.start.initializeStateExpression", + Message = $"Workflow '{workflow.WorkflowName}' initializes state through a CLR delegate. This must become a declaration expression.", + }); + } + + if (!string.IsNullOrWhiteSpace(workflow.Spec.InitialTaskName)) + { + AnalyzeTask(workflow.Spec.GetRequiredTask(workflow.Spec.InitialTaskName), diagnostics); + } + else + { + AnalyzeSequence(workflow.Spec.InitialSequence, "$.start.initialSequence", diagnostics); + } + + foreach (var task in workflow.Spec.TasksByName.Values) + { + AnalyzeTask(task, diagnostics); + AnalyzeSequence(task.OnComplete, $"$.tasks['{task.TaskName}'].onComplete", diagnostics); + } + } + + private static void AnalyzeTask( + WorkflowHumanTaskDefinition task, + List diagnostics) + where TStartRequest : class + { + if (task.RouteExpression is null) + { + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD002", + Path = $"$.tasks['{task.TaskName}'].routeExpression", + Message = $"Workflow task '{task.TaskName}' resolves its route through a CLR delegate. This must become a declaration expression.", + }); + } + + if (task.PayloadExpression is null) + { + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD002", + Path = $"$.tasks['{task.TaskName}'].payloadExpression", + Message = $"Workflow task '{task.TaskName}' builds its payload through a CLR delegate. This must become a declaration expression.", + }); + } + } + + private static void AnalyzeSequence( + WorkflowStepSequence sequence, + string path, + List diagnostics) + where TStartRequest : class + { + var steps = sequence.Steps.ToArray(); + for (var index = 0; index < steps.Length; index++) + { + var step = steps[index]; + var stepPath = $"{path}.steps[{index}]"; + + switch (step) + { + case WorkflowStateAssignmentStepDefinition assignmentStep when assignmentStep.ValueExpression is null: + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD010", + Path = stepPath, + Message = "State assignment value is currently provided through a CLR delegate. This must become a declaration expression.", + }); + break; + case WorkflowStateAssignmentStepDefinition: + break; + case WorkflowBusinessReferenceAssignmentStepDefinition businessReferenceStep + when businessReferenceStep.BusinessReferenceDeclaration is null: + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD011", + Path = stepPath, + Message = "Business reference assignment is currently provided through a CLR delegate. This must become a declaration expression.", + }); + break; + case WorkflowBusinessReferenceAssignmentStepDefinition: + break; + case WorkflowMicroserviceCallStepDefinition microserviceCall + when microserviceCall.PayloadExpression is null: + case WorkflowLegacyRabbitCallStepDefinition legacyRabbitCall + when legacyRabbitCall.PayloadExpression is null: + case WorkflowGraphqlCallStepDefinition graphqlCall + when graphqlCall.VariablesExpression is null: + case WorkflowHttpCallStepDefinition httpCall + when httpCall.PayloadExpression is null: + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD012", + Path = stepPath, + Message = "Transport call payload is currently provided through a CLR delegate. This must become a declaration expression.", + }); + AnalyzeFailureHandlers(stepPath, step, diagnostics); + break; + case WorkflowMicroserviceCallStepDefinition: + case WorkflowLegacyRabbitCallStepDefinition: + case WorkflowGraphqlCallStepDefinition: + case WorkflowHttpCallStepDefinition: + AnalyzeFailureHandlers(stepPath, step, diagnostics); + break; + case WorkflowDecisionStepDefinition decisionStep: + AnalyzeSequence(decisionStep.WhenTrue, $"{stepPath}.whenTrue", diagnostics); + AnalyzeSequence(decisionStep.WhenFalse, $"{stepPath}.whenFalse", diagnostics); + break; + case WorkflowConditionalStepDefinition conditionalStep: + if (conditionalStep.Condition.CanonicalExpression is null) + { + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD013", + Path = $"{stepPath}.condition", + Message = "WhenExpression currently contains executable CLR logic. This must become a declaration expression tree.", + }); + } + + AnalyzeSequence(conditionalStep.WhenTrue, $"{stepPath}.whenTrue", diagnostics); + AnalyzeSequence(conditionalStep.WhenElse, $"{stepPath}.whenElse", diagnostics); + break; + case WorkflowContinueWithStepDefinition continueWithStep + when continueWithStep.InvocationDeclaration is null: + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD014", + Path = stepPath, + Message = "ContinueWith currently builds workflow invocation payload and business reference through CLR delegates. This must become a declaration invocation.", + }); + break; + case WorkflowContinueWithStepDefinition: + break; + case WorkflowSubWorkflowStepDefinition subWorkflowStep + when subWorkflowStep.InvocationDeclaration is null: + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD015", + Path = stepPath, + Message = "SubWorkflow currently builds workflow invocation payload and business reference through CLR delegates. This must become a declaration invocation.", + }); + break; + case WorkflowSubWorkflowStepDefinition: + break; + case WorkflowRepeatStepDefinition repeatStep: + if (repeatStep.MaxIterationsExpression is null) + { + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD018", + Path = $"{stepPath}.maxIterationsExpression", + Message = "Repeat max-iterations is currently provided through a CLR delegate. This must become a declaration expression.", + }); + } + + if (repeatStep.ContinueWhileExpression is null) + { + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD019", + Path = $"{stepPath}.continueWhileExpression", + Message = "Repeat continue-while condition is currently provided through a CLR delegate. This must become a declaration expression.", + }); + } + + AnalyzeSequence(repeatStep.Body, $"{stepPath}.body", diagnostics); + break; + case WorkflowInlineStepDefinition: + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD016", + Path = stepPath, + Message = "Inline/Run step contains imperative executable code. This must be replaced by a first-class declaration primitive.", + }); + AnalyzeFailureHandlers(stepPath, step, diagnostics); + break; + case WorkflowTimerStepDefinition timerStep + when timerStep.DelayExpression is null: + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD017", + Path = stepPath, + Message = "Timer delay is currently provided through a CLR delegate. This must become a declaration expression.", + }); + break; + case WorkflowTimerStepDefinition: + break; + case WorkflowExternalSignalStepDefinition externalSignalStep + when externalSignalStep.SignalNameExpression is null: + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD020", + Path = $"{stepPath}.signalNameExpression", + Message = "External signal name is currently provided through a CLR delegate. This must become a declaration expression.", + }); + break; + case WorkflowExternalSignalStepDefinition: + break; + case WorkflowForkStepDefinition forkStep: + for (var branchIndex = 0; branchIndex < forkStep.Branches.Count; branchIndex++) + { + AnalyzeSequence(forkStep.Branches.ElementAt(branchIndex), $"{stepPath}.branches[{branchIndex}]", diagnostics); + } + + break; + case WorkflowActivateTaskStepDefinition: + case WorkflowCompleteStepDefinition: + break; + default: + diagnostics.Add(new WorkflowCanonicalizationDiagnostic + { + Code = "WFCD099", + Path = stepPath, + Message = $"Unknown workflow step type '{step.GetType().FullName}' encountered during canonicalization analysis.", + }); + break; + } + } + } + + private static void AnalyzeFailureHandlers( + string stepPath, + WorkflowStepDefinition step, + List diagnostics) + where TStartRequest : class + { + WorkflowFailureHandlers? failureHandlers = step switch + { + WorkflowMicroserviceCallStepDefinition call => call.FailureHandlers, + WorkflowLegacyRabbitCallStepDefinition call => call.FailureHandlers, + WorkflowGraphqlCallStepDefinition call => call.FailureHandlers, + WorkflowHttpCallStepDefinition call => call.FailureHandlers, + WorkflowInlineStepDefinition inline => inline.FailureHandlers, + _ => null, + }; + + if (failureHandlers is null) + { + return; + } + + AnalyzeSequence(failureHandlers.WhenFailure, $"{stepPath}.whenFailure", diagnostics); + AnalyzeSequence(failureHandlers.WhenTimeout, $"{stepPath}.whenTimeout", diagnostics); + } + + private static WorkflowRequestContractDeclaration BuildStartRequestContract() + where TStartRequest : class + { + var type = typeof(TStartRequest); + var schema = new Dictionary + { + ["type"] = "object", + ["properties"] = BuildPropertiesSchema(type), + }; + + return new WorkflowRequestContractDeclaration + { + ContractName = type.FullName ?? type.Name, + Schema = schema, + AllowAdditionalProperties = true, + }; + } + + private static Dictionary BuildPropertiesSchema(Type type) + { + var properties = new Dictionary(); + + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var jsonType = GetJsonSchemaType(prop.PropertyType); + var propSchema = new Dictionary { ["type"] = jsonType }; + + if (prop.PropertyType.IsEnum) + { + propSchema["enum"] = Enum.GetNames(prop.PropertyType); + } + + if (jsonType == "array") + { + var elementType = prop.PropertyType.IsArray + ? prop.PropertyType.GetElementType() + : prop.PropertyType.GetGenericArguments().FirstOrDefault(); + if (elementType is not null) + { + propSchema["items"] = new Dictionary { ["type"] = GetJsonSchemaType(elementType) }; + } + } + + // Use camelCase property name to match JSON serialization convention + var name = char.ToLowerInvariant(prop.Name[0]) + prop.Name[1..]; + properties[name] = propSchema; + } + + return properties; + } + + private static string GetJsonSchemaType(Type type) + { + var underlying = Nullable.GetUnderlyingType(type) ?? type; + + if (underlying == typeof(string)) return "string"; + if (underlying == typeof(bool)) return "boolean"; + if (underlying == typeof(int) || underlying == typeof(long) || underlying == typeof(short) + || underlying == typeof(decimal) || underlying == typeof(double) || underlying == typeof(float)) return "number"; + if (underlying == typeof(DateTime) || underlying == typeof(DateTimeOffset)) return "string"; + if (underlying.IsEnum) return "string"; + if (underlying.IsArray || (underlying.IsGenericType && + typeof(global::System.Collections.IEnumerable).IsAssignableFrom(underlying))) return "array"; + if (underlying == typeof(JsonElement) || underlying == typeof(object)) return "object"; + + return "object"; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionValidator.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionValidator.cs new file mode 100644 index 000000000..89cae8db0 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionValidator.cs @@ -0,0 +1,1046 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public sealed record WorkflowCanonicalValidationError +{ + public required string Code { get; init; } + public required string Path { get; init; } + public required string Message { get; init; } +} + +public sealed record WorkflowCanonicalValidationResult +{ + public IReadOnlyCollection Errors { get; init; } = []; + public bool Succeeded => Errors.Count == 0; +} + +public static class WorkflowCanonicalDefinitionValidator +{ + public static WorkflowCanonicalValidationResult ValidateExpression( + WorkflowExpressionDefinition expression, + IWorkflowFunctionCatalog? functionCatalog = null) + { + ArgumentNullException.ThrowIfNull(expression); + + var errors = new List(); + ValidateExpressionCore(expression, "$", errors, functionCatalog); + return new WorkflowCanonicalValidationResult + { + Errors = errors, + }; + } + + public static WorkflowCanonicalValidationResult ValidateBusinessReference( + WorkflowBusinessReferenceDeclaration businessReference, + IWorkflowFunctionCatalog? functionCatalog = null) + { + ArgumentNullException.ThrowIfNull(businessReference); + + var errors = new List(); + ValidateBusinessReferenceCore(businessReference, "$", errors, functionCatalog); + return new WorkflowCanonicalValidationResult + { + Errors = errors, + }; + } + + public static WorkflowCanonicalValidationResult ValidateTask( + WorkflowTaskDeclaration task, + IWorkflowFunctionCatalog? functionCatalog = null) + { + ArgumentNullException.ThrowIfNull(task); + + var definition = new WorkflowCanonicalDefinition + { + WorkflowName = "FragmentValidationWorkflow", + WorkflowVersion = "1.0.0", + DisplayName = "Fragment Validation Workflow", + Start = new WorkflowStartDeclaration + { + InitializeStateExpression = WorkflowExpr.Obj(), + InitialTaskName = task.TaskName, + }, + Tasks = [task], + }; + + return Validate(definition, functionCatalog); + } + + public static WorkflowCanonicalValidationResult Validate( + WorkflowCanonicalDefinition definition, + IWorkflowFunctionCatalog? functionCatalog = null) + { + ArgumentNullException.ThrowIfNull(definition); + + var errors = new List(); + + RequireText(definition.WorkflowName, "$.workflowName", "Workflow name is required."); + RequireText(definition.WorkflowVersion, "$.workflowVersion", "Workflow version is required."); + RequireText(definition.DisplayName, "$.displayName", "Workflow display name is required."); + if (!string.IsNullOrWhiteSpace(definition.WorkflowVersion) + && !WorkflowVersioning.TryParseSemanticVersion(definition.WorkflowVersion, out _)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL002", + Path = "$.workflowVersion", + Message = $"Workflow version '{definition.WorkflowVersion}' must be in strict semantic version format 'major.minor.patch'.", + }); + } + + if (!string.Equals(definition.SchemaVersion, WorkflowCanonicalDefinitionSchema.Version1, StringComparison.Ordinal)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL001", + Path = "$.schemaVersion", + Message = $"Workflow schema version '{definition.SchemaVersion}' is not supported.", + }); + } + + if (definition.StartRequest is not null && string.IsNullOrWhiteSpace(definition.StartRequest.ContractName)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL003", + Path = "$.startRequest.contractName", + Message = "Workflow start request contract name is required when startRequest is declared.", + }); + } + + ValidateRequiredModules(definition.RequiredModules, errors); + ValidateRequiredCapabilities(definition.RequiredCapabilities, errors); + + if (definition.BusinessReference is not null) + { + ValidateBusinessReferenceCore(definition.BusinessReference, "$.businessReference", errors, functionCatalog); + } + + ValidateStart(definition, errors, functionCatalog); + ValidateTasks(definition, errors, functionCatalog); + + return new WorkflowCanonicalValidationResult + { + Errors = errors, + }; + + void RequireText(string? value, string path, string message) + { + if (!string.IsNullOrWhiteSpace(value)) + { + return; + } + + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL000", + Path = path, + Message = message, + }); + } + } + + private static void ValidateStart( + WorkflowCanonicalDefinition definition, + List errors, + IWorkflowFunctionCatalog? functionCatalog) + { + if (definition.Start.InitializeStateExpression is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL010", + Path = "$.start.initializeStateExpression", + Message = "Workflow start initializer expression is required.", + }); + } + else + { + ValidateExpressionCore(definition.Start.InitializeStateExpression, "$.start.initializeStateExpression", errors, functionCatalog); + } + + var hasInitialTask = !string.IsNullOrWhiteSpace(definition.Start.InitialTaskName); + var hasInitialSequence = definition.Start.InitialSequence.Steps.Count > 0; + if (!hasInitialTask && !hasInitialSequence) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL011", + Path = "$.start", + Message = "Workflow start must define either initialTaskName or initialSequence.", + }); + } + + if (hasInitialTask + && !definition.Tasks.Any(x => string.Equals(x.TaskName, definition.Start.InitialTaskName, StringComparison.OrdinalIgnoreCase))) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL012", + Path = "$.start.initialTaskName", + Message = $"Initial task '{definition.Start.InitialTaskName}' is not declared.", + }); + } + + ValidateSequence(definition.Start.InitialSequence, "$.start.initialSequence", definition.Tasks, errors, functionCatalog); + } + + private static void ValidateTasks( + WorkflowCanonicalDefinition definition, + List errors, + IWorkflowFunctionCatalog? functionCatalog) + { + var duplicateTaskNames = definition.Tasks + .GroupBy(x => x.TaskName, StringComparer.OrdinalIgnoreCase) + .Where(x => x.Count() > 1) + .Select(x => x.Key) + .ToArray(); + + foreach (var duplicateTaskName in duplicateTaskNames) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL020", + Path = "$.tasks", + Message = $"Workflow task '{duplicateTaskName}' is declared more than once.", + }); + } + + foreach (var task in definition.Tasks) + { + if (string.IsNullOrWhiteSpace(task.TaskName)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL021", + Path = "$.tasks[].taskName", + Message = "Workflow task name is required.", + }); + } + + if (string.IsNullOrWhiteSpace(task.TaskType)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL022", + Path = $"$.tasks['{task.TaskName}'].taskType", + Message = $"Workflow task '{task.TaskName}' requires a task type.", + }); + } + + if (task.RouteExpression is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL023", + Path = $"$.tasks['{task.TaskName}'].routeExpression", + Message = $"Workflow task '{task.TaskName}' requires a route expression.", + }); + } + else + { + ValidateExpressionCore(task.RouteExpression, $"$.tasks['{task.TaskName}'].routeExpression", errors, functionCatalog); + } + + if (task.PayloadExpression is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL024", + Path = $"$.tasks['{task.TaskName}'].payloadExpression", + Message = $"Workflow task '{task.TaskName}' requires a payload expression.", + }); + } + else + { + ValidateExpressionCore(task.PayloadExpression, $"$.tasks['{task.TaskName}'].payloadExpression", errors, functionCatalog); + } + + ValidateSequence(task.OnComplete, $"$.tasks['{task.TaskName}'].onComplete", definition.Tasks, errors, functionCatalog); + } + } + + private static void ValidateSequence( + WorkflowStepSequenceDeclaration sequence, + string path, + IReadOnlyCollection tasks, + List errors, + IWorkflowFunctionCatalog? functionCatalog) + { + for (var index = 0; index < sequence.Steps.Count; index++) + { + var step = sequence.Steps.ElementAt(index); + var stepPath = $"{path}.steps[{index}]"; + + switch (step) + { + case WorkflowSetStateStepDeclaration assignment: + if (string.IsNullOrWhiteSpace(assignment.StateKey)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL030", + Path = $"{stepPath}.stateKey", + Message = "State assignment requires a state key.", + }); + } + + if (assignment.ValueExpression is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL031", + Path = $"{stepPath}.valueExpression", + Message = "State assignment requires a value expression.", + }); + } + else + { + ValidateExpressionCore(assignment.ValueExpression, $"{stepPath}.valueExpression", errors, functionCatalog); + } + + break; + case WorkflowAssignBusinessReferenceStepDeclaration businessReferenceStep: + if (businessReferenceStep.BusinessReference is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL032", + Path = $"{stepPath}.businessReference", + Message = "Business reference assignment requires a business reference declaration.", + }); + } + else + { + ValidateBusinessReferenceCore(businessReferenceStep.BusinessReference, $"{stepPath}.businessReference", errors, functionCatalog); + } + + break; + case WorkflowTransportCallStepDeclaration transportStep: + if (string.IsNullOrWhiteSpace(transportStep.StepName)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL033", + Path = $"{stepPath}.stepName", + Message = "Transport call step requires a step name.", + }); + } + + if (transportStep.Invocation?.Address is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL034", + Path = $"{stepPath}.invocation.address", + Message = "Transport call step requires an address declaration.", + }); + } + else + { + ValidateTransportInvocation(transportStep.Invocation, $"{stepPath}.invocation", errors, functionCatalog); + } + + if (transportStep.WhenFailure is not null) + { + ValidateSequence(transportStep.WhenFailure, $"{stepPath}.whenFailure", tasks, errors, functionCatalog); + } + + if (transportStep.WhenTimeout is not null) + { + ValidateSequence(transportStep.WhenTimeout, $"{stepPath}.whenTimeout", tasks, errors, functionCatalog); + } + + break; + case WorkflowDecisionStepDeclaration decisionStep: + if (string.IsNullOrWhiteSpace(decisionStep.DecisionName)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL035", + Path = $"{stepPath}.decisionName", + Message = "Decision step requires a decision name.", + }); + } + + if (decisionStep.ConditionExpression is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL036", + Path = $"{stepPath}.conditionExpression", + Message = "Decision step requires a condition expression.", + }); + } + else + { + ValidateExpressionCore(decisionStep.ConditionExpression, $"{stepPath}.conditionExpression", errors, functionCatalog); + } + + ValidateSequence(decisionStep.WhenTrue, $"{stepPath}.whenTrue", tasks, errors, functionCatalog); + ValidateSequence(decisionStep.WhenElse, $"{stepPath}.whenElse", tasks, errors, functionCatalog); + break; + case WorkflowActivateTaskStepDeclaration activateTaskStep: + if (string.IsNullOrWhiteSpace(activateTaskStep.TaskName)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL037", + Path = $"{stepPath}.taskName", + Message = "ActivateTask step requires a task name.", + }); + } + else if (!tasks.Any(x => string.Equals(x.TaskName, activateTaskStep.TaskName, StringComparison.OrdinalIgnoreCase))) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL038", + Path = $"{stepPath}.taskName", + Message = $"ActivateTask references undeclared task '{activateTaskStep.TaskName}'.", + }); + } + + if (activateTaskStep.RuntimeRolesExpression is not null) + { + ValidateExpressionCore(activateTaskStep.RuntimeRolesExpression, $"{stepPath}.runtimeRolesExpression", errors, functionCatalog); + } + + break; + case WorkflowContinueWithWorkflowStepDeclaration continueWithStep: + ValidateWorkflowInvocation(continueWithStep.Invocation, stepPath, errors, functionCatalog); + break; + case WorkflowSubWorkflowStepDeclaration subWorkflowStep: + ValidateWorkflowInvocation(subWorkflowStep.Invocation, stepPath, errors, functionCatalog); + break; + case WorkflowRepeatStepDeclaration repeatStep: + if (string.IsNullOrWhiteSpace(repeatStep.StepName)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL043", + Path = $"{stepPath}.stepName", + Message = "Repeat step requires a step name.", + }); + } + + if (repeatStep.MaxIterationsExpression is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL044", + Path = $"{stepPath}.maxIterationsExpression", + Message = "Repeat step requires a max-iterations expression.", + }); + } + else + { + ValidateExpressionCore(repeatStep.MaxIterationsExpression, $"{stepPath}.maxIterationsExpression", errors, functionCatalog); + } + + if (repeatStep.ContinueWhileExpression is not null) + { + ValidateExpressionCore(repeatStep.ContinueWhileExpression, $"{stepPath}.continueWhileExpression", errors, functionCatalog); + } + + ValidateSequence(repeatStep.Body, $"{stepPath}.body", tasks, errors, functionCatalog); + break; + case WorkflowTimerStepDeclaration timerStep: + if (string.IsNullOrWhiteSpace(timerStep.StepName)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL039", + Path = $"{stepPath}.stepName", + Message = "Timer step requires a step name.", + }); + } + + if (timerStep.DelayExpression is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL040", + Path = $"{stepPath}.delayExpression", + Message = "Timer step requires a delay expression.", + }); + } + else + { + ValidateExpressionCore(timerStep.DelayExpression, $"{stepPath}.delayExpression", errors, functionCatalog); + } + + break; + case WorkflowExternalSignalStepDeclaration externalSignalStep: + if (string.IsNullOrWhiteSpace(externalSignalStep.StepName)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL067", + Path = $"{stepPath}.stepName", + Message = "External signal step requires a step name.", + }); + } + + if (externalSignalStep.SignalNameExpression is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL068", + Path = $"{stepPath}.signalNameExpression", + Message = "External signal step requires a signal-name expression.", + }); + } + else + { + ValidateExpressionCore(externalSignalStep.SignalNameExpression, $"{stepPath}.signalNameExpression", errors, functionCatalog); + } + + break; + case WorkflowForkStepDeclaration forkStep: + if (string.IsNullOrWhiteSpace(forkStep.StepName)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL041", + Path = $"{stepPath}.stepName", + Message = "Fork step requires a step name.", + }); + } + + if (forkStep.Branches.Count == 0) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL042", + Path = $"{stepPath}.branches", + Message = "Fork step requires at least one branch.", + }); + } + + for (var branchIndex = 0; branchIndex < forkStep.Branches.Count; branchIndex++) + { + ValidateSequence( + forkStep.Branches.ElementAt(branchIndex), + $"{stepPath}.branches[{branchIndex}]", + tasks, + errors, + functionCatalog); + } + + break; + case WorkflowCompleteStepDeclaration: + break; + default: + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL099", + Path = stepPath, + Message = $"Unknown canonical workflow step type '{step.GetType().FullName}'.", + }); + break; + } + } + } + + private static void ValidateWorkflowInvocation( + WorkflowWorkflowInvocationDeclaration? invocation, + string path, + List errors, + IWorkflowFunctionCatalog? functionCatalog) + { + if (invocation is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL043", + Path = $"{path}.invocation", + Message = "Workflow invocation is required.", + }); + return; + } + + if (invocation.WorkflowNameExpression is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL044", + Path = $"{path}.workflowNameExpression", + Message = "Workflow invocation requires a workflow name expression.", + }); + } + else + { + ValidateExpressionCore(invocation.WorkflowNameExpression, $"{path}.workflowNameExpression", errors, functionCatalog); + } + + if (invocation.WorkflowVersionExpression is not null) + { + ValidateExpressionCore(invocation.WorkflowVersionExpression, $"{path}.workflowVersionExpression", errors, functionCatalog); + } + + if (invocation.PayloadExpression is not null) + { + ValidateExpressionCore(invocation.PayloadExpression, $"{path}.payloadExpression", errors, functionCatalog); + } + + if (invocation.BusinessReference is not null) + { + ValidateBusinessReferenceCore(invocation.BusinessReference, $"{path}.businessReference", errors, functionCatalog); + } + } + + private static void ValidateTransportInvocation( + WorkflowTransportInvocationDeclaration invocation, + string path, + List errors, + IWorkflowFunctionCatalog? functionCatalog) + { + ValidateTransportAddress(invocation.Address, $"{path}.address", errors); + + if (invocation.PayloadExpression is not null) + { + ValidateExpressionCore(invocation.PayloadExpression, $"{path}.payloadExpression", errors, functionCatalog); + } + } + + private static void ValidateTransportAddress( + WorkflowTransportAddressDeclaration address, + string path, + List errors) + { + switch (address) + { + case WorkflowMicroserviceAddressDeclaration microserviceAddress: + RequireText(microserviceAddress.MicroserviceName, $"{path}.microserviceName", "Microservice address requires a microservice name."); + RequireText(microserviceAddress.Command, $"{path}.command", "Microservice address requires a command."); + break; + case WorkflowRabbitAddressDeclaration rabbitAddress: + RequireText(rabbitAddress.Exchange, $"{path}.exchange", "Rabbit address requires an exchange."); + RequireText(rabbitAddress.RoutingKey, $"{path}.routingKey", "Rabbit address requires a routing key."); + break; + case WorkflowLegacyRabbitAddressDeclaration legacyRabbitAddress: + RequireText(legacyRabbitAddress.Command, $"{path}.command", "Legacy Rabbit address requires a command."); + break; + case WorkflowGraphqlAddressDeclaration graphqlAddress: + RequireText(graphqlAddress.Target, $"{path}.target", "GraphQL address requires a target."); + RequireText(graphqlAddress.Query, $"{path}.query", "GraphQL address requires a query."); + break; + case WorkflowHttpAddressDeclaration httpAddress: + RequireText(httpAddress.Target, $"{path}.target", "HTTP address requires a target."); + RequireText(httpAddress.Path, $"{path}.path", "HTTP address requires a path."); + RequireText(httpAddress.Method, $"{path}.method", "HTTP address requires a method."); + break; + default: + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL045", + Path = path, + Message = $"Unknown transport address type '{address.GetType().FullName}'.", + }); + break; + } + + void RequireText(string? value, string errorPath, string message) + { + if (!string.IsNullOrWhiteSpace(value)) + { + return; + } + + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL046", + Path = errorPath, + Message = message, + }); + } + } + + private static void ValidateRequiredModules( + IReadOnlyCollection modules, + List errors) + { + var duplicateModuleNames = modules + .Where(module => !string.IsNullOrWhiteSpace(module.ModuleName)) + .GroupBy(module => module.ModuleName, StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToArray(); + + foreach (var duplicateModuleName in duplicateModuleNames) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL004", + Path = "$.requiredModules", + Message = $"Workflow required module '{duplicateModuleName}' is declared more than once.", + }); + } + + for (var index = 0; index < modules.Count; index++) + { + var module = modules.ElementAt(index); + var modulePath = $"$.requiredModules[{index}]"; + + if (string.IsNullOrWhiteSpace(module.ModuleName)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL005", + Path = $"{modulePath}.moduleName", + Message = "Workflow required module name is required.", + }); + } + + if (string.IsNullOrWhiteSpace(module.VersionExpression)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL006", + Path = $"{modulePath}.versionExpression", + Message = "Workflow required module version expression is required.", + }); + continue; + } + + if (!WorkflowModuleVersionExpression.TryParse(module.VersionExpression, out _, out var error)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL007", + Path = $"{modulePath}.versionExpression", + Message = error ?? $"Workflow required module version expression '{module.VersionExpression}' is invalid.", + }); + } + } + } + + private static void ValidateRequiredCapabilities( + IReadOnlyCollection requiredCapabilities, + List errors) + { + for (var index = 0; index < requiredCapabilities.Count; index++) + { + if (!string.IsNullOrWhiteSpace(requiredCapabilities.ElementAt(index))) + { + continue; + } + + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL008", + Path = $"$.requiredCapabilities[{index}]", + Message = "Workflow required capability value cannot be empty.", + }); + } + } + + private static void ValidateBusinessReferenceCore( + WorkflowBusinessReferenceDeclaration businessReference, + string path, + List errors, + IWorkflowFunctionCatalog? functionCatalog) + { + if (businessReference.KeyExpression is not null) + { + ValidateExpressionCore(businessReference.KeyExpression, $"{path}.keyExpression", errors, functionCatalog); + } + + foreach (var part in businessReference.Parts) + { + if (string.IsNullOrWhiteSpace(part.Name)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL047", + Path = $"{path}.parts[].name", + Message = "Business reference part requires a name.", + }); + } + + if (part.Expression is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL048", + Path = $"{path}.parts['{part.Name}'].expression", + Message = "Business reference part requires an expression.", + }); + } + else + { + ValidateExpressionCore(part.Expression, $"{path}.parts['{part.Name}'].expression", errors, functionCatalog); + } + } + } + + private static void ValidateExpressionCore( + WorkflowExpressionDefinition expression, + string path, + List errors, + IWorkflowFunctionCatalog? functionCatalog) + { + switch (expression) + { + case WorkflowNullExpressionDefinition: + case WorkflowBooleanExpressionDefinition: + break; + case WorkflowStringExpressionDefinition stringExpression: + if (stringExpression.Value is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL049", + Path = $"{path}.value", + Message = "String expression requires a value.", + }); + } + + break; + case WorkflowNumberExpressionDefinition numberExpression: + if (string.IsNullOrWhiteSpace(numberExpression.Value)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL050", + Path = $"{path}.value", + Message = "Number expression requires a numeric string value.", + }); + } + + break; + case WorkflowPathExpressionDefinition pathExpression: + if (string.IsNullOrWhiteSpace(pathExpression.Path)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL051", + Path = $"{path}.path", + Message = "Path expression requires a path.", + }); + } + + break; + case WorkflowObjectExpressionDefinition objectExpression: + foreach (var property in objectExpression.Properties) + { + if (string.IsNullOrWhiteSpace(property.Name)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL052", + Path = $"{path}.properties[].name", + Message = "Object expression property requires a name.", + }); + } + + if (property.Expression is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL053", + Path = $"{path}.properties['{property.Name}'].expression", + Message = "Object expression property requires an expression.", + }); + } + else + { + ValidateExpressionCore(property.Expression, $"{path}.properties['{property.Name}'].expression", errors, functionCatalog); + } + } + + break; + case WorkflowArrayExpressionDefinition arrayExpression: + for (var index = 0; index < arrayExpression.Items.Count; index++) + { + var item = arrayExpression.Items.ElementAt(index); + if (item is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL054", + Path = $"{path}.items[{index}]", + Message = "Array expression item cannot be null.", + }); + continue; + } + + ValidateExpressionCore(item, $"{path}.items[{index}]", errors, functionCatalog); + } + + break; + case WorkflowFunctionExpressionDefinition functionExpression: + if (string.IsNullOrWhiteSpace(functionExpression.FunctionName)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL055", + Path = $"{path}.functionName", + Message = "Function expression requires a function name.", + }); + } + else if (functionCatalog is not null) + { + if (!functionCatalog.TryGetFunction(functionExpression.FunctionName, out var descriptor)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL064", + Path = $"{path}.functionName", + Message = $"Function '{functionExpression.FunctionName}' is not registered in the workflow function catalog.", + }); + } + else + { + ValidateFunctionArguments(functionExpression, descriptor, path, errors); + } + } + + for (var index = 0; index < functionExpression.Arguments.Count; index++) + { + var argument = functionExpression.Arguments.ElementAt(index); + if (argument is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL056", + Path = $"{path}.arguments[{index}]", + Message = "Function expression argument cannot be null.", + }); + continue; + } + + ValidateExpressionCore(argument, $"{path}.arguments[{index}]", errors, functionCatalog); + } + + break; + case WorkflowGroupExpressionDefinition groupExpression: + if (groupExpression.Expression is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL057", + Path = $"{path}.expression", + Message = "Group expression requires an inner expression.", + }); + break; + } + + ValidateExpressionCore(groupExpression.Expression, $"{path}.expression", errors, functionCatalog); + break; + case WorkflowUnaryExpressionDefinition unaryExpression: + if (string.IsNullOrWhiteSpace(unaryExpression.Operator)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL058", + Path = $"{path}.operator", + Message = "Unary expression requires an operator.", + }); + } + + if (unaryExpression.Operand is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL059", + Path = $"{path}.operand", + Message = "Unary expression requires an operand.", + }); + } + else + { + ValidateExpressionCore(unaryExpression.Operand, $"{path}.operand", errors, functionCatalog); + } + + break; + case WorkflowBinaryExpressionDefinition binaryExpression: + if (string.IsNullOrWhiteSpace(binaryExpression.Operator)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL060", + Path = $"{path}.operator", + Message = "Binary expression requires an operator.", + }); + } + + if (binaryExpression.Left is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL061", + Path = $"{path}.left", + Message = "Binary expression requires a left operand.", + }); + } + else + { + ValidateExpressionCore(binaryExpression.Left, $"{path}.left", errors, functionCatalog); + } + + if (binaryExpression.Right is null) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL062", + Path = $"{path}.right", + Message = "Binary expression requires a right operand.", + }); + } + else + { + ValidateExpressionCore(binaryExpression.Right, $"{path}.right", errors, functionCatalog); + } + + break; + default: + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL063", + Path = path, + Message = $"Unknown workflow expression type '{expression.GetType().FullName}'.", + }); + break; + } + } + + private static void ValidateFunctionArguments( + WorkflowFunctionExpressionDefinition functionExpression, + WorkflowFunctionDescriptor descriptor, + string path, + List errors) + { + var arguments = descriptor.Arguments.ToArray(); + var requiredCount = arguments.Count(x => x.Required); + var hasVariadic = arguments.Any(x => x.Variadic); + var actualCount = functionExpression.Arguments.Count; + + if (actualCount < requiredCount) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL065", + Path = $"{path}.arguments", + Message = + $"Function '{functionExpression.FunctionName}' requires at least {requiredCount} argument(s), but {actualCount} were provided.", + }); + } + + if (!hasVariadic && actualCount > arguments.Length) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFVAL066", + Path = $"{path}.arguments", + Message = + $"Function '{functionExpression.FunctionName}' allows at most {arguments.Length} argument(s), but {actualCount} were provided.", + }); + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalEvaluationContext.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalEvaluationContext.cs new file mode 100644 index 000000000..d32ef1cd5 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalEvaluationContext.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Text.Json; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public sealed record WorkflowCanonicalEvaluationContext +{ + public JsonElement? Start { get; init; } + public JsonElement? State { get; init; } + public JsonElement? Payload { get; init; } + public JsonElement? Result { get; init; } + public WorkflowBusinessReference? BusinessReference { get; init; } + public IWorkflowFunctionRuntime? FunctionRuntime { get; init; } + + public static WorkflowCanonicalEvaluationContext From( + WorkflowSpecExecutionContext context) + where TStartRequest : class + { + return new WorkflowCanonicalEvaluationContext + { + Start = context.StartRequest is null ? null : context.StartRequest.AsJsonElement(), + State = context.WorkflowState.AsJsonElement(), + Payload = context.Payload.AsJsonElement(), + Result = context.ResultValues.AsJsonElement(), + BusinessReference = context.BusinessReference, + FunctionRuntime = context.FunctionRuntime, + }; + } + + public static WorkflowCanonicalEvaluationContext ForStartRequest( + TStartRequest startRequest, + IWorkflowFunctionRuntime? functionRuntime = null) + { + return new WorkflowCanonicalEvaluationContext + { + Start = startRequest is null ? null : startRequest.AsJsonElement(), + FunctionRuntime = functionRuntime, + }; + } + + public static WorkflowCanonicalEvaluationContext ForResult( + JsonElement result, + IWorkflowFunctionRuntime? functionRuntime = null) + { + return new WorkflowCanonicalEvaluationContext + { + Result = result.Clone(), + FunctionRuntime = functionRuntime, + }; + } + + public static WorkflowCanonicalEvaluationContext ForState( + IReadOnlyDictionary state, + IReadOnlyDictionary? results = null, + WorkflowBusinessReference? businessReference = null, + IWorkflowFunctionRuntime? functionRuntime = null) + { + return new WorkflowCanonicalEvaluationContext + { + State = state.AsJsonElement(), + Result = results?.AsJsonElement(), + BusinessReference = businessReference, + FunctionRuntime = functionRuntime, + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalExpressionBuilder.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalExpressionBuilder.cs new file mode 100644 index 000000000..17e281366 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalExpressionBuilder.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public static class WorkflowExpr +{ + public static WorkflowNamedExpressionDefinition Prop(string name, WorkflowExpressionDefinition expression) + { + return new WorkflowNamedExpressionDefinition + { + Name = name, + Expression = expression ?? throw new ArgumentNullException(nameof(expression)), + }; + } + + public static WorkflowNullExpressionDefinition Null() + { + return new WorkflowNullExpressionDefinition(); + } + + public static WorkflowExpressionDefinition FromValue(object? value) + { + return value switch + { + null => Null(), + WorkflowExpressionDefinition expression => expression, + JsonElement element => FromJsonElement(element), + _ => FromJsonElement(value.AsJsonElement()), + }; + } + + public static WorkflowStringExpressionDefinition String(string value) + { + return new WorkflowStringExpressionDefinition + { + Value = value, + }; + } + + public static WorkflowBooleanExpressionDefinition Bool(bool value) + { + return new WorkflowBooleanExpressionDefinition + { + Value = value, + }; + } + + public static WorkflowNumberExpressionDefinition Number(long value) + { + return new WorkflowNumberExpressionDefinition + { + Value = value.ToString(CultureInfo.InvariantCulture), + }; + } + + public static WorkflowNumberExpressionDefinition Number(decimal value) + { + return new WorkflowNumberExpressionDefinition + { + Value = value.ToString(CultureInfo.InvariantCulture), + }; + } + + public static WorkflowPathExpressionDefinition Path(string path) + { + return new WorkflowPathExpressionDefinition + { + Path = path, + }; + } + + public static WorkflowObjectExpressionDefinition Obj(params WorkflowNamedExpressionDefinition[] properties) + { + return new WorkflowObjectExpressionDefinition + { + Properties = properties, + }; + } + + public static WorkflowObjectExpressionDefinition Obj(IEnumerable properties) + { + return new WorkflowObjectExpressionDefinition + { + Properties = properties is WorkflowNamedExpressionDefinition[] array ? array : [.. properties], + }; + } + + public static WorkflowArrayExpressionDefinition Array(params WorkflowExpressionDefinition[] items) + { + return new WorkflowArrayExpressionDefinition + { + Items = items, + }; + } + + public static WorkflowArrayExpressionDefinition Array(IEnumerable items) + { + return new WorkflowArrayExpressionDefinition + { + Items = items is WorkflowExpressionDefinition[] array ? array : [.. items], + }; + } + + public static WorkflowFunctionExpressionDefinition Func(string functionName, params WorkflowExpressionDefinition[] arguments) + { + return new WorkflowFunctionExpressionDefinition + { + FunctionName = functionName, + Arguments = arguments, + }; + } + + public static WorkflowGroupExpressionDefinition Group(WorkflowExpressionDefinition expression) + { + return new WorkflowGroupExpressionDefinition + { + Expression = expression ?? throw new ArgumentNullException(nameof(expression)), + }; + } + + public static WorkflowUnaryExpressionDefinition Not(WorkflowExpressionDefinition operand) + { + return new WorkflowUnaryExpressionDefinition + { + Operator = "not", + Operand = operand, + }; + } + + public static WorkflowBinaryExpressionDefinition Eq(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right) + { + return Binary("eq", left, right); + } + + public static WorkflowBinaryExpressionDefinition Ne(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right) + { + return Binary("ne", left, right); + } + + public static WorkflowBinaryExpressionDefinition And(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right) + { + return Binary("and", left, right); + } + + public static WorkflowBinaryExpressionDefinition Or(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right) + { + return Binary("or", left, right); + } + + public static WorkflowBinaryExpressionDefinition Gt(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right) + { + return Binary("gt", left, right); + } + + public static WorkflowBinaryExpressionDefinition Gte(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right) + { + return Binary("gte", left, right); + } + + public static WorkflowBinaryExpressionDefinition Lt(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right) + { + return Binary("lt", left, right); + } + + public static WorkflowBinaryExpressionDefinition Lte(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right) + { + return Binary("lte", left, right); + } + + public static WorkflowBinaryExpressionDefinition Binary( + string @operator, + WorkflowExpressionDefinition left, + WorkflowExpressionDefinition right) + { + return new WorkflowBinaryExpressionDefinition + { + Operator = @operator, + Left = left, + Right = right, + }; + } + + private static WorkflowExpressionDefinition FromJsonElement(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Undefined or JsonValueKind.Null => Null(), + JsonValueKind.String => String(element.GetString() ?? string.Empty), + JsonValueKind.True => Bool(true), + JsonValueKind.False => Bool(false), + JsonValueKind.Number when element.TryGetInt64(out var int64Value) => Number(int64Value), + JsonValueKind.Number when element.TryGetDecimal(out var decimalValue) => Number(decimalValue), + JsonValueKind.Number => new WorkflowNumberExpressionDefinition + { + Value = element.GetRawText(), + }, + JsonValueKind.Object => Obj(BuildObjectProperties(element)), + JsonValueKind.Array => Array(BuildArrayItems(element)), + _ => String(element.ToString()), + }; + } + + private static IEnumerable BuildObjectProperties(JsonElement element) + { + foreach (var property in element.EnumerateObject()) + { + yield return Prop(property.Name, FromJsonElement(property.Value)); + } + } + + private static IEnumerable BuildArrayItems(JsonElement element) + { + foreach (var item in element.EnumerateArray()) + { + yield return FromJsonElement(item); + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalExpressionRuntime.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalExpressionRuntime.cs new file mode 100644 index 000000000..fabb7c215 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalExpressionRuntime.cs @@ -0,0 +1,637 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public static class WorkflowCanonicalExpressionRuntime +{ + public static object? Evaluate( + WorkflowExpressionDefinition expression, + WorkflowSpecExecutionContext context) + where TStartRequest : class + { + return Evaluate(expression, WorkflowCanonicalEvaluationContext.From(context)); + } + + public static object? Evaluate( + WorkflowExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + ArgumentNullException.ThrowIfNull(expression); + ArgumentNullException.ThrowIfNull(context); + + return expression switch + { + WorkflowNullExpressionDefinition => null, + WorkflowStringExpressionDefinition stringExpression => stringExpression.Value, + WorkflowNumberExpressionDefinition numberExpression => ParseNumber(numberExpression.Value), + WorkflowBooleanExpressionDefinition booleanExpression => booleanExpression.Value, + WorkflowPathExpressionDefinition pathExpression => ResolvePath(pathExpression.Path, context), + WorkflowObjectExpressionDefinition objectExpression => EvaluateObject(objectExpression, context), + WorkflowArrayExpressionDefinition arrayExpression => EvaluateArray(arrayExpression, context), + WorkflowFunctionExpressionDefinition functionExpression => EvaluateFunction(functionExpression, context), + WorkflowGroupExpressionDefinition groupExpression => Evaluate(groupExpression.Expression, context), + WorkflowUnaryExpressionDefinition unaryExpression => EvaluateUnary(unaryExpression, context), + WorkflowBinaryExpressionDefinition binaryExpression => EvaluateBinary(binaryExpression, context), + _ => throw new InvalidOperationException( + $"Workflow expression type '{expression.GetType().FullName}' is not supported by the canonical runtime."), + }; + } + + public static WorkflowBusinessReference EvaluateBusinessReference( + WorkflowBusinessReferenceDeclaration declaration, + WorkflowSpecExecutionContext context) + where TStartRequest : class + { + return EvaluateBusinessReference(declaration, WorkflowCanonicalEvaluationContext.From(context)); + } + + public static WorkflowBusinessReference EvaluateBusinessReference( + WorkflowBusinessReferenceDeclaration declaration, + WorkflowCanonicalEvaluationContext context) + { + ArgumentNullException.ThrowIfNull(declaration); + ArgumentNullException.ThrowIfNull(context); + + var parts = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var part in declaration.Parts) + { + parts[part.Name] = Evaluate(part.Expression, context); + } + + var key = Evaluate(declaration.KeyExpression ?? WorkflowExpr.Null(), context)?.ToString(); + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference + { + Key = key, + Parts = parts, + }) ?? new WorkflowBusinessReference(); + } + + private static Dictionary EvaluateObject( + WorkflowObjectExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in expression.Properties) + { + result[property.Name] = Evaluate(property.Expression, context); + } + + return result; + } + + private static object?[] EvaluateArray( + WorkflowArrayExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + return [.. expression.Items.Select(x => Evaluate(x, context))]; + } + + private static object? EvaluateFunction( + WorkflowFunctionExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + var builtInResult = expression.FunctionName switch + { + "coalesce" => EvaluateCoalesce(expression, context), + "concat" => EvaluateConcat(expression, context), + "add" => EvaluateAdd(expression, context), + "first" => EvaluateFirst(expression, context), + "if" => EvaluateIf(expression, context), + "isNullOrWhiteSpace" => EvaluateIsNullOrWhiteSpace(expression, context), + "length" => EvaluateLength(expression, context), + "mergeObjects" => EvaluateMergeObjects(expression, context), + "upper" => EvaluateUpper(expression, context), + "selectManyPath" => EvaluateSelectManyPath(expression, context), + "findPath" => EvaluateFindPath(expression, context), + _ => WorkflowCanonicalFunctionSentinel.Value, + }; + + if (!ReferenceEquals(builtInResult, WorkflowCanonicalFunctionSentinel.Value)) + { + return builtInResult; + } + + var arguments = expression.Arguments.Select(argument => Evaluate(argument, context)).ToArray(); + if (context.FunctionRuntime?.TryEvaluate(expression.FunctionName, arguments, context, out var pluginResult) == true) + { + return pluginResult; + } + + throw new InvalidOperationException( + $"Workflow function '{expression.FunctionName}' is not supported by the canonical runtime."); + } + + private static object EvaluateUnary( + WorkflowUnaryExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + return expression.Operator switch + { + "not" => !ToBoolean(Evaluate(expression.Operand, context)), + _ => throw new InvalidOperationException( + $"Workflow unary operator '{expression.Operator}' is not supported by the canonical runtime."), + }; + } + + private static object EvaluateBinary( + WorkflowBinaryExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + return expression.Operator switch + { + "eq" => AreEqual(Evaluate(expression.Left, context), Evaluate(expression.Right, context)), + "ne" => !AreEqual(Evaluate(expression.Left, context), Evaluate(expression.Right, context)), + "and" => ToBoolean(Evaluate(expression.Left, context)) && ToBoolean(Evaluate(expression.Right, context)), + "or" => ToBoolean(Evaluate(expression.Left, context)) || ToBoolean(Evaluate(expression.Right, context)), + "gt" => CompareNumbers(Evaluate(expression.Left, context), Evaluate(expression.Right, context)) > 0, + "gte" => CompareNumbers(Evaluate(expression.Left, context), Evaluate(expression.Right, context)) >= 0, + "lt" => CompareNumbers(Evaluate(expression.Left, context), Evaluate(expression.Right, context)) < 0, + "lte" => CompareNumbers(Evaluate(expression.Left, context), Evaluate(expression.Right, context)) <= 0, + _ => throw new InvalidOperationException( + $"Workflow binary operator '{expression.Operator}' is not supported by the canonical runtime."), + }; + } + + private static object? EvaluateCoalesce( + WorkflowFunctionExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + foreach (var argument in expression.Arguments) + { + var value = Evaluate(argument, context); + if (!IsMissing(value)) + { + return value; + } + } + + return null; + } + + private static object EvaluateConcat( + WorkflowFunctionExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + if (expression.Arguments.Count == 0) + { + throw new InvalidOperationException("Workflow function 'concat' requires at least one argument."); + } + + return string.Concat(expression.Arguments.Select(argument => Evaluate(argument, context)?.ToString() ?? string.Empty)); + } + + private static object EvaluateAdd( + WorkflowFunctionExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + if (expression.Arguments.Count < 2) + { + throw new InvalidOperationException("Workflow function 'add' requires at least two arguments."); + } + + var total = 0m; + var hasFractionalPart = false; + foreach (var argument in expression.Arguments) + { + var value = Evaluate(argument, context); + if (!TryConvertToDecimal(value, out var numericValue)) + { + throw new InvalidOperationException("Workflow function 'add' requires numeric arguments."); + } + + total += numericValue; + hasFractionalPart |= decimal.Truncate(numericValue) != numericValue; + } + + if (!hasFractionalPart + && decimal.Truncate(total) == total + && total >= long.MinValue + && total <= long.MaxValue) + { + return decimal.ToInt64(total); + } + + return total; + } + + private static object? EvaluateFirst( + WorkflowFunctionExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + if (expression.Arguments.Count != 1) + { + throw new InvalidOperationException("Workflow function 'first' requires one argument."); + } + + var value = Evaluate(expression.Arguments.First(), context); + return value switch + { + null => null, + JsonElement element when element.ValueKind == JsonValueKind.Array => + element.EnumerateArray().Select(ToRuntimeValue).FirstOrDefault(), + IEnumerable enumerable when value is not string => enumerable.FirstOrDefault(), + _ => value, + }; + } + + private static object? EvaluateIf( + WorkflowFunctionExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + if (expression.Arguments.Count < 3) + { + throw new InvalidOperationException("Workflow function 'if' requires three arguments."); + } + + var arguments = expression.Arguments.ToArray(); + return ToBoolean(Evaluate(arguments[0], context)) + ? Evaluate(arguments[1], context) + : Evaluate(arguments[2], context); + } + + private static object EvaluateIsNullOrWhiteSpace( + WorkflowFunctionExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + if (expression.Arguments.Count != 1) + { + throw new InvalidOperationException("Workflow function 'isNullOrWhiteSpace' requires one argument."); + } + + var value = Evaluate(expression.Arguments.First(), context)?.ToString(); + return string.IsNullOrWhiteSpace(value); + } + + private static object EvaluateLength( + WorkflowFunctionExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + if (expression.Arguments.Count != 1) + { + throw new InvalidOperationException("Workflow function 'length' requires one argument."); + } + + var value = Evaluate(expression.Arguments.First(), context); + return value switch + { + null => 0L, + string stringValue => stringValue.Length, + Array array => array.LongLength, + IReadOnlyCollection readOnlyCollection => readOnlyCollection.Count, + ICollection collection => collection.Count, + IReadOnlyCollection> readOnlyDictionary => readOnlyDictionary.Count, + ICollection> dictionary => dictionary.Count, + IEnumerable enumerable => enumerable.LongCount(), + IEnumerable> keyValueEnumerable => keyValueEnumerable.LongCount(), + _ => 0L, + }; + } + + private static object EvaluateMergeObjects( + WorkflowFunctionExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + if (expression.Arguments.Count == 0) + { + throw new InvalidOperationException("Workflow function 'mergeObjects' requires at least one argument."); + } + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var argument in expression.Arguments) + { + var value = Evaluate(argument, context); + if (IsMissing(value)) + { + continue; + } + + foreach (var entry in value.AsWorkflowObjectDictionary()) + { + result[entry.Key] = entry.Value; + } + } + + return result; + } + + private static object? EvaluateUpper( + WorkflowFunctionExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + if (expression.Arguments.Count != 1) + { + throw new InvalidOperationException("Workflow function 'upper' requires one argument."); + } + + var value = Evaluate(expression.Arguments.First(), context)?.ToString(); + return value?.ToUpperInvariant(); + } + + private static object? EvaluateSelectManyPath( + WorkflowFunctionExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + if (expression.Arguments.Count != 2) + { + throw new InvalidOperationException("Workflow function 'selectManyPath' requires two arguments."); + } + + var sourceValues = Evaluate(expression.Arguments.ElementAt(0), context); + var relativePath = Evaluate(expression.Arguments.ElementAt(1), context)?.ToString(); + if (string.IsNullOrWhiteSpace(relativePath)) + { + throw new InvalidOperationException("Workflow function 'selectManyPath' requires a relative path string."); + } + + var results = new List(); + foreach (var item in EnumerateRuntimeArray(sourceValues)) + { + var nestedValue = ResolveRelativePath(item, relativePath); + if (nestedValue is null) + { + continue; + } + + if (nestedValue is object?[] nestedArray) + { + results.AddRange(nestedArray); + continue; + } + + if (nestedValue is IEnumerable enumerable && nestedValue is not string) + { + results.AddRange(enumerable); + continue; + } + + results.Add(nestedValue); + } + + return results.ToArray(); + } + + private static object? EvaluateFindPath( + WorkflowFunctionExpressionDefinition expression, + WorkflowCanonicalEvaluationContext context) + { + if (expression.Arguments.Count != 2) + { + throw new InvalidOperationException("Workflow function 'findPath' requires two arguments."); + } + + var sourceValues = Evaluate(expression.Arguments.ElementAt(0), context); + var relativePath = Evaluate(expression.Arguments.ElementAt(1), context)?.ToString(); + if (string.IsNullOrWhiteSpace(relativePath)) + { + throw new InvalidOperationException("Workflow function 'findPath' requires a relative path string."); + } + + foreach (var item in EnumerateRuntimeArray(sourceValues)) + { + var nestedValue = ResolveRelativePath(item, relativePath); + if (!IsMissing(nestedValue)) + { + return nestedValue; + } + } + + return null; + } + + private static object? ResolvePath( + string path, + WorkflowCanonicalEvaluationContext context) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length == 0) + { + return null; + } + + var rootElement = segments[0] switch + { + "start" => context.Start, + "state" => context.State, + "payload" => context.Payload, + "result" => context.Result, + "businessReference" => context.BusinessReference is null ? default(JsonElement?) : context.BusinessReference.AsJsonElement(), + _ => default(JsonElement?), + }; + + if (rootElement is null) + { + return null; + } + + var current = rootElement.Value; + for (var index = 1; index < segments.Length; index++) + { + if (!TryResolveSegment(current, segments[index], out current)) + { + return null; + } + } + + return ToRuntimeValue(current); + } + + private static bool TryResolveSegment(JsonElement current, string segment, out JsonElement next) + { + if (current.ValueKind == JsonValueKind.Object + && current.TryGetPropertyIgnoreCase(segment, out next)) + { + return true; + } + + if (current.ValueKind == JsonValueKind.Array + && int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out var index)) + { + var length = current.GetArrayLength(); + if (index >= 0 && index < length) + { + next = current[index].Clone(); + return true; + } + } + + next = default; + return false; + } + + private static object? ToRuntimeValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Undefined or JsonValueKind.Null => null, + JsonValueKind.String => element.GetString(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number when element.TryGetInt64(out var int64Value) => int64Value, + JsonValueKind.Number when element.TryGetDecimal(out var decimalValue) => decimalValue, + JsonValueKind.Number when element.TryGetDouble(out var doubleValue) => doubleValue, + JsonValueKind.Object => element.EnumerateObject().ToDictionary( + x => x.Name, + x => ToRuntimeValue(x.Value), + StringComparer.OrdinalIgnoreCase), + JsonValueKind.Array => element.EnumerateArray().Select(ToRuntimeValue).ToArray(), + _ => element.ToString(), + }; + } + + private static object ParseNumber(string value) + { + if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var int64Value)) + { + return int64Value; + } + + if (decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var decimalValue)) + { + return decimalValue; + } + + if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue)) + { + return doubleValue; + } + + throw new InvalidOperationException($"Workflow numeric value '{value}' is not valid."); + } + + private static bool AreEqual(object? left, object? right) + { + if (left is null || right is null) + { + return left is null && right is null; + } + + if (TryConvertToDecimal(left, out var leftDecimal) + && TryConvertToDecimal(right, out var rightDecimal)) + { + return leftDecimal == rightDecimal; + } + + return string.Equals(left.ToString(), right.ToString(), StringComparison.OrdinalIgnoreCase); + } + + private static bool TryConvertToDecimal(object? value, out decimal result) + { + switch (value) + { + case null: + result = default; + return false; + case decimal decimalValue: + result = decimalValue; + return true; + case byte or short or int or long: + result = Convert.ToDecimal(value, CultureInfo.InvariantCulture); + return true; + case sbyte or ushort or uint or ulong: + result = Convert.ToDecimal(value, CultureInfo.InvariantCulture); + return true; + case float or double: + result = Convert.ToDecimal(value, CultureInfo.InvariantCulture); + return true; + case string stringValue when decimal.TryParse( + stringValue, + NumberStyles.Any, + CultureInfo.InvariantCulture, + out var parsedValue): + result = parsedValue; + return true; + default: + result = default; + return false; + } + } + + private static bool ToBoolean(object? value) + { + return value switch + { + null => false, + bool booleanValue => booleanValue, + string stringValue when bool.TryParse(stringValue, out var parsedBoolean) => parsedBoolean, + string stringValue when decimal.TryParse( + stringValue, + NumberStyles.Any, + CultureInfo.InvariantCulture, + out var numericBoolean) => numericBoolean != 0, + byte or short or int or long => Convert.ToInt64(value, CultureInfo.InvariantCulture) != 0, + sbyte or ushort or uint or ulong => Convert.ToUInt64(value, CultureInfo.InvariantCulture) != 0, + float or double or decimal => Convert.ToDecimal(value, CultureInfo.InvariantCulture) != 0, + _ => true, + }; + } + + private static bool IsMissing(object? value) + { + return value switch + { + null => true, + JsonElement element when element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined => true, + _ => false, + }; + } + + private static int CompareNumbers(object? left, object? right) + { + if (!TryConvertToDecimal(left, out var leftDecimal) + || !TryConvertToDecimal(right, out var rightDecimal)) + { + throw new InvalidOperationException("Workflow numeric comparison requires numeric operands."); + } + + return leftDecimal.CompareTo(rightDecimal); + } + + private static IEnumerable EnumerateRuntimeArray(object? value) + { + return value switch + { + null => [], + JsonElement element when element.ValueKind == JsonValueKind.Array => + element.EnumerateArray().Select(ToRuntimeValue), + object?[] array => array, + IEnumerable enumerable when value is not string => enumerable, + _ => [], + }; + } + + private static object? ResolveRelativePath(object? source, string relativePath) + { + if (source is null || string.IsNullOrWhiteSpace(relativePath)) + { + return null; + } + + var current = source.AsJsonElement(); + var segments = relativePath.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var segment in segments) + { + if (!TryResolveSegment(current, segment, out current)) + { + return null; + } + } + + return ToRuntimeValue(current); + } + + private static class WorkflowCanonicalFunctionSentinel + { + public static readonly object Value = new(); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalImportValidator.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalImportValidator.cs new file mode 100644 index 000000000..dfb5ac607 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalImportValidator.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public sealed record WorkflowCanonicalImportValidationResult +{ + public WorkflowCanonicalDefinition? Definition { get; init; } + public IReadOnlyCollection SchemaErrors { get; init; } = []; + public IReadOnlyCollection SemanticErrors { get; init; } = []; + public IReadOnlyCollection ModuleErrors { get; init; } = []; + public bool Succeeded => SchemaErrors.Count == 0 && SemanticErrors.Count == 0 && ModuleErrors.Count == 0 && Definition is not null; +} + +public static class WorkflowCanonicalImportValidator +{ + public static WorkflowCanonicalImportValidationResult Validate( + string json, + IReadOnlyCollection? installedModules = null, + IWorkflowFunctionCatalog? functionCatalog = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(json); + + try + { + using var _ = JsonDocument.Parse(json); + } + catch (JsonException exception) + { + return new WorkflowCanonicalImportValidationResult + { + SchemaErrors = + [ + new WorkflowCanonicalValidationError + { + Code = "WFSCHEMA000", + Path = "$", + Message = $"Workflow canonical JSON is not valid JSON: {exception.Message}", + }, + ], + }; + } + + WorkflowCanonicalDefinition definition; + try + { + definition = WorkflowCanonicalJsonSerializer.Deserialize(json); + } + catch (JsonException exception) + { + return new WorkflowCanonicalImportValidationResult + { + SchemaErrors = + [ + new WorkflowCanonicalValidationError + { + Code = "WFSCHEMA002", + Path = "$", + Message = $"Workflow canonical JSON could not be deserialized: {exception.Message}", + }, + ], + }; + } + + var semanticErrors = WorkflowCanonicalDefinitionValidator.Validate(definition, functionCatalog).Errors; + var moduleErrors = installedModules is null + ? Array.Empty() + : ValidateInstalledModules(definition.RequiredModules, installedModules); + + return new WorkflowCanonicalImportValidationResult + { + Definition = definition, + SchemaErrors = [], + SemanticErrors = semanticErrors, + ModuleErrors = moduleErrors, + }; + } + + private static WorkflowCanonicalValidationError[] ValidateInstalledModules( + IReadOnlyCollection requiredModules, + IReadOnlyCollection installedModules) + { + var errors = new List(); + + for (var index = 0; index < requiredModules.Count; index++) + { + var requiredModule = requiredModules.ElementAt(index); + var modulePath = $"$.requiredModules[{index}]"; + + var matches = installedModules + .Where(module => string.Equals(module.ModuleName, requiredModule.ModuleName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (matches.Length == 0) + { + if (!requiredModule.Optional) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFIMP010", + Path = modulePath, + Message = $"Required workflow module '{requiredModule.ModuleName}' is not installed.", + }); + } + + continue; + } + + if (!WorkflowModuleVersionExpression.TryParse(requiredModule.VersionExpression, out var requirement, out var parseError)) + { + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFIMP011", + Path = $"{modulePath}.versionExpression", + Message = parseError ?? $"Required workflow module version expression '{requiredModule.VersionExpression}' is invalid.", + }); + continue; + } + + if (matches.Any(match => requirement.IsSatisfiedBy(match.Version))) + { + continue; + } + + errors.Add(new WorkflowCanonicalValidationError + { + Code = "WFIMP012", + Path = modulePath, + Message = $"Installed versions for workflow module '{requiredModule.ModuleName}' do not satisfy '{requiredModule.VersionExpression}'.", + }); + } + + return [.. errors]; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalJsonSchema.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalJsonSchema.cs new file mode 100644 index 000000000..b8e188cdd --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalJsonSchema.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using StellaOps.Workflow.Contracts; + +using NJsonSchema; +using NJsonSchema.Generation; + +namespace StellaOps.Workflow.Abstractions; + +public static class WorkflowCanonicalJsonSchema +{ + private static readonly Lazy Schema = new(CreateSchema); + + public static JsonSchema DefinitionSchema => Schema.Value; + + public static string GetSchemaJson() + { + return Schema.Value.ToJson(); + } + + public static IReadOnlyCollection ValidateJson(string json) + { + ArgumentException.ThrowIfNullOrWhiteSpace(json); + + try + { + return [.. Schema.Value + .Validate(json) + .Select(error => new WorkflowCanonicalValidationError + { + Code = "WFSCHEMA001", + Path = string.IsNullOrWhiteSpace(error.Path) ? "$" : error.Path!, + Message = error.ToString(), + })]; + } + catch (Exception exception) + { + return + [ + new WorkflowCanonicalValidationError + { + Code = "WFSCHEMA000", + Path = "$", + Message = $"Workflow canonical JSON schema validation failed: {exception.Message}", + }, + ]; + } + } + + private static JsonSchema CreateSchema() + { + var schemaSettings = new SystemTextJsonSchemaGeneratorSettings + { + GenerateAbstractProperties = true, + AllowReferencesWithProperties = true, + SerializerOptions = WorkflowCanonicalJsonSerializer.Options, + }; + + return JsonSchema.FromType(schemaSettings); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalTemplateLoader.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalTemplateLoader.cs new file mode 100644 index 000000000..acf6fac2c --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalTemplateLoader.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public sealed class WorkflowCanonicalTemplateBindings +{ + private readonly Dictionary values = new(StringComparer.Ordinal); + + public WorkflowCanonicalTemplateBindings AddString(string token, string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + values[token] = WorkflowCanonicalJsonSerializer.SerializeFragment(value); + return this; + } + + public WorkflowCanonicalTemplateBindings AddJson(string token, TValue value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + values[token] = WorkflowCanonicalJsonSerializer.SerializeFragment(value); + return this; + } + + public IReadOnlyDictionary Build() + { + return values; + } +} + +public static class WorkflowCanonicalTemplateLoader +{ + public static WorkflowCanonicalDefinition LoadEmbeddedDefinition( + Assembly assembly, + string resourceName, + IReadOnlyDictionary bindings) + { + ArgumentNullException.ThrowIfNull(assembly); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); + ArgumentNullException.ThrowIfNull(bindings); + + var template = LoadEmbeddedText(assembly, resourceName); + var rendered = Render(template, bindings); + return WorkflowCanonicalJsonSerializer.Deserialize(rendered); + } + + public static TValue LoadEmbeddedFragment( + Assembly assembly, + string resourceName, + IReadOnlyDictionary bindings) + { + ArgumentNullException.ThrowIfNull(assembly); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); + ArgumentNullException.ThrowIfNull(bindings); + + var template = LoadEmbeddedText(assembly, resourceName); + var rendered = Render(template, bindings); + return WorkflowCanonicalJsonSerializer.DeserializeFragment(rendered); + } + + public static string LoadEmbeddedText(Assembly assembly, string resourceName) + { + ArgumentNullException.ThrowIfNull(assembly); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); + + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream is null) + { + throw new InvalidOperationException($"Embedded workflow template resource '{resourceName}' was not found in assembly '{assembly.FullName}'."); + } + + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + public static string Render(string template, IReadOnlyDictionary bindings) + { + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(bindings); + + var rendered = template; + foreach (var binding in bindings) + { + rendered = rendered.Replace($"{{{{{binding.Key}}}}}", binding.Value, StringComparison.Ordinal); + } + + return rendered; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowContentHasher.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowContentHasher.cs new file mode 100644 index 000000000..a23e3144c --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowContentHasher.cs @@ -0,0 +1,23 @@ +using System.Security.Cryptography; +using System.Text; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// Computes a deterministic content hash for canonical workflow definitions. +/// Used to detect whether a definition has changed across imports. +/// +public static class WorkflowContentHasher +{ + /// + /// Computes a SHA-256 hash of the canonical definition JSON. + /// The JSON is normalized to UTF-8 bytes before hashing. + /// Returns a lowercase hex string (64 characters). + /// + public static string ComputeHash(string canonicalDefinitionJson) + { + var bytes = Encoding.UTF8.GetBytes(canonicalDefinitionJson); + var hash = SHA256.HashData(bytes); + return Convert.ToHexStringLower(hash); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCoreFunctionProvider.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCoreFunctionProvider.cs new file mode 100644 index 000000000..aa62a693c --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCoreFunctionProvider.cs @@ -0,0 +1,290 @@ +using System.Collections.Generic; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public sealed class WorkflowCoreFunctionProvider : IWorkflowFunctionProvider +{ + private const string CoreModuleName = "workflow.functions.core"; + private const string CoreModuleVersion = "1.0.0"; + + public IReadOnlyCollection GetFunctions() + { + return + [ + Create( + "coalesce", + "Returns the first argument that is not null or missing.", + new WorkflowFunctionReturnDescriptor + { + Type = "any", + Description = "The first non-null argument value.", + }, + new WorkflowFunctionExample + { + Expression = "coalesce(state.customerId, payload.customerId, start.customerId)", + Description = "Falls back across state, payload, and start values.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "values", + Type = "any", + Required = true, + Variadic = true, + Description = "One or more candidate values evaluated from left to right.", + }), + Create( + "concat", + "Concatenates the textual representation of all provided arguments.", + new WorkflowFunctionReturnDescriptor + { + Type = "string", + Description = "A single string built from all argument values.", + }, + new WorkflowFunctionExample + { + Expression = "concat(\"Treaty #\", start.treatyNo)", + Description = "Builds a task description from a literal prefix and a workflow value.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "values", + Type = "any", + Required = true, + Variadic = true, + Description = "One or more values converted to text and concatenated from left to right.", + }), + Create( + "add", + "Adds the numeric representation of all provided arguments.", + new WorkflowFunctionReturnDescriptor + { + Type = "number", + Description = "The arithmetic sum of the provided numeric arguments.", + }, + new WorkflowFunctionExample + { + Expression = "add(coalesce(state.agentPollAttempt, 0), 1)", + Description = "Increments a workflow counter without dropping into imperative CLR code.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "values", + Type = "number", + Required = true, + Variadic = true, + Description = "Two or more numeric values summed from left to right.", + }), + Create( + "first", + "Returns the first item from an array-like value. Non-array inputs are returned unchanged.", + new WorkflowFunctionReturnDescriptor + { + Type = "any", + Description = "The first array element, or the original value when it is not an array.", + }, + new WorkflowFunctionExample + { + Expression = "first(state.objectData)", + Description = "Uses the first object-data entry when state stores an array of object payloads.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "value", + Type = "any", + Required = true, + Description = "The array-like or scalar value to inspect.", + }), + Create( + "if", + "Evaluates a boolean condition and returns either the second or third argument.", + new WorkflowFunctionReturnDescriptor + { + Type = "any", + Description = "The value selected by the condition.", + }, + new WorkflowFunctionExample + { + Expression = "if(state.shouldLookupCustomer, payload.customerId, null)", + Description = "Returns a customer identifier only when lookup is required.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "condition", + Type = "boolean", + Required = true, + Description = "The condition to evaluate.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "whenTrue", + Type = "any", + Required = true, + Description = "Returned when the condition is true.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "whenFalse", + Type = "any", + Required = true, + Description = "Returned when the condition is false.", + }), + Create( + "isNullOrWhiteSpace", + "Returns true when the argument is null, missing, or only whitespace.", + new WorkflowFunctionReturnDescriptor + { + Type = "boolean", + Description = "True when the input is null or whitespace.", + }, + new WorkflowFunctionExample + { + Expression = "isNullOrWhiteSpace(state.integrationCustomerId)", + Description = "Checks whether an integration customer identifier is missing.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "value", + Type = "any", + Required = true, + Description = "The value to inspect.", + }), + Create( + "length", + "Returns the length of a string, array, or object-like collection. Missing values return zero.", + new WorkflowFunctionReturnDescriptor + { + Type = "number", + Description = "The computed length of the provided value.", + }, + new WorkflowFunctionExample + { + Expression = "length(result.documentsStatus)", + Description = "Counts generated documents in a batch-print response.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "value", + Type = "any", + Required = true, + Description = "The string, array, or collection to measure.", + }), + Create( + "mergeObjects", + "Merges one or more object-like values into a single object. Later arguments override earlier keys.", + new WorkflowFunctionReturnDescriptor + { + Type = "object", + Description = "A merged object containing all contributed properties.", + }, + new WorkflowFunctionExample + { + Expression = "mergeObjects(first(state.objectData), { polObjectId: state.polObjectId })", + Description = "Adds or overrides fields on a workflow object payload without imperative code.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "objects", + Type = "object", + Required = true, + Variadic = true, + Description = "One or more object-like values merged from left to right.", + }), + Create( + "upper", + "Returns the upper-case invariant representation of a string value.", + new WorkflowFunctionReturnDescriptor + { + Type = "string", + Description = "The upper-case transformed string, or null when the input is missing.", + }, + new WorkflowFunctionExample + { + Expression = "upper(result.productInfo.lob)", + Description = "Normalizes a returned line-of-business code.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "value", + Type = "any", + Required = true, + Description = "The value to normalize to upper-case text.", + }), + Create( + "selectManyPath", + "Projects every item from an array through a relative path and flattens the results.", + new WorkflowFunctionReturnDescriptor + { + Type = "array", + Description = "A flattened array of projected values.", + }, + new WorkflowFunctionExample + { + Expression = "selectManyPath(result.objects, \"objectValues\")", + Description = "Flattens nested object values from a result payload.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "source", + Type = "array", + Required = true, + Description = "The array to project.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "relativePath", + Type = "string", + Required = true, + Description = "The relative path to resolve for each item.", + }), + Create( + "findPath", + "Returns the first non-missing value found when projecting array items through a relative path.", + new WorkflowFunctionReturnDescriptor + { + Type = "any", + Description = "The first non-missing projected value, or null.", + }, + new WorkflowFunctionExample + { + Expression = "findPath(result.customers, \"srCustId\")", + Description = "Extracts the first customer identifier from a lookup result.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "source", + Type = "array", + Required = true, + Description = "The array to inspect.", + }, + new WorkflowFunctionArgumentDescriptor + { + Name = "relativePath", + Type = "string", + Required = true, + Description = "The relative path to resolve for each item.", + }), + ]; + } + + private static WorkflowFunctionDescriptor Create( + string functionName, + string summary, + WorkflowFunctionReturnDescriptor returnDescriptor, + WorkflowFunctionExample example, + params WorkflowFunctionArgumentDescriptor[] arguments) + { + return new WorkflowFunctionDescriptor + { + FunctionName = functionName, + ModuleName = CoreModuleName, + ModuleVersion = CoreModuleVersion, + Summary = summary, + Deterministic = true, + Arguments = arguments, + Return = returnDescriptor, + Examples = [example], + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDeclarativeAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDeclarativeAbstractions.cs new file mode 100644 index 000000000..7d373c17f --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDeclarativeAbstractions.cs @@ -0,0 +1,2449 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public interface IDeclarativeWorkflow : ISerdicaWorkflow; + +public interface IDeclarativeWorkflow : IDeclarativeWorkflow, ISerdicaWorkflow + where TStartRequest : class +{ + WorkflowSpec Spec { get; } +} + +public static class WorkflowSpec +{ + public static WorkflowSpecBuilder For() + where TStartRequest : class + { + return new WorkflowSpecBuilder(); + } +} + +public sealed class WorkflowSpec + where TStartRequest : class +{ + public required Func> InitializeState { get; init; } + public required Func> InitializeStateWithRuntime { get; init; } + public WorkflowExpressionDefinition? InitializeStateExpression { get; init; } + public string? InitialTaskName { get; init; } + public WorkflowStepSequence InitialSequence { get; init; } = WorkflowStepSequence.Empty; + public required IReadOnlyDictionary> TasksByName { get; init; } + + public IReadOnlyCollection TaskDescriptors => + TasksByName.Values.Select(x => x.Descriptor).ToArray(); + + public WorkflowHumanTaskDefinition GetRequiredTask(string taskName) + { + if (!TasksByName.TryGetValue(taskName, out var task)) + { + throw new InvalidOperationException($"Workflow task '{taskName}' is not declared."); + } + + return task; + } +} + +public sealed class WorkflowSpecBuilder + where TStartRequest : class +{ + private readonly Dictionary> tasksByName = + new(StringComparer.OrdinalIgnoreCase); + private Func>? initializeState; + private Func>? initializeStateWithRuntime; + private WorkflowExpressionDefinition? initializeStateExpression; + private string? initialTaskName; + private WorkflowStepSequence? initialSequence; + + public WorkflowSpecBuilder InitializeState( + Func> initializer) + { + initializeState = initializer ?? throw new ArgumentNullException(nameof(initializer)); + initializeStateWithRuntime = (startRequest, _) => initializeState(startRequest); + initializeStateExpression = null; + return this; + } + + public WorkflowSpecBuilder InitializeState( + WorkflowExpressionDefinition initializerExpression) + { + ArgumentNullException.ThrowIfNull(initializerExpression); + initializeStateWithRuntime = (startRequest, functionRuntime) => + WorkflowCanonicalExpressionRuntime + .Evaluate( + initializerExpression, + WorkflowCanonicalEvaluationContext.ForStartRequest(startRequest, functionRuntime)) + .AsWorkflowJsonDictionary(); + initializeState = startRequest => initializeStateWithRuntime(startRequest, null); + initializeStateExpression = initializerExpression; + return this; + } + + public WorkflowSpecBuilder InitializeState( + Func initializer) + { + ArgumentNullException.ThrowIfNull(initializer); + return InitializeState(startRequest => initializer(startRequest).AsWorkflowJsonDictionary()); + } + + public WorkflowSpecBuilder StartWith(WorkflowHumanTaskDefinition task) + { + AddTask(task); + initialTaskName = task.TaskName; + initialSequence = null; + return this; + } + + public WorkflowSpecBuilder StartWith( + Action> configure) + { + ArgumentNullException.ThrowIfNull(configure); + var builder = new WorkflowFlowBuilder(); + configure(builder); + initialTaskName = null; + initialSequence = builder.Build(); + return this; + } + + public WorkflowSpecBuilder AddTask(WorkflowHumanTaskDefinition task) + { + ArgumentNullException.ThrowIfNull(task); + tasksByName[task.TaskName] = task; + return this; + } + + public WorkflowSpec Build() + { + if (initializeState is null) + { + throw new InvalidOperationException("Workflow state initializer is required."); + } + + if (initializeStateWithRuntime is null) + { + throw new InvalidOperationException("Workflow runtime-aware state initializer is required."); + } + + var hasInitialTask = string.IsNullOrWhiteSpace(initialTaskName) == false; + var hasInitialSequence = initialSequence?.Steps.Count > 0; + + if (!hasInitialTask && !hasInitialSequence) + { + throw new InvalidOperationException("Workflow start definition is required."); + } + + var spec = new WorkflowSpec + { + InitializeState = initializeState, + InitializeStateWithRuntime = initializeStateWithRuntime, + InitializeStateExpression = initializeStateExpression, + InitialTaskName = initialTaskName, + InitialSequence = initialSequence ?? WorkflowStepSequence.Empty, + TasksByName = new Dictionary>( + tasksByName, + StringComparer.OrdinalIgnoreCase), + }; + + WorkflowStepIdentityAssigner.Assign(spec); + return spec; + } +} + +public static class WorkflowHumanTask +{ + public static WorkflowHumanTaskBuilder For( + string taskName, + string taskType, + string route, + IEnumerable? taskRoles = null) + where TStartRequest : class + { + return new WorkflowHumanTaskBuilder(taskName, taskType, route) + .WithRoles(taskRoles); + } +} + +/// +/// Defines a human task (user task) within a workflow. +/// Human tasks pause workflow execution and wait for a user to complete them. +/// +public sealed class WorkflowHumanTaskDefinition + where TStartRequest : class +{ + public required string TaskName { get; init; } + public required WorkflowTaskDescriptor Descriptor { get; init; } + public required Func, string> ResolveRoute { get; init; } + public required Func, Dictionary> BuildPayload { get; init; } + public WorkflowExpressionDefinition? RouteExpression { get; init; } + public WorkflowExpressionDefinition? PayloadExpression { get; init; } + public required WorkflowStepSequence OnComplete { get; init; } + + /// + /// Optional timeout for the human task in seconds. + /// When null, no deadline is set — the task runs indefinitely until completed or purged by retention. + /// When set, the task's DeadlineUtc is computed as CreatedOnUtc + TimeoutSeconds. + /// + public int? TimeoutSeconds { get; init; } +} + +public sealed class WorkflowHumanTaskBuilder + where TStartRequest : class +{ + private readonly string route; + private readonly string taskName; + private readonly string taskType; + private Func, Dictionary>? payloadFactory; + private Func, string>? routeFactory; + private WorkflowExpressionDefinition routeExpression; + private WorkflowExpressionDefinition? payloadExpression; + private IReadOnlyCollection taskRoles = []; + private int? timeoutSeconds; + + public WorkflowHumanTaskBuilder(string taskName, string taskType, string route) + { + this.taskName = string.IsNullOrWhiteSpace(taskName) + ? throw new ArgumentException("Task name is required.", nameof(taskName)) + : taskName; + this.taskType = string.IsNullOrWhiteSpace(taskType) + ? throw new ArgumentException("Task type is required.", nameof(taskType)) + : taskType; + this.route = string.IsNullOrWhiteSpace(route) + ? throw new ArgumentException("Task route is required.", nameof(route)) + : route; + routeExpression = WorkflowExpr.String(this.route); + } + + public WorkflowHumanTaskBuilder WithRoles(IEnumerable? taskRoles) + { + this.taskRoles = taskRoles?.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray() + ?? []; + return this; + } + + public WorkflowHumanTaskBuilder WithRoles(params string[] taskRoles) + { + return WithRoles((IEnumerable?)taskRoles); + } + + /// + /// Sets an optional timeout for the human task. + /// When expired, the task's DeadlineUtc is exceeded and it becomes stale. + /// Default when not called: null (no deadline — task runs until completed or purged by retention). + /// + public WorkflowHumanTaskBuilder WithTimeout(int seconds) + { + timeoutSeconds = seconds; + return this; + } + + public WorkflowHumanTaskBuilder WithRoute( + Func, string> resolveRoute) + { + routeFactory = resolveRoute ?? throw new ArgumentNullException(nameof(resolveRoute)); + routeExpression = null!; + return this; + } + + public WorkflowHumanTaskBuilder WithRoute( + WorkflowExpressionDefinition resolveRouteExpression) + { + ArgumentNullException.ThrowIfNull(resolveRouteExpression); + routeFactory = context => + WorkflowCanonicalExpressionRuntime.Evaluate(resolveRouteExpression, context)?.ToString() + ?? string.Empty; + routeExpression = resolveRouteExpression; + return this; + } + + public WorkflowHumanTaskBuilder WithPayload( + Func, Dictionary> buildPayload) + { + payloadFactory = buildPayload ?? throw new ArgumentNullException(nameof(buildPayload)); + payloadExpression = null; + return this; + } + + public WorkflowHumanTaskBuilder WithPayload( + WorkflowExpressionDefinition buildPayloadExpression) + { + ArgumentNullException.ThrowIfNull(buildPayloadExpression); + payloadFactory = context => + WorkflowCanonicalExpressionRuntime.Evaluate(buildPayloadExpression, context).AsWorkflowJsonDictionary(); + payloadExpression = buildPayloadExpression; + return this; + } + + public WorkflowHumanTaskBuilder WithPayload( + Func, TPayload> buildPayload) + { + ArgumentNullException.ThrowIfNull(buildPayload); + payloadFactory = context => buildPayload(context).AsWorkflowJsonDictionary(); + payloadExpression = null; + return this; + } + + public WorkflowHumanTaskDefinition OnComplete( + Action> configure) + { + ArgumentNullException.ThrowIfNull(configure); + var builder = new WorkflowFlowBuilder(); + configure(builder); + return Build(builder.Build()); + } + + public WorkflowHumanTaskDefinition Build() + { + return Build(WorkflowStepSequence.Empty); + } + + private WorkflowHumanTaskDefinition Build(WorkflowStepSequence onComplete) + { + if (payloadFactory is null) + { + throw new InvalidOperationException($"Workflow task '{taskName}' requires a payload factory."); + } + + return new WorkflowHumanTaskDefinition + { + TaskName = taskName, + Descriptor = new WorkflowTaskDescriptor + { + TaskName = taskName, + TaskType = taskType, + Route = route, + TaskRoles = taskRoles.ToArray(), + }, + ResolveRoute = routeFactory ?? (_ => route), + BuildPayload = payloadFactory, + RouteExpression = routeExpression, + PayloadExpression = payloadExpression, + OnComplete = onComplete, + TimeoutSeconds = timeoutSeconds, + }; + } +} + +public sealed class WorkflowSpecExecutionContext + : IWorkflowSpecExecutionContext + where TStartRequest : class +{ + private const string MissingPayloadMessageKey = "WorkflowPayloadFieldMissing"; + private readonly Dictionary results = new(StringComparer.OrdinalIgnoreCase); + + public WorkflowSpecExecutionContext( + string workflowName, + TStartRequest? startRequest, + Dictionary workflowState, + IReadOnlyDictionary payload, + WorkflowBusinessReference? businessReference = null, + IWorkflowFunctionRuntime? functionRuntime = null) + { + WorkflowName = workflowName; + StartRequest = startRequest; + WorkflowState = workflowState ?? throw new ArgumentNullException(nameof(workflowState)); + Payload = payload ?? throw new ArgumentNullException(nameof(payload)); + BusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + FunctionRuntime = functionRuntime; + } + + public string WorkflowName { get; } + public TStartRequest? StartRequest { get; } + public Dictionary WorkflowState { get; } + public IReadOnlyDictionary Payload { get; } + public IReadOnlyDictionary ResultValues => results; + public WorkflowBusinessReference? BusinessReference { get; private set; } + public IWorkflowFunctionRuntime? FunctionRuntime { get; } + public WorkflowValueDictionary StateValues => WorkflowState.WorkflowDict(WorkflowName, MissingPayloadMessageKey); + public WorkflowValueDictionary PayloadValues => Payload.WorkflowDict(WorkflowName, MissingPayloadMessageKey); + + public TStartRequest GetRequiredStartRequest() + { + return StartRequest + ?? throw new InvalidOperationException( + $"Workflow '{WorkflowName}' start request is not available in the current execution context."); + } + + public TResponse GetRequiredResult(string resultKey) + { + if (!results.TryGetValue(resultKey, out var value)) + { + throw new InvalidOperationException( + $"Workflow '{WorkflowName}' does not have a stored result named '{resultKey}'."); + } + + return value.Get(); + } + + public TResponse? GetOptionalResult(string resultKey) + { + return results.TryGetValue(resultKey, out var value) + ? value.Get() + : default; + } + + public void SetResult(string resultKey, JsonElement value) + { + results[resultKey] = value.Clone(); + } + + public void SetBusinessReference(WorkflowBusinessReference? businessReference) + { + BusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + } + + public bool CompareValue( + WorkflowValueSource source, + string key, + object? expectedValue) + { + var values = source == WorkflowValueSource.Payload ? Payload : WorkflowState; + if (!values.TryGetValue(key, out var actualValue)) + { + return expectedValue is null; + } + + if (expectedValue is null) + { + return actualValue.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined; + } + + if (expectedValue is string expectedText) + { + return WorkflowJsonExtensions.TryGet(actualValue, out string? actualText) + && string.Equals(actualText, expectedText, StringComparison.OrdinalIgnoreCase); + } + + return WorkflowJsonExtensions.TryGet(actualValue, expectedValue.GetType(), out var convertedValue) + && Equals(convertedValue, expectedValue); + } +} + +public sealed class WorkflowStepSequence + where TStartRequest : class +{ + public static WorkflowStepSequence Empty { get; } = new([]); + + public WorkflowStepSequence(IReadOnlyCollection> steps) + { + Steps = steps; + } + + public IReadOnlyCollection> Steps { get; } +} + +public abstract record WorkflowStepDefinition(string? DisplayName) + where TStartRequest : class +{ + public string? StepId { get; set; } +} + +public sealed record WorkflowStateAssignmentStepDefinition( + string Key, + Func, object?> ValueFactory, + bool OnlyWhenHasValue) + : WorkflowStepDefinition((string?)null) + where TStartRequest : class +{ + public WorkflowExpressionDefinition? ValueExpression { get; init; } +} + +public sealed record WorkflowBusinessReferenceAssignmentStepDefinition( + Func, WorkflowBusinessReference?> BusinessReferenceFactory) + : WorkflowStepDefinition((string?)null) + where TStartRequest : class +{ + public WorkflowBusinessReferenceDeclaration? BusinessReferenceDeclaration { get; init; } +} + +public enum WorkflowValueSource +{ + Payload = 1, + State = 2, +} + +public sealed record WorkflowValueEqualsConditionDefinition( + WorkflowValueSource Source, + string Key, + object? ExpectedValue, + string DisplayName) +{ + public WorkflowExpressionDefinition CanonicalExpression => WorkflowExpr.Eq( + Source == WorkflowValueSource.Payload + ? WorkflowExpr.Path($"payload.{Key}") + : WorkflowExpr.Path($"state.{Key}"), + WorkflowExpr.FromValue(ExpectedValue)); +} + +public sealed record WorkflowDecisionStepDefinition( + WorkflowValueEqualsConditionDefinition Condition, + WorkflowStepSequence WhenTrue, + WorkflowStepSequence WhenFalse) + : WorkflowStepDefinition(Condition.DisplayName) + where TStartRequest : class; + +public sealed record WorkflowFailureHandlers( + WorkflowStepSequence WhenFailure, + WorkflowStepSequence WhenTimeout) + where TStartRequest : class +{ + public bool HasFailureBranch => WhenFailure.Steps.Count > 0; + public bool HasTimeoutBranch => WhenTimeout.Steps.Count > 0; + public bool HasAnyBranch => HasFailureBranch || HasTimeoutBranch; +} + +public enum WorkflowHandledBranchAction +{ + None = 0, + Complete = 1, +} + +/// +/// Defines a transport call to a Serdica microservice via RPC. +/// +public sealed record WorkflowMicroserviceCallStepDefinition( + string StepName, + string MicroserviceName, + string Command, + Func, object?> PayloadFactory, + string? ResultKey, + WorkflowFailureHandlers? FailureHandlers = null, + int? TimeoutSeconds = null) + : WorkflowStepDefinition(StepName) + where TStartRequest : class +{ + public WorkflowExpressionDefinition? PayloadExpression { get; init; } +} + +/// +/// Defines a transport call via the legacy RabbitMQ protocol (envelope or consumer mode). +/// +public sealed record WorkflowLegacyRabbitCallStepDefinition( + string StepName, + string Command, + WorkflowLegacyRabbitMode Mode, + Func, object?> PayloadFactory, + string? ResultKey, + WorkflowFailureHandlers? FailureHandlers = null, + int? TimeoutSeconds = null) + : WorkflowStepDefinition(StepName) + where TStartRequest : class +{ + public WorkflowExpressionDefinition? PayloadExpression { get; init; } +} + +/// +/// Defines a transport call to a GraphQL endpoint. +/// +public sealed record WorkflowGraphqlCallStepDefinition( + string StepName, + string Target, + string Query, + string? OperationName, + Func, IDictionary> VariablesFactory, + string? ResultKey, + WorkflowFailureHandlers? FailureHandlers = null, + int? TimeoutSeconds = null) + : WorkflowStepDefinition(StepName) + where TStartRequest : class +{ + public WorkflowExpressionDefinition? VariablesExpression { get; init; } +} + +/// +/// Defines a transport call to an HTTP endpoint. +/// +public sealed record WorkflowHttpCallStepDefinition( + string StepName, + string Target, + string Method, + string Path, + Func, object?> PayloadFactory, + string? ResultKey, + WorkflowFailureHandlers? FailureHandlers = null, + int? TimeoutSeconds = null) + : WorkflowStepDefinition(StepName) + where TStartRequest : class +{ + public WorkflowExpressionDefinition? PayloadExpression { get; init; } +} + +public sealed record WorkflowActivateTaskStepDefinition( + string TaskName, + Func, IReadOnlyCollection> RuntimeRolesFactory) + : WorkflowStepDefinition($"Activate {TaskName}") + where TStartRequest : class +{ + public WorkflowExpressionDefinition? RuntimeRolesExpression { get; init; } +} + +public sealed record WorkflowCompleteStepDefinition() + : WorkflowStepDefinition("Complete") + where TStartRequest : class; + +public sealed class WorkflowFlowBuilder + where TStartRequest : class +{ + private readonly List> steps = []; + + public WorkflowFlowBuilder Set( + string key, + object? value) + { + return Set(key, WorkflowExpr.FromValue(value)); + } + + public WorkflowFlowBuilder Set( + string key, + Func, object?> valueFactory) + { + steps.Add(new WorkflowStateAssignmentStepDefinition(key, valueFactory, false)); + return this; + } + + public WorkflowFlowBuilder Set( + string key, + WorkflowExpressionDefinition valueExpression) + { + ArgumentNullException.ThrowIfNull(valueExpression); + steps.Add(new WorkflowStateAssignmentStepDefinition( + key, + context => WorkflowCanonicalExpressionRuntime.Evaluate(valueExpression, context), + false) + { + ValueExpression = valueExpression, + }); + return this; + } + + public WorkflowFlowBuilder SetIfHasValue( + string key, + Func, object?> valueFactory) + { + steps.Add(new WorkflowStateAssignmentStepDefinition(key, valueFactory, true)); + return this; + } + + public WorkflowFlowBuilder SetIfHasValue( + string key, + WorkflowExpressionDefinition valueExpression) + { + ArgumentNullException.ThrowIfNull(valueExpression); + steps.Add(new WorkflowStateAssignmentStepDefinition( + key, + context => WorkflowCanonicalExpressionRuntime.Evaluate(valueExpression, context), + true) + { + ValueExpression = valueExpression, + }); + return this; + } + + public WorkflowFlowBuilder SetBusinessReference( + Func, WorkflowBusinessReference?> businessReferenceFactory) + { + ArgumentNullException.ThrowIfNull(businessReferenceFactory); + steps.Add(new WorkflowBusinessReferenceAssignmentStepDefinition(businessReferenceFactory)); + return this; + } + + public WorkflowFlowBuilder SetBusinessReference( + WorkflowBusinessReferenceDeclaration businessReferenceDeclaration) + { + ArgumentNullException.ThrowIfNull(businessReferenceDeclaration); + steps.Add(new WorkflowBusinessReferenceAssignmentStepDefinition( + context => WorkflowCanonicalExpressionRuntime.EvaluateBusinessReference(businessReferenceDeclaration, context)) + { + BusinessReferenceDeclaration = businessReferenceDeclaration, + }); + return this; + } + + public WorkflowFlowBuilder Call( + string stepName, + string microserviceName, + string command, + Func, object?> payloadFactory) + { + steps.Add(new WorkflowMicroserviceCallStepDefinition( + stepName, + microserviceName, + command, + payloadFactory, + null)); + return this; + } + + public WorkflowFlowBuilder Call( + string resultKey, + string stepName, + string microserviceName, + string command, + Func, object?> payloadFactory) + { + return AddMicroserviceCall(stepName, microserviceName, command, payloadFactory, null, resultKey, null, null); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address) + { + return AddMicroserviceCall( + stepName, + address.MicroserviceName, + address.Command, + _ => null, + WorkflowExpr.Null(), + null, + null, + null); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address, + Func, object?> payloadFactory) + { + return AddMicroserviceCall(stepName, address.MicroserviceName, address.Command, payloadFactory, null, null, null, null); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address, + WorkflowExpressionDefinition payloadExpression) + { + return AddMicroserviceCall( + stepName, + address.MicroserviceName, + address.Command, + context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context), + payloadExpression, + null, + null, + null); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address, + Func, object?> payloadFactory, + Action> whenFailure, + Action>? whenTimeout = null) + { + return AddMicroserviceCall( + stepName, + address.MicroserviceName, + address.Command, + payloadFactory, + null, + null, + whenFailure, + whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address, + Func, object?> payloadFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + return AddMicroserviceCall( + stepName, + address.MicroserviceName, + address.Command, + payloadFactory, + null, + null, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address, + string? resultKey = null) + { + return Call(stepName, address, _ => null, resultKey); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address, + Func, object?> payloadFactory, + string? resultKey = null) + { + return AddMicroserviceCall( + stepName, + address.MicroserviceName, + address.Command, + payloadFactory, + null, + resultKey ?? stepName, + null, + null); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address, + Func, object?> payloadFactory, + Action> whenFailure, + Action>? whenTimeout = null, + string? resultKey = null) + { + return AddMicroserviceCall( + stepName, + address.MicroserviceName, + address.Command, + payloadFactory, + null, + resultKey, + whenFailure, + whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address, + Func, object?> payloadFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None, + string? resultKey = null) + { + return AddMicroserviceCall( + stepName, + address.MicroserviceName, + address.Command, + payloadFactory, + null, + resultKey, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address, + Func, TRequest> payloadFactory) + { + return AddMicroserviceCall( + stepName, + address.MicroserviceName, + address.Command, + context => payloadFactory(context), + null, + null, + null, + null); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address, + Func, TRequest> payloadFactory, + Action> whenFailure, + Action>? whenTimeout = null) + { + return AddMicroserviceCall( + stepName, + address.MicroserviceName, + address.Command, + context => payloadFactory(context), + null, + null, + whenFailure, + whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address, + Func, TRequest> payloadFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + return AddMicroserviceCall( + stepName, + address.MicroserviceName, + address.Command, + context => payloadFactory(context), + null, + null, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address, + Func, TRequest> payloadFactory, + string? resultKey = null) + { + return AddMicroserviceCall( + stepName, + address.MicroserviceName, + address.Command, + context => payloadFactory(context), + null, + resultKey ?? stepName, + null, + null); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address, + Func, TRequest> payloadFactory, + Action> whenFailure, + Action>? whenTimeout = null, + string? resultKey = null) + { + return AddMicroserviceCall( + stepName, + address.MicroserviceName, + address.Command, + context => payloadFactory(context), + null, + resultKey, + whenFailure, + whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + Address address, + Func, TRequest> payloadFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None, + string? resultKey = null) + { + return AddMicroserviceCall( + stepName, + address.MicroserviceName, + address.Command, + context => payloadFactory(context), + null, + resultKey, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address) + { + return AddLegacyRabbitCall(stepName, address, _ => null, WorkflowExpr.Null(), null, null, null); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + Func, object?> payloadFactory) + { + return AddLegacyRabbitCall(stepName, address, payloadFactory, null, null, null, null); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + WorkflowExpressionDefinition payloadExpression) + { + return AddLegacyRabbitCall( + stepName, + address, + context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context), + payloadExpression, + null, + null, + null); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + WorkflowExpressionDefinition payloadExpression, + Action> whenFailure, + Action>? whenTimeout = null) + { + return AddLegacyRabbitCall( + stepName, + address, + context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context), + payloadExpression, + null, + whenFailure, + whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + WorkflowExpressionDefinition payloadExpression, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + return AddLegacyRabbitCall( + stepName, + address, + context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context), + payloadExpression, + null, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + Func, object?> payloadFactory, + Action> whenFailure, + Action>? whenTimeout = null) + { + return AddLegacyRabbitCall(stepName, address, payloadFactory, null, null, whenFailure, whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + Func, object?> payloadFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + return AddLegacyRabbitCall(stepName, address, payloadFactory, null, null, null, null, onFailure, onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + string? resultKey = null) + { + return Call(stepName, address, _ => null, resultKey); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + Func, object?> payloadFactory, + string? resultKey = null) + { + return AddLegacyRabbitCall(stepName, address, payloadFactory, null, resultKey ?? stepName, null, null); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + WorkflowExpressionDefinition payloadExpression, + string? resultKey = null) + { + ArgumentNullException.ThrowIfNull(payloadExpression); + return AddLegacyRabbitCall( + stepName, + address, + context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context), + payloadExpression, + resultKey ?? stepName, + null, + null); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + Func, object?> payloadFactory, + Action> whenFailure, + Action>? whenTimeout = null, + string? resultKey = null) + { + return AddLegacyRabbitCall(stepName, address, payloadFactory, null, resultKey, whenFailure, whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + WorkflowExpressionDefinition payloadExpression, + Action> whenFailure, + Action>? whenTimeout = null, + string? resultKey = null) + { + ArgumentNullException.ThrowIfNull(payloadExpression); + return AddLegacyRabbitCall( + stepName, + address, + context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context), + payloadExpression, + resultKey, + whenFailure, + whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + Func, object?> payloadFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None, + string? resultKey = null) + { + return AddLegacyRabbitCall(stepName, address, payloadFactory, null, resultKey, null, null, onFailure, onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + WorkflowExpressionDefinition payloadExpression, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None, + string? resultKey = null) + { + ArgumentNullException.ThrowIfNull(payloadExpression); + return AddLegacyRabbitCall( + stepName, + address, + context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context), + payloadExpression, + resultKey, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + Func, TRequest> payloadFactory) + { + return AddLegacyRabbitCall(stepName, address, context => payloadFactory(context), null, null, null, null); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + Func, TRequest> payloadFactory, + Action> whenFailure, + Action>? whenTimeout = null) + { + return AddLegacyRabbitCall(stepName, address, context => payloadFactory(context), null, null, whenFailure, whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + Func, TRequest> payloadFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + return AddLegacyRabbitCall( + stepName, + address, + context => payloadFactory(context), + null, + null, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + Func, TRequest> payloadFactory, + string? resultKey = null) + { + return AddLegacyRabbitCall(stepName, address, context => payloadFactory(context), null, resultKey ?? stepName, null, null); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + Func, TRequest> payloadFactory, + Action> whenFailure, + Action>? whenTimeout = null, + string? resultKey = null) + { + return AddLegacyRabbitCall(stepName, address, context => payloadFactory(context), null, resultKey, whenFailure, whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + LegacyRabbitAddress address, + Func, TRequest> payloadFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None, + string? resultKey = null) + { + return AddLegacyRabbitCall( + stepName, + address, + context => payloadFactory(context), + null, + resultKey, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address) + { + return AddHttpCall(stepName, address, _ => null, WorkflowExpr.Null(), null, null, null); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + Func, object?> payloadFactory) + { + return AddHttpCall(stepName, address, payloadFactory, null, null, null, null); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + WorkflowExpressionDefinition payloadExpression) + { + ArgumentNullException.ThrowIfNull(payloadExpression); + return AddHttpCall( + stepName, + address, + context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context), + payloadExpression, + null, + null, + null); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + Func, object?> payloadFactory, + Action> whenFailure, + Action>? whenTimeout = null) + { + return AddHttpCall(stepName, address, payloadFactory, null, null, whenFailure, whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + WorkflowExpressionDefinition payloadExpression, + Action> whenFailure, + Action>? whenTimeout = null) + { + ArgumentNullException.ThrowIfNull(payloadExpression); + return AddHttpCall( + stepName, + address, + context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context), + payloadExpression, + null, + whenFailure, + whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + Func, object?> payloadFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + return AddHttpCall(stepName, address, payloadFactory, null, null, null, null, onFailure, onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + WorkflowExpressionDefinition payloadExpression, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + ArgumentNullException.ThrowIfNull(payloadExpression); + return AddHttpCall( + stepName, + address, + context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context), + payloadExpression, + null, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + string? resultKey = null) + { + return AddHttpCall(stepName, address, _ => null, WorkflowExpr.Null(), resultKey ?? stepName, null, null); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + Func, object?> payloadFactory, + string? resultKey = null) + { + return AddHttpCall(stepName, address, payloadFactory, null, resultKey ?? stepName, null, null); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + WorkflowExpressionDefinition payloadExpression, + string? resultKey = null) + { + ArgumentNullException.ThrowIfNull(payloadExpression); + return AddHttpCall( + stepName, + address, + context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context), + payloadExpression, + resultKey ?? stepName, + null, + null); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + Func, object?> payloadFactory, + Action> whenFailure, + Action>? whenTimeout = null, + string? resultKey = null) + { + return AddHttpCall(stepName, address, payloadFactory, null, resultKey, whenFailure, whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + WorkflowExpressionDefinition payloadExpression, + Action> whenFailure, + Action>? whenTimeout = null, + string? resultKey = null) + { + ArgumentNullException.ThrowIfNull(payloadExpression); + return AddHttpCall( + stepName, + address, + context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context), + payloadExpression, + resultKey, + whenFailure, + whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + Func, object?> payloadFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None, + string? resultKey = null) + { + return AddHttpCall(stepName, address, payloadFactory, null, resultKey, null, null, onFailure, onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + WorkflowExpressionDefinition payloadExpression, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None, + string? resultKey = null) + { + ArgumentNullException.ThrowIfNull(payloadExpression); + return AddHttpCall( + stepName, + address, + context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context), + payloadExpression, + resultKey, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + Func, TRequest> payloadFactory) + { + return AddHttpCall(stepName, address, context => payloadFactory(context), null, null, null, null); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + Func, TRequest> payloadFactory, + Action> whenFailure, + Action>? whenTimeout = null) + { + return AddHttpCall(stepName, address, context => payloadFactory(context), null, null, whenFailure, whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + Func, TRequest> payloadFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + return AddHttpCall( + stepName, + address, + context => payloadFactory(context), + null, + null, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + Func, TRequest> payloadFactory, + string? resultKey = null) + { + return AddHttpCall(stepName, address, context => payloadFactory(context), null, resultKey ?? stepName, null, null); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + Func, TRequest> payloadFactory, + Action> whenFailure, + Action>? whenTimeout = null, + string? resultKey = null) + { + return AddHttpCall(stepName, address, context => payloadFactory(context), null, resultKey, whenFailure, whenTimeout); + } + + public WorkflowFlowBuilder Call( + string stepName, + HttpAddress address, + Func, TRequest> payloadFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None, + string? resultKey = null) + { + return AddHttpCall( + stepName, + address, + context => payloadFactory(context), + null, + resultKey, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + string target, + string query, + Func, IDictionary> variablesFactory, + string? operationName = null) + { + steps.Add(new WorkflowGraphqlCallStepDefinition( + stepName, + target, + query, + operationName, + variablesFactory, + null)); + return this; + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + GraphqlAddress address) + { + return AddGraphqlCall( + stepName, + address, + _ => new Dictionary(), + WorkflowExpr.Obj(), + null, + null, + null); + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + GraphqlAddress address, + Func, object?> variablesFactory) + { + return AddGraphqlCall( + stepName, + address, + context => variablesFactory(context).AsWorkflowObjectDictionary(), + null, + null, + null, + null); + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + GraphqlAddress address, + Func, object?> variablesFactory, + Action> whenFailure, + Action>? whenTimeout = null) + { + return AddGraphqlCall( + stepName, + address, + context => variablesFactory(context).AsWorkflowObjectDictionary(), + null, + null, + whenFailure, + whenTimeout); + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + GraphqlAddress address, + Func, object?> variablesFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + return AddGraphqlCall( + stepName, + address, + context => variablesFactory(context).AsWorkflowObjectDictionary(), + null, + null, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + GraphqlAddress address, + string? resultKey = null) + { + return AddGraphqlCall( + stepName, + address, + _ => new Dictionary(), + WorkflowExpr.Obj(), + resultKey ?? stepName, + null, + null); + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + GraphqlAddress address, + Func, object?> variablesFactory, + string? resultKey = null) + { + return AddGraphqlCall( + stepName, + address, + context => variablesFactory(context).AsWorkflowObjectDictionary(), + null, + resultKey ?? stepName, + null, + null); + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + GraphqlAddress address, + Func, object?> variablesFactory, + Action> whenFailure, + Action>? whenTimeout = null, + string? resultKey = null) + { + return AddGraphqlCall( + stepName, + address, + context => variablesFactory(context).AsWorkflowObjectDictionary(), + null, + resultKey, + whenFailure, + whenTimeout); + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + GraphqlAddress address, + Func, object?> variablesFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None, + string? resultKey = null) + { + return AddGraphqlCall( + stepName, + address, + context => variablesFactory(context).AsWorkflowObjectDictionary(), + null, + resultKey, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + GraphqlAddress address, + Func, TVariables> variablesFactory) + { + return AddGraphqlCall( + stepName, + address, + context => variablesFactory(context).AsWorkflowObjectDictionary(), + null, + null, + null, + null); + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + GraphqlAddress address, + Func, TVariables> variablesFactory, + Action> whenFailure, + Action>? whenTimeout = null) + { + return AddGraphqlCall( + stepName, + address, + context => variablesFactory(context).AsWorkflowObjectDictionary(), + null, + null, + whenFailure, + whenTimeout); + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + GraphqlAddress address, + Func, TVariables> variablesFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + return AddGraphqlCall( + stepName, + address, + context => variablesFactory(context).AsWorkflowObjectDictionary(), + null, + null, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + GraphqlAddress address, + Func, TVariables> variablesFactory, + string? resultKey = null) + { + return AddGraphqlCall( + stepName, + address, + context => variablesFactory(context).AsWorkflowObjectDictionary(), + null, + resultKey ?? stepName, + null, + null); + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + GraphqlAddress address, + Func, TVariables> variablesFactory, + Action> whenFailure, + Action>? whenTimeout = null, + string? resultKey = null) + { + return AddGraphqlCall( + stepName, + address, + context => variablesFactory(context).AsWorkflowObjectDictionary(), + null, + resultKey, + whenFailure, + whenTimeout); + } + + public WorkflowFlowBuilder QueryGraphql( + string stepName, + GraphqlAddress address, + Func, TVariables> variablesFactory, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None, + string? resultKey = null) + { + return AddGraphqlCall( + stepName, + address, + context => variablesFactory(context).AsWorkflowObjectDictionary(), + null, + resultKey, + null, + null, + onFailure, + onTimeout); + } + + public WorkflowFlowBuilder QueryGraphql( + string resultKey, + string stepName, + string target, + string query, + Func, IDictionary> variablesFactory, + string? operationName = null) + { + steps.Add(new WorkflowGraphqlCallStepDefinition( + stepName, + target, + query, + operationName, + variablesFactory, + resultKey, + null)); + return this; + } + + public WorkflowFlowBuilder WhenPayloadEquals( + string key, + TValue expectedValue, + string decisionName, + Action> whenTrue, + Action>? whenElse = null) + { + return WhenCondition( + new WorkflowValueConditionDefinition( + WorkflowValueSource.Payload, + key, + expectedValue, + decisionName), + whenTrue, + whenElse); + } + + public WorkflowFlowBuilder WhenStateEquals( + string key, + TValue expectedValue, + string decisionName, + Action> whenTrue, + Action>? whenElse = null) + { + return WhenCondition( + new WorkflowValueConditionDefinition( + WorkflowValueSource.State, + key, + expectedValue, + decisionName), + whenTrue, + whenElse); + } + + public WorkflowFlowBuilder WhenStateFlag( + string key, + bool expectedValue, + string decisionName, + Action> whenTrue, + Action>? whenElse = null) + { + return WhenCondition( + new WorkflowValueConditionDefinition( + WorkflowValueSource.State, + key, + expectedValue, + decisionName), + whenTrue, + whenElse); + } + + public WorkflowFlowBuilder WhenExpression( + string decisionName, + Func, bool> evaluator, + Action> whenTrue, + Action>? whenElse = null) + { + return WhenCondition( + new WorkflowExpressionConditionDefinition( + decisionName, + evaluator), + whenTrue, + whenElse); + } + + public WorkflowFlowBuilder WhenExpression( + string decisionName, + WorkflowExpressionDefinition conditionExpression, + Action> whenTrue, + Action>? whenElse = null) + { + ArgumentNullException.ThrowIfNull(conditionExpression); + return WhenCondition( + new WorkflowExpressionConditionDefinition( + decisionName, + context => WorkflowCanonicalExpressionRuntime.Evaluate(conditionExpression, context) switch + { + bool value => value, + null => false, + _ => throw new InvalidOperationException( + $"Workflow conditional expression '{decisionName}' must evaluate to a boolean value."), + }, + conditionExpression), + whenTrue, + whenElse); + } + + public WorkflowFlowBuilder ActivateTask( + string taskName, + Func, IReadOnlyCollection>? runtimeRolesFactory = null) + { + steps.Add(new WorkflowActivateTaskStepDefinition(taskName, runtimeRolesFactory ?? (_ => []))); + return this; + } + + public WorkflowFlowBuilder ActivateTask( + string taskName, + WorkflowExpressionDefinition runtimeRolesExpression) + { + ArgumentNullException.ThrowIfNull(runtimeRolesExpression); + steps.Add(new WorkflowActivateTaskStepDefinition( + taskName, + context => ConvertToStringCollection( + WorkflowCanonicalExpressionRuntime.Evaluate(runtimeRolesExpression, context))) + { + RuntimeRolesExpression = runtimeRolesExpression, + }); + return this; + } + + public WorkflowFlowBuilder ContinueWith( + string stepName, + WorkflowReference workflowReference) + { + var canonicalInvocation = workflowReference.TryBuildCanonicalInvocationDeclaration(); + if (canonicalInvocation is not null) + { + return ContinueWith(stepName, canonicalInvocation); + } + + return ContinueWith(stepName, workflowReference, _ => new Dictionary()); + } + + public WorkflowFlowBuilder ContinueWith( + string stepName, + WorkflowReference workflowReference, + Func, object?> payloadFactory, + Func, WorkflowBusinessReference?>? businessReferenceFactory = null) + { + steps.Add(new WorkflowContinueWithStepDefinition( + stepName, + context => workflowReference.BuildStartWorkflowRequest( + context, + payloadFactory(context), + businessReferenceFactory?.Invoke(context)))); + return this; + } + + public WorkflowFlowBuilder ContinueWith( + string stepName, + WorkflowWorkflowInvocationDeclaration invocationDeclaration) + { + ArgumentNullException.ThrowIfNull(invocationDeclaration); + steps.Add(new WorkflowContinueWithStepDefinition( + stepName, + context => BuildStartWorkflowRequest(invocationDeclaration, context)) + { + InvocationDeclaration = invocationDeclaration, + }); + return this; + } + + public WorkflowFlowBuilder ContinueWith( + string stepName, + WorkflowReference workflowReference, + WorkflowExpressionDefinition payloadExpression, + WorkflowBusinessReferenceDeclaration? businessReferenceDeclaration = null) + { + var invocationDeclaration = workflowReference.TryBuildCanonicalInvocationDeclaration(payloadExpression, businessReferenceDeclaration) + ?? throw new InvalidOperationException( + $"Workflow reference used by step '{stepName}' cannot be canonicalized because its target is dynamic."); + return ContinueWith(stepName, invocationDeclaration); + } + + public WorkflowFlowBuilder ContinueWith( + string stepName, + WorkflowReference workflowReference, + Func, TPayload> payloadFactory, + Func, WorkflowBusinessReference?>? businessReferenceFactory = null) + { + return ContinueWith(stepName, workflowReference, context => (object?)payloadFactory(context), businessReferenceFactory); + } + + public WorkflowFlowBuilder SubWorkflow( + string stepName, + WorkflowReference workflowReference, + string? resultKey = null) + { + var canonicalInvocation = workflowReference.TryBuildCanonicalInvocationDeclaration(); + if (canonicalInvocation is not null) + { + return SubWorkflow(stepName, canonicalInvocation, resultKey); + } + + return SubWorkflow(stepName, workflowReference, _ => new Dictionary(), null, resultKey); + } + + public WorkflowFlowBuilder SubWorkflow( + string stepName, + WorkflowReference workflowReference, + Func, object?> payloadFactory, + Func, WorkflowBusinessReference?>? businessReferenceFactory = null, + string? resultKey = null) + { + steps.Add(new WorkflowSubWorkflowStepDefinition( + stepName, + context => workflowReference.BuildStartWorkflowRequest( + context, + payloadFactory(context), + businessReferenceFactory?.Invoke(context)), + resultKey ?? stepName)); + return this; + } + + public WorkflowFlowBuilder SubWorkflow( + string stepName, + WorkflowWorkflowInvocationDeclaration invocationDeclaration, + string? resultKey = null) + { + ArgumentNullException.ThrowIfNull(invocationDeclaration); + steps.Add(new WorkflowSubWorkflowStepDefinition( + stepName, + context => BuildStartWorkflowRequest(invocationDeclaration, context), + resultKey ?? stepName) + { + InvocationDeclaration = invocationDeclaration, + }); + return this; + } + + public WorkflowFlowBuilder SubWorkflow( + string stepName, + WorkflowReference workflowReference, + WorkflowExpressionDefinition payloadExpression, + WorkflowBusinessReferenceDeclaration? businessReferenceDeclaration = null, + string? resultKey = null) + { + var invocationDeclaration = workflowReference.TryBuildCanonicalInvocationDeclaration(payloadExpression, businessReferenceDeclaration) + ?? throw new InvalidOperationException( + $"Workflow reference used by step '{stepName}' cannot be canonicalized because its target is dynamic."); + return SubWorkflow(stepName, invocationDeclaration, resultKey); + } + + public WorkflowFlowBuilder SubWorkflow( + string stepName, + WorkflowReference workflowReference, + Func, TPayload> payloadFactory, + Func, WorkflowBusinessReference?>? businessReferenceFactory = null, + string? resultKey = null) + { + return SubWorkflow( + stepName, + workflowReference, + context => (object?)payloadFactory(context), + businessReferenceFactory, + resultKey); + } + + public WorkflowFlowBuilder Run( + string stepName, + Func, WorkflowInlineStepServices, CancellationToken, Task> executeAsync) + { + ArgumentNullException.ThrowIfNull(executeAsync); + steps.Add(new WorkflowInlineStepDefinition(stepName, executeAsync, null)); + return this; + } + + public WorkflowFlowBuilder Run( + string stepName, + Func, WorkflowInlineStepServices, CancellationToken, Task> executeAsync, + Action> whenFailure, + Action>? whenTimeout = null) + { + ArgumentNullException.ThrowIfNull(executeAsync); + steps.Add(new WorkflowInlineStepDefinition( + stepName, + executeAsync, + CreateFailureHandlers(whenFailure, whenTimeout))); + return this; + } + + public WorkflowFlowBuilder Run( + string stepName, + Func, WorkflowInlineStepServices, CancellationToken, Task> executeAsync, + WorkflowHandledBranchAction onFailure, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + ArgumentNullException.ThrowIfNull(executeAsync); + steps.Add(new WorkflowInlineStepDefinition( + stepName, + executeAsync, + CreateFailureHandlers(null, null, onFailure, onTimeout))); + return this; + } + + public WorkflowFlowBuilder Repeat( + string stepName, + int maxIterations, + string? iterationStateKey, + WorkflowExpressionDefinition continueWhileExpression, + Action> configureBody) + { + return Repeat( + stepName, + WorkflowExpr.Number(maxIterations), + iterationStateKey, + continueWhileExpression, + configureBody); + } + + public WorkflowFlowBuilder Repeat( + string stepName, + WorkflowExpressionDefinition maxIterationsExpression, + string? iterationStateKey, + WorkflowExpressionDefinition continueWhileExpression, + Action> configureBody) + { + ArgumentNullException.ThrowIfNull(maxIterationsExpression); + ArgumentNullException.ThrowIfNull(continueWhileExpression); + ArgumentNullException.ThrowIfNull(configureBody); + + var bodyBuilder = new WorkflowFlowBuilder(); + configureBody(bodyBuilder); + steps.Add(new WorkflowRepeatStepDefinition( + stepName, + context => ConvertToInt32( + WorkflowCanonicalExpressionRuntime.Evaluate(maxIterationsExpression, context), + "Workflow repeat max-iterations expression"), + bodyBuilder.Build(), + context => WorkflowCanonicalExpressionRuntime.Evaluate(continueWhileExpression, context) switch + { + bool value => value, + null => false, + _ => throw new InvalidOperationException( + $"Workflow repeat expression for step '{stepName}' must evaluate to a boolean value."), + }) + { + MaxIterationsExpression = maxIterationsExpression, + IterationStateKey = iterationStateKey, + ContinueWhileExpression = continueWhileExpression, + }); + return this; + } + + public WorkflowFlowBuilder Wait( + string stepName, + Func, TimeSpan> delayFactory) + { + steps.Add(new WorkflowTimerStepDefinition(stepName, delayFactory)); + return this; + } + + public WorkflowFlowBuilder Wait( + string stepName, + WorkflowExpressionDefinition delayExpression) + { + ArgumentNullException.ThrowIfNull(delayExpression); + steps.Add(new WorkflowTimerStepDefinition( + stepName, + context => ConvertToTimeSpan( + WorkflowCanonicalExpressionRuntime.Evaluate(delayExpression, context))) + { + DelayExpression = delayExpression, + }); + return this; + } + + public WorkflowFlowBuilder WaitForSignal( + string stepName, + string signalName, + string? resultKey = null) + { + if (string.IsNullOrWhiteSpace(signalName)) + { + throw new ArgumentException("Signal name is required.", nameof(signalName)); + } + + return WaitForSignal(stepName, WorkflowExpr.String(signalName), resultKey); + } + + public WorkflowFlowBuilder WaitForSignal( + string stepName, + Func, string> signalNameFactory, + string? resultKey = null) + { + ArgumentNullException.ThrowIfNull(signalNameFactory); + steps.Add(new WorkflowExternalSignalStepDefinition(stepName, signalNameFactory, resultKey)); + return this; + } + + public WorkflowFlowBuilder WaitForSignal( + string stepName, + WorkflowExpressionDefinition signalNameExpression, + string? resultKey = null) + { + ArgumentNullException.ThrowIfNull(signalNameExpression); + steps.Add(new WorkflowExternalSignalStepDefinition( + stepName, + context => ConvertToRequiredString( + WorkflowCanonicalExpressionRuntime.Evaluate(signalNameExpression, context), + "Workflow external signal name expression"), + resultKey) + { + SignalNameExpression = signalNameExpression, + }); + return this; + } + + public WorkflowFlowBuilder Fork( + string stepName, + params Action>[] branches) + { + ArgumentNullException.ThrowIfNull(branches); + if (branches.Length == 0) + { + throw new InvalidOperationException("Workflow fork requires at least one branch."); + } + + var builtBranches = new WorkflowStepSequence[branches.Length]; + for (var i = 0; i < branches.Length; i++) + { + var branchBuilder = new WorkflowFlowBuilder(); + branches[i](branchBuilder); + builtBranches[i] = branchBuilder.Build(); + } + + steps.Add(new WorkflowForkStepDefinition(stepName, builtBranches)); + return this; + } + + public WorkflowFlowBuilder Complete() + { + steps.Add(new WorkflowCompleteStepDefinition()); + return this; + } + + internal WorkflowStepSequence Build() + { + return new WorkflowStepSequence(steps.ToArray()); + } + + private WorkflowFlowBuilder AddMicroserviceCall( + string stepName, + string microserviceName, + string command, + Func, object?> payloadFactory, + WorkflowExpressionDefinition? payloadExpression, + string? resultKey, + Action>? whenFailure, + Action>? whenTimeout, + WorkflowHandledBranchAction onFailure = WorkflowHandledBranchAction.None, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + steps.Add(new WorkflowMicroserviceCallStepDefinition( + stepName, + microserviceName, + command, + payloadFactory, + resultKey, + CreateFailureHandlers(whenFailure, whenTimeout, onFailure, onTimeout)) + { + PayloadExpression = payloadExpression, + }); + return this; + } + + private WorkflowFlowBuilder AddLegacyRabbitCall( + string stepName, + LegacyRabbitAddress address, + Func, object?> payloadFactory, + WorkflowExpressionDefinition? payloadExpression, + string? resultKey, + Action>? whenFailure, + Action>? whenTimeout, + WorkflowHandledBranchAction onFailure = WorkflowHandledBranchAction.None, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + steps.Add(new WorkflowLegacyRabbitCallStepDefinition( + stepName, + address.Command, + address.Mode, + payloadFactory, + resultKey, + CreateFailureHandlers(whenFailure, whenTimeout, onFailure, onTimeout)) + { + PayloadExpression = payloadExpression, + }); + return this; + } + + private WorkflowFlowBuilder AddHttpCall( + string stepName, + HttpAddress address, + Func, object?> payloadFactory, + WorkflowExpressionDefinition? payloadExpression, + string? resultKey, + Action>? whenFailure, + Action>? whenTimeout, + WorkflowHandledBranchAction onFailure = WorkflowHandledBranchAction.None, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + steps.Add(new WorkflowHttpCallStepDefinition( + stepName, + address.Target, + address.Method, + address.Path, + payloadFactory, + resultKey, + CreateFailureHandlers(whenFailure, whenTimeout, onFailure, onTimeout)) + { + PayloadExpression = payloadExpression, + }); + return this; + } + + private WorkflowFlowBuilder AddGraphqlCall( + string stepName, + GraphqlAddress address, + Func, IDictionary> variablesFactory, + WorkflowExpressionDefinition? variablesExpression, + string? resultKey, + Action>? whenFailure, + Action>? whenTimeout, + WorkflowHandledBranchAction onFailure = WorkflowHandledBranchAction.None, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + steps.Add(new WorkflowGraphqlCallStepDefinition( + stepName, + address.Target, + address.Query, + address.OperationName, + variablesFactory, + resultKey, + CreateFailureHandlers(whenFailure, whenTimeout, onFailure, onTimeout)) + { + VariablesExpression = variablesExpression, + }); + return this; + } + + private static WorkflowFailureHandlers? CreateFailureHandlers( + Action>? whenFailure, + Action>? whenTimeout, + WorkflowHandledBranchAction onFailure = WorkflowHandledBranchAction.None, + WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None) + { + if (whenFailure is null + && whenTimeout is null + && onFailure == WorkflowHandledBranchAction.None + && onTimeout == WorkflowHandledBranchAction.None) + { + return null; + } + + var failureBuilder = new WorkflowFlowBuilder(); + whenFailure?.Invoke(failureBuilder); + ApplyHandledBranchAction(failureBuilder, onFailure, whenFailure); + + var timeoutBuilder = new WorkflowFlowBuilder(); + whenTimeout?.Invoke(timeoutBuilder); + ApplyHandledBranchAction(timeoutBuilder, onTimeout, whenTimeout); + + return new WorkflowFailureHandlers(failureBuilder.Build(), timeoutBuilder.Build()); + } + + private static void ApplyHandledBranchAction( + WorkflowFlowBuilder builder, + WorkflowHandledBranchAction action, + Action>? configuredBranch) + { + if (configuredBranch is not null) + { + return; + } + + if (action == WorkflowHandledBranchAction.Complete) + { + builder.Complete(); + } + } + + private WorkflowFlowBuilder WhenEquals( + WorkflowValueSource source, + string key, + TValue expectedValue, + string decisionName, + Action> whenTrue, + Action> whenFalse) + { + ArgumentNullException.ThrowIfNull(whenTrue); + ArgumentNullException.ThrowIfNull(whenFalse); + + var trueBuilder = new WorkflowFlowBuilder(); + whenTrue(trueBuilder); + + var falseBuilder = new WorkflowFlowBuilder(); + whenFalse(falseBuilder); + + steps.Add(new WorkflowDecisionStepDefinition( + new WorkflowValueEqualsConditionDefinition( + source, + key, + expectedValue, + decisionName), + trueBuilder.Build(), + falseBuilder.Build())); + + return this; + } + + private WorkflowFlowBuilder WhenCondition( + WorkflowConditionDefinition condition, + Action> whenTrue, + Action>? whenElse) + { + ArgumentNullException.ThrowIfNull(whenTrue); + + var trueBuilder = new WorkflowFlowBuilder(); + whenTrue(trueBuilder); + + var elseBuilder = new WorkflowFlowBuilder(); + whenElse?.Invoke(elseBuilder); + + steps.Add(new WorkflowConditionalStepDefinition( + condition, + trueBuilder.Build(), + elseBuilder.Build())); + + return this; + } + + private static StartWorkflowRequest BuildStartWorkflowRequest( + WorkflowWorkflowInvocationDeclaration invocationDeclaration, + WorkflowSpecExecutionContext context) + { + var workflowName = WorkflowCanonicalExpressionRuntime.Evaluate( + invocationDeclaration.WorkflowNameExpression, + context)?.ToString(); + if (string.IsNullOrWhiteSpace(workflowName)) + { + throw new InvalidOperationException("Workflow invocation name expression must evaluate to a workflow name."); + } + + var workflowVersion = invocationDeclaration.WorkflowVersionExpression is null + ? null + : WorkflowCanonicalExpressionRuntime.Evaluate(invocationDeclaration.WorkflowVersionExpression, context)?.ToString(); + var payload = invocationDeclaration.PayloadExpression is null + ? null + : WorkflowCanonicalExpressionRuntime.Evaluate(invocationDeclaration.PayloadExpression, context); + var businessReference = invocationDeclaration.BusinessReference is null + ? null + : WorkflowCanonicalExpressionRuntime.EvaluateBusinessReference(invocationDeclaration.BusinessReference, context); + + return new StartWorkflowRequest + { + WorkflowName = workflowName, + WorkflowVersion = workflowVersion, + Payload = payload.AsWorkflowObjectDictionary(), + BusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference), + }; + } + + private static IReadOnlyCollection ConvertToStringCollection(object? value) + { + return value switch + { + null => [], + string stringValue when !string.IsNullOrWhiteSpace(stringValue) => [stringValue], + IEnumerable values => values + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(), + IEnumerable values => values + .Select(x => x?.ToString()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Cast() + .ToArray(), + _ => throw new InvalidOperationException("Workflow runtime roles expression must evaluate to a string or string collection."), + }; + } + + private static TimeSpan ConvertToTimeSpan(object? value) + { + return value switch + { + TimeSpan timeSpan => timeSpan, + long milliseconds => TimeSpan.FromMilliseconds(milliseconds), + int milliseconds => TimeSpan.FromMilliseconds(milliseconds), + decimal milliseconds => TimeSpan.FromMilliseconds((double)milliseconds), + double milliseconds => TimeSpan.FromMilliseconds(milliseconds), + string stringValue when TimeSpan.TryParse(stringValue, out var timeSpan) => timeSpan, + string stringValue when long.TryParse(stringValue, out var milliseconds) => TimeSpan.FromMilliseconds(milliseconds), + _ => throw new InvalidOperationException("Workflow timer expression must evaluate to a TimeSpan, numeric milliseconds, or time-span string."), + }; + } + + private static string ConvertToRequiredString(object? value, string contextMessage) + { + var text = value?.ToString(); + if (!string.IsNullOrWhiteSpace(text)) + { + return text; + } + + throw new InvalidOperationException($"{contextMessage} must evaluate to a non-empty string value."); + } + + private static int ConvertToInt32(object? value, string contextMessage) + { + return value switch + { + int intValue => intValue, + long longValue when longValue is >= int.MinValue and <= int.MaxValue => (int)longValue, + decimal decimalValue when decimalValue is >= int.MinValue and <= int.MaxValue => decimal.ToInt32(decimalValue), + double doubleValue when doubleValue is >= int.MinValue and <= int.MaxValue => Convert.ToInt32(doubleValue), + string stringValue when int.TryParse(stringValue, out var parsed) => parsed, + _ => throw new InvalidOperationException($"{contextMessage} must evaluate to an integer value."), + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDeclarativeAdvancedAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDeclarativeAdvancedAbstractions.cs new file mode 100644 index 000000000..2425ea7c6 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDeclarativeAdvancedAbstractions.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public sealed class WorkflowInlineStepServices( + IWorkflowMicroserviceTransport microserviceTransport, + IWorkflowLegacyRabbitTransport legacyRabbitTransport, + IWorkflowGraphqlTransport graphqlTransport, + IWorkflowHttpTransport httpTransport) +{ + public IWorkflowMicroserviceTransport MicroserviceTransport { get; } = microserviceTransport; + public IWorkflowLegacyRabbitTransport LegacyRabbitTransport { get; } = legacyRabbitTransport; + public IWorkflowGraphqlTransport GraphqlTransport { get; } = graphqlTransport; + public IWorkflowHttpTransport HttpTransport { get; } = httpTransport; + + public Task CallAsync( + Address address, + object? payload, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(address); + return MicroserviceTransport.ExecuteAsync(new WorkflowMicroserviceRequest + { + MicroserviceName = address.MicroserviceName, + Command = address.Command, + Payload = payload, + }, cancellationToken); + } + + public Task CallAsync( + LegacyRabbitAddress address, + object? payload, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(address); + return LegacyRabbitTransport.ExecuteAsync(new WorkflowLegacyRabbitRequest + { + Command = address.Command, + Mode = address.Mode, + Payload = payload, + }, cancellationToken); + } + + public Task QueryAsync( + GraphqlAddress address, + object? variables, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(address); + return GraphqlTransport.ExecuteAsync(new WorkflowGraphqlRequest + { + Target = address.Target, + Query = address.Query, + OperationName = address.OperationName, + Variables = variables.AsWorkflowObjectDictionary(), + }, cancellationToken); + } + + public Task CallAsync( + HttpAddress address, + object? payload, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(address); + return HttpTransport.ExecuteAsync(new WorkflowHttpRequest + { + Target = address.Target, + Method = address.Method, + Path = address.Path, + Payload = payload, + }, cancellationToken); + } +} + +public abstract class WorkflowConditionDefinition + where TStartRequest : class +{ + protected WorkflowConditionDefinition(string displayName) + { + DisplayName = displayName; + } + + public string DisplayName { get; } + + public virtual WorkflowExpressionDefinition? CanonicalExpression => null; + + public abstract bool Evaluate(WorkflowSpecExecutionContext context); +} + +public sealed class WorkflowValueConditionDefinition : WorkflowConditionDefinition + where TStartRequest : class +{ + public WorkflowValueConditionDefinition( + WorkflowValueSource source, + string key, + object? expectedValue, + string displayName) + : base(displayName) + { + Source = source; + Key = key; + ExpectedValue = expectedValue; + } + + public WorkflowValueSource Source { get; } + public string Key { get; } + public object? ExpectedValue { get; } + + public override WorkflowExpressionDefinition CanonicalExpression => WorkflowExpr.Eq( + Source == WorkflowValueSource.Payload + ? WorkflowExpr.Path($"payload.{Key}") + : WorkflowExpr.Path($"state.{Key}"), + WorkflowExpr.FromValue(ExpectedValue)); + + public override bool Evaluate(WorkflowSpecExecutionContext context) + { + return context.CompareValue(Source, Key, ExpectedValue); + } +} + +public sealed class WorkflowExpressionConditionDefinition : WorkflowConditionDefinition + where TStartRequest : class +{ + private readonly Func, bool> evaluator; + private readonly WorkflowExpressionDefinition? canonicalExpression; + + public WorkflowExpressionConditionDefinition( + string displayName, + Func, bool> evaluator, + WorkflowExpressionDefinition? canonicalExpression = null) + : base(displayName) + { + this.evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator)); + this.canonicalExpression = canonicalExpression; + } + + public override WorkflowExpressionDefinition? CanonicalExpression => canonicalExpression; + + public override bool Evaluate(WorkflowSpecExecutionContext context) + { + return evaluator(context); + } +} + +public sealed record WorkflowConditionalStepDefinition( + WorkflowConditionDefinition Condition, + WorkflowStepSequence WhenTrue, + WorkflowStepSequence WhenElse) + : WorkflowStepDefinition(Condition.DisplayName) + where TStartRequest : class; + +public sealed record WorkflowContinueWithStepDefinition( + string StepName, + Func, StartWorkflowRequest> StartWorkflowRequestFactory) + : WorkflowStepDefinition(StepName) + where TStartRequest : class +{ + public WorkflowWorkflowInvocationDeclaration? InvocationDeclaration { get; init; } +} + +public sealed record WorkflowSubWorkflowStepDefinition( + string StepName, + Func, StartWorkflowRequest> StartWorkflowRequestFactory, + string? ResultKey) + : WorkflowStepDefinition(StepName) + where TStartRequest : class +{ + public WorkflowWorkflowInvocationDeclaration? InvocationDeclaration { get; init; } +} + +public sealed record WorkflowInlineStepDefinition( + string StepName, + Func, WorkflowInlineStepServices, CancellationToken, Task> ExecuteAsync, + WorkflowFailureHandlers? FailureHandlers = null) + : WorkflowStepDefinition(StepName) + where TStartRequest : class; + +public sealed record WorkflowRepeatStepDefinition( + string StepName, + Func, int> MaxIterationsFactory, + WorkflowStepSequence Body, + Func, bool>? ContinueWhileEvaluator = null) + : WorkflowStepDefinition(StepName) + where TStartRequest : class +{ + public WorkflowExpressionDefinition? MaxIterationsExpression { get; init; } + public string? IterationStateKey { get; init; } + public WorkflowExpressionDefinition? ContinueWhileExpression { get; init; } +} + +public sealed record WorkflowTimerStepDefinition( + string StepName, + Func, TimeSpan> DelayFactory) + : WorkflowStepDefinition(StepName) + where TStartRequest : class +{ + public WorkflowExpressionDefinition? DelayExpression { get; init; } +} + +public sealed record WorkflowExternalSignalStepDefinition( + string StepName, + Func, string> SignalNameFactory, + string? ResultKey) + : WorkflowStepDefinition(StepName) + where TStartRequest : class +{ + public WorkflowExpressionDefinition? SignalNameExpression { get; init; } +} + +public sealed record WorkflowForkStepDefinition( + string StepName, + IReadOnlyCollection> Branches) + : WorkflowStepDefinition(StepName) + where TStartRequest : class; diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDefinitionAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDefinitionAbstractions.cs new file mode 100644 index 000000000..3a861b6d5 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDefinitionAbstractions.cs @@ -0,0 +1,9 @@ +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public interface IWorkflowDefinitionCatalog +{ + IReadOnlyCollection GetDefinitions(); + WorkflowDefinitionDescriptor? GetDefinition(string workflowName, string? workflowVersion = null); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDefinitionStoreAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDefinitionStoreAbstractions.cs new file mode 100644 index 000000000..ebdea19b7 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDefinitionStoreAbstractions.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// Persistent store for versioned canonical workflow definitions. +/// Implementations exist for MongoDB, Oracle, and Postgres via backend plugins. +/// +public interface IWorkflowDefinitionStore +{ + /// + /// Gets the active definition for a workflow name. + /// Returns null if no definition is stored for this workflow. + /// + Task GetActiveAsync(string workflowName, CancellationToken cancellationToken = default); + + /// + /// Gets a specific version of a workflow definition. + /// Version may include build metadata (e.g., "1.0.0+2"). + /// + Task GetAsync(string workflowName, string version, CancellationToken cancellationToken = default); + + /// + /// Gets all versions of a workflow definition, ordered by creation date descending. + /// + Task> GetVersionsAsync(string workflowName, CancellationToken cancellationToken = default); + + /// + /// Gets all active definitions across all workflow names. + /// + Task> GetAllActiveAsync(CancellationToken cancellationToken = default); + + /// + /// Inserts or updates a definition record. + /// + Task UpsertAsync(WorkflowDefinitionRecord record, CancellationToken cancellationToken = default); + + /// + /// Sets a specific version as active for a workflow name. + /// Deactivates all other versions of the same workflow. + /// + Task ActivateAsync(string workflowName, string version, CancellationToken cancellationToken = default); + + /// + /// Finds any definition record matching the given content hash, regardless of version. + /// Used to detect duplicate imports. + /// + Task FindByHashAsync(string workflowName, string contentHash, CancellationToken cancellationToken = default); +} + +/// +/// A versioned, persisted canonical workflow definition record. +/// +public sealed record WorkflowDefinitionRecord +{ + /// Workflow name (e.g., "ApproveApplication"). + public required string WorkflowName { get; init; } + + /// + /// Full version including build metadata (e.g., "1.0.0+2"). + /// Build metadata is appended when the same base version is imported with different content. + /// + public required string WorkflowVersion { get; init; } + + /// Base semantic version without build metadata (e.g., "1.0.0"). + public required string BaseVersion { get; init; } + + /// Build iteration counter. 0 for the first import of a base version. + public int BuildIteration { get; init; } + + /// SHA-256 hash of the canonical definition JSON (lowercase hex, 64 chars). + public required string ContentHash { get; init; } + + /// The full canonical definition as serialized JSON. + public required string CanonicalDefinitionJson { get; init; } + + /// Human-readable workflow name from the definition. + public string? DisplayName { get; init; } + + /// Whether this version is the active version for the workflow name. + public bool IsActive { get; init; } + + /// Optional SVG diagram rendering. + public byte[]? RenderingSvg { get; init; } + + /// Optional render graph JSON (for UI rendering). + public byte[]? RenderingJson { get; init; } + + /// Optional PNG screenshot. + public byte[]? RenderingPng { get; init; } + + /// When this record was created. + public DateTime CreatedOnUtc { get; init; } + + /// When this version was last activated (set as active). + public DateTime? ActivatedOnUtc { get; init; } + + /// User or system identifier that performed the import. + public string? ImportedBy { get; init; } + + /// + /// Resolved dependency versions snapshot. Populated at instance start time. + /// Maps referenced workflow names to their active versions at the time of resolution. + /// + public IReadOnlyDictionary? ResolvedDependencies { get; init; } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowExecutionAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowExecutionAbstractions.cs new file mode 100644 index 000000000..69a5ea6e3 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowExecutionAbstractions.cs @@ -0,0 +1,211 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public sealed record WorkflowExecutionTaskPlan +{ + public string? WorkflowName { get; init; } + public string? WorkflowVersion { get; init; } + public IReadOnlyCollection WorkflowRoles { get; init; } = []; + public required string TaskName { get; init; } + public required string TaskType { get; init; } + public required string Route { get; init; } + public IReadOnlyCollection TaskRoles { get; init; } = []; + public IReadOnlyCollection RuntimeRoles { get; init; } = []; + public IReadOnlyDictionary Payload { get; init; } = new Dictionary(); + + /// + /// Optional timeout for the task in seconds. Used to compute . + /// + public int? TimeoutSeconds { get; init; } +} + +public sealed record WorkflowContinuationPlan +{ + public required StartWorkflowRequest Request { get; init; } + public DateTime? DueAtUtc { get; init; } +} + +public sealed record WorkflowPendingSignalPlan +{ + public required string SignalType { get; init; } + public required string WaitingToken { get; init; } + public bool AutoDispatch { get; init; } = true; + public DateTime? DueAtUtc { get; init; } + public IReadOnlyDictionary Payload { get; init; } = new Dictionary(); + public IReadOnlyDictionary ResumeState { get; init; } = new Dictionary(); +} + +public sealed record WorkflowStartExecutionContext +{ + public required WorkflowRegistration Registration { get; init; } + public required WorkflowDefinitionDescriptor Definition { get; init; } + public WorkflowBusinessReference? BusinessReference { get; init; } + public object? StartRequest { get; init; } + public IReadOnlyDictionary Payload { get; init; } = new Dictionary(); + + public TStartRequest GetRequiredStartRequest() + where TStartRequest : class + { + return StartRequest as TStartRequest + ?? throw new InvalidOperationException( + $"Workflow start request is not available as '{typeof(TStartRequest).FullName}'."); + } +} + +public sealed record WorkflowTaskExecutionContext +{ + public required WorkflowRegistration Registration { get; init; } + public required WorkflowDefinitionDescriptor Definition { get; init; } + public required string WorkflowInstanceId { get; init; } + public string? RuntimeInstanceId { get; init; } + public string? RuntimeStateJson { get; init; } + public required WorkflowTaskSummary CurrentTask { get; init; } + public IReadOnlyDictionary WorkflowState { get; init; } = new Dictionary(); + public IReadOnlyDictionary Payload { get; init; } = new Dictionary(); +} + +public sealed record WorkflowSignalExecutionContext +{ + public required WorkflowRegistration Registration { get; init; } + public required WorkflowDefinitionDescriptor Definition { get; init; } + public required WorkflowRuntimeStateRecord RuntimeState { get; init; } + public required WorkflowSignalEnvelope Signal { get; init; } +} + +public sealed record WorkflowStartExecutionPlan +{ + public string InstanceStatus { get; init; } = "Open"; + public WorkflowBusinessReference? BusinessReference { get; init; } + public IReadOnlyDictionary WorkflowState { get; init; } = new Dictionary(); + public IReadOnlyCollection Tasks { get; init; } = []; + public IReadOnlyCollection PendingSignals { get; init; } = []; + public IReadOnlyCollection Continuations { get; init; } = []; +} + +public sealed record WorkflowTaskCompletionPlan +{ + public string InstanceStatus { get; init; } = "Completed"; + public WorkflowBusinessReference? BusinessReference { get; init; } + public IReadOnlyDictionary WorkflowState { get; init; } = new Dictionary(); + public IReadOnlyCollection NextTasks { get; init; } = []; + public IReadOnlyCollection PendingSignals { get; init; } = []; + public IReadOnlyCollection Continuations { get; init; } = []; +} + +public sealed record WorkflowRuntimeExecutionResult +{ + public bool Ignored { get; init; } + public required string RuntimeProvider { get; init; } + public string? RuntimeInstanceId { get; init; } + public string? RuntimeStatus { get; init; } + public required string InstanceStatus { get; init; } + public WorkflowBusinessReference? BusinessReference { get; init; } + public IReadOnlyDictionary WorkflowState { get; init; } = new Dictionary(); + public object? RuntimeState { get; init; } + public IReadOnlyCollection Tasks { get; init; } = []; + public IReadOnlyCollection PendingSignals { get; init; } = []; + public IReadOnlyCollection Continuations { get; init; } = []; +} + +public sealed record WorkflowRuntimeStateRecord +{ + public required string WorkflowInstanceId { get; init; } + public required string WorkflowName { get; init; } + public required string WorkflowVersion { get; init; } + public long Version { get; init; } + public WorkflowBusinessReference? BusinessReference { get; init; } + public string RuntimeProvider { get; init; } = WorkflowRuntimeProviderNames.Engine; + public required string RuntimeInstanceId { get; init; } + public string RuntimeStatus { get; init; } = "Open"; + public string StateJson { get; init; } = "{}"; + public DateTime CreatedOnUtc { get; init; } = DateTime.UtcNow; + public DateTime? CompletedOnUtc { get; init; } + public DateTime? StaleAfterUtc { get; init; } + public DateTime? PurgeAfterUtc { get; init; } + public DateTime LastUpdatedOnUtc { get; init; } = DateTime.UtcNow; +} + +public interface IWorkflowExecutionHandler +{ + Task StartAsync( + WorkflowStartExecutionContext context, + CancellationToken cancellationToken = default); + + Task CompleteTaskAsync( + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default); +} + +public interface IWorkflowSignalResumableExecutionHandler +{ + Task ResumeSignalAsync( + WorkflowSignalResumeContext context, + CancellationToken cancellationToken = default); +} + +public sealed record WorkflowSignalResumeContext +{ + public WorkflowBusinessReference? BusinessReference { get; init; } + public required WorkflowSignalEnvelope Signal { get; init; } + public required IReadOnlyDictionary WorkflowState { get; init; } + public IReadOnlyDictionary ResumeState { get; init; } = new Dictionary(); + public IReadOnlyCollection Continuations { get; init; } = []; +} + +public interface IWorkflowExecutionHandlerCatalog +{ + IWorkflowExecutionHandler? GetHandler(string workflowName, string workflowVersion); +} + +public interface IWorkflowRuntimeOrchestrator +{ + Task StartAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + StartWorkflowRequest request, + object startRequest, + CancellationToken cancellationToken = default); + + Task CompleteAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default); + + Task ResumeAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowSignalExecutionContext context, + CancellationToken cancellationToken = default); +} + +public interface IWorkflowRuntimeStateStore +{ + Task UpsertAsync( + WorkflowRuntimeStateRecord state, + CancellationToken cancellationToken = default); + + Task GetAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default); + + Task> GetManyAsync( + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken = default); + + Task MarkStaleAsync( + IReadOnlyCollection workflowInstanceIds, + DateTime updatedOnUtc, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowFunctionCatalog.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowFunctionCatalog.cs new file mode 100644 index 000000000..04c22f510 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowFunctionCatalog.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using StellaOps.Workflow.Contracts; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace StellaOps.Workflow.Abstractions; + +public interface IWorkflowFunctionProvider +{ + IReadOnlyCollection GetFunctions(); +} + +public interface IWorkflowFunctionCatalog +{ + IReadOnlyCollection GetFunctions(); + bool TryGetFunction(string functionName, out WorkflowFunctionDescriptor descriptor); +} + +public sealed class WorkflowFunctionCatalog : IWorkflowFunctionCatalog +{ + private readonly IReadOnlyCollection functions; + private readonly IReadOnlyDictionary functionsByName; + + public WorkflowFunctionCatalog(IEnumerable providers) + { + ArgumentNullException.ThrowIfNull(providers); + + var loadedFunctions = providers + .SelectMany(x => x.GetFunctions()) + .Where(x => x is not null) + .ToArray(); + + var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var function in loadedFunctions) + { + Register(dictionary, function.FunctionName, function); + + foreach (var alias in function.Aliases.Where(x => !string.IsNullOrWhiteSpace(x))) + { + Register(dictionary, alias, function); + } + } + + functions = loadedFunctions + .DistinctBy(x => x.FunctionName, StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x.ModuleName, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.FunctionName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + functionsByName = dictionary; + } + + public IReadOnlyCollection GetFunctions() + { + return functions; + } + + public bool TryGetFunction(string functionName, out WorkflowFunctionDescriptor descriptor) + { + return functionsByName.TryGetValue(functionName, out descriptor!); + } + + private static void Register( + IDictionary dictionary, + string key, + WorkflowFunctionDescriptor descriptor) + { + if (string.IsNullOrWhiteSpace(key)) + { + return; + } + + if (dictionary.TryGetValue(key, out var existing)) + { + throw new InvalidOperationException( + $"Workflow function '{key}' is registered more than once by modules '{existing.ModuleName}' and '{descriptor.ModuleName}'."); + } + + dictionary[key] = descriptor; + } +} + +public static class WorkflowFunctionCatalogServiceCollectionExtensions +{ + public static IServiceCollection AddWorkflowFunctionCatalog(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + public static IServiceCollection AddWorkflowFunctionProvider(this IServiceCollection services) + where TProvider : class, IWorkflowFunctionProvider + { + services.AddWorkflowFunctionCatalog(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + if (typeof(IWorkflowFunctionRuntimeProvider).IsAssignableFrom(typeof(TProvider))) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton( + typeof(IWorkflowFunctionRuntimeProvider), + typeof(TProvider))); + } + + return services; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowFunctionRuntime.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowFunctionRuntime.cs new file mode 100644 index 000000000..8a92ab1e8 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowFunctionRuntime.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace StellaOps.Workflow.Abstractions; + +public interface IWorkflowFunctionRuntimeProvider +{ + bool TryEvaluate( + string functionName, + IReadOnlyCollection arguments, + WorkflowCanonicalEvaluationContext context, + out object? result); +} + +public interface IWorkflowFunctionRuntime +{ + bool TryEvaluate( + string functionName, + IReadOnlyCollection arguments, + WorkflowCanonicalEvaluationContext context, + out object? result); +} + +internal sealed class WorkflowFunctionRuntime(IEnumerable providers) + : IWorkflowFunctionRuntime +{ + private readonly IWorkflowFunctionRuntimeProvider[] providers = providers.ToArray(); + + public bool TryEvaluate( + string functionName, + IReadOnlyCollection arguments, + WorkflowCanonicalEvaluationContext context, + out object? result) + { + ArgumentException.ThrowIfNullOrWhiteSpace(functionName); + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(context); + + foreach (var provider in providers) + { + if (provider.TryEvaluate(functionName, arguments, context, out result)) + { + return true; + } + } + + result = null; + return false; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowHostedJobLockAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowHostedJobLockAbstractions.cs new file mode 100644 index 000000000..08fafb137 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowHostedJobLockAbstractions.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Workflow.Abstractions; + +public interface IWorkflowHostedJobLockService +{ + Task TryAcquireAsync( + string lockName, + string lockOwner, + DateTime acquiredOnUtc, + TimeSpan lease, + CancellationToken cancellationToken = default); + + Task ReleaseAsync( + string lockName, + string lockOwner, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowJsonExtensions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowJsonExtensions.cs new file mode 100644 index 000000000..ce8a45b6d --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowJsonExtensions.cs @@ -0,0 +1,432 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; + + +namespace StellaOps.Workflow.Abstractions; + +public readonly record struct WorkflowValueDictionary( + IReadOnlyDictionary Values, + string WorkflowName, + string MissingMessageKey) +{ + public WorkflowValue this[string key] + { + get + { + if (!Values.TryGetValue(key, out var value)) + { + throw new WorkflowValueNotFoundException(MissingMessageKey, key, WorkflowName); + } + + return new WorkflowValue(key, WorkflowName, MissingMessageKey, value); + } + } +} + +public readonly record struct WorkflowValue( + string Key, + string WorkflowName, + string MissingMessageKey, + JsonElement Value) +{ + public T Get() + { + if (WorkflowJsonExtensions.TryGet(Value, out T? result)) + { + return result!; + } + + throw new WorkflowValueNotFoundException(MissingMessageKey, Key, WorkflowName); + } +} + +public static class WorkflowJsonExtensions +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + }; + + public static WorkflowValueDictionary WorkflowDict( + this IReadOnlyDictionary source, + string workflowName, + string missingMessageKey) + { + return new WorkflowValueDictionary(source, workflowName, missingMessageKey); + } + + public static Dictionary CloneJson(this IReadOnlyDictionary source) + { + var target = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in source) + { + target[item.Key] = item.Value.Clone(); + } + + return target; + } + + public static Dictionary AsWorkflowJsonDictionary(this object? value) + { + switch (value) + { + case null: + return new Dictionary(StringComparer.OrdinalIgnoreCase); + case IReadOnlyDictionary jsonDictionary: + return jsonDictionary.CloneJson(); + case IDictionary jsonDictionary: + return new Dictionary(jsonDictionary, StringComparer.OrdinalIgnoreCase) + .CloneJson(); + } + + var element = value.AsJsonElement(); + if (element.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("Workflow JSON dictionary source must serialize to an object."); + } + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in element.EnumerateObject()) + { + result[property.Name] = property.Value.Clone(); + } + + return result; + } + + public static Dictionary Assign( + this Dictionary source, + string key, + T value) + { + source[key] = value.AsJsonElement(); + return source; + } + + public static Dictionary AssignIfHasValue( + this Dictionary source, + string key, + string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + source[key] = value.AsJsonElement(); + } + + return source; + } + + public static T? GetOptional( + this IReadOnlyDictionary source, + string key) + { + if (!source.TryGetValue(key, out var value) + || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return default; + } + + if (TryGet(value, out T? result)) + { + return result; + } + + throw new InvalidOperationException($"Unable to convert workflow value '{key}' to '{typeof(T).FullName}'."); + } + + public static bool TryGetPropertyIgnoreCase( + this JsonElement element, + string propertyName, + out JsonElement value) + { + if (element.ValueKind != JsonValueKind.Object) + { + value = default; + return false; + } + + foreach (var property in element.EnumerateObject()) + { + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value.Clone(); + return true; + } + } + + value = default; + return false; + } + + public static T? GetOptionalProperty( + this JsonElement element, + string propertyName) + { + if (!element.TryGetPropertyIgnoreCase(propertyName, out var value) + || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return default; + } + + if (TryGet(value, out T? result)) + { + return result; + } + + throw new InvalidOperationException( + $"Unable to convert workflow property '{propertyName}' to '{typeof(T).FullName}'."); + } + + public static T GetRequiredProperty( + this JsonElement element, + string propertyName) + { + if (!element.TryGetPropertyIgnoreCase(propertyName, out var value) + || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + throw new InvalidOperationException($"Workflow property '{propertyName}' is required."); + } + + if (TryGet(value, out T? result)) + { + return result!; + } + + throw new InvalidOperationException( + $"Unable to convert workflow property '{propertyName}' to '{typeof(T).FullName}'."); + } + + public static JsonElement AsJsonElement(this T value) + { + return value is JsonElement jsonElement + ? jsonElement.Clone() + : JsonSerializer.SerializeToElement(value, SerializerOptions); + } + + public static Dictionary AsWorkflowObjectDictionary(this object? value) + { + return value switch + { + null => new Dictionary(StringComparer.OrdinalIgnoreCase), + Dictionary dictionary => new(dictionary, StringComparer.OrdinalIgnoreCase), + IDictionary dictionary => new(dictionary, StringComparer.OrdinalIgnoreCase), + IReadOnlyDictionary dictionary => dictionary.ToDictionary( + x => x.Key, + x => x.Value, + StringComparer.OrdinalIgnoreCase), + JsonElement element when element.ValueKind == JsonValueKind.Object => + JsonSerializer.Deserialize>(element.GetRawText(), SerializerOptions) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase), + _ => JsonSerializer.Deserialize>( + JsonSerializer.Serialize(value, SerializerOptions), + SerializerOptions) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase), + }; + } + + public static T Get(this JsonElement value) + { + if (TryGet(value, out T? result)) + { + return result!; + } + + throw new InvalidOperationException($"Unable to convert workflow value to '{typeof(T).FullName}'."); + } + + public static bool TryGet(JsonElement value, out T? result) + { + if (TryGet(value, typeof(T), out var parsedValue)) + { + result = (T?)parsedValue; + return true; + } + + result = default; + return false; + } + + public static bool TryGet( + JsonElement value, + Type targetType, + out object? result) + { + var resolvedTargetType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + result = null; + return true; + } + + return resolvedTargetType == typeof(JsonElement) + ? TryAssign(value.Clone(), out result) + : resolvedTargetType == typeof(string) + ? TryAssign(value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString(), out result) + : resolvedTargetType == typeof(bool) + ? TryParseBoolean(value, out result) + : resolvedTargetType == typeof(long) + ? TryParseInt64(value, out result) + : resolvedTargetType == typeof(int) + ? TryParseInt32(value, out result) + : resolvedTargetType == typeof(decimal) + ? TryParseDecimal(value, out result) + : resolvedTargetType == typeof(double) + ? TryParseDouble(value, out result) + : resolvedTargetType == typeof(Guid) + ? TryParseGuid(value, out result) + : resolvedTargetType.IsEnum + ? TryParseEnum(value, resolvedTargetType, out result) + : TryDeserialize(value, resolvedTargetType, out result); + } + + private static bool TryAssign(object? value, out object? result) + { + result = value; + return true; + } + + private static bool TryParseBoolean(JsonElement value, out object? result) + { + if (value.ValueKind == JsonValueKind.True || value.ValueKind == JsonValueKind.False) + { + result = value.GetBoolean(); + return true; + } + + if (value.ValueKind == JsonValueKind.String + && bool.TryParse(value.GetString(), out var boolValue)) + { + result = boolValue; + return true; + } + + result = null; + return false; + } + + private static bool TryParseInt64(JsonElement value, out object? result) + { + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var longValue)) + { + result = longValue; + return true; + } + + if (value.ValueKind == JsonValueKind.String + && long.TryParse(value.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedValue)) + { + result = parsedValue; + return true; + } + + result = null; + return false; + } + + private static bool TryParseInt32(JsonElement value, out object? result) + { + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var intValue)) + { + result = intValue; + return true; + } + + if (value.ValueKind == JsonValueKind.String + && int.TryParse(value.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedValue)) + { + result = parsedValue; + return true; + } + + result = null; + return false; + } + + private static bool TryParseDecimal(JsonElement value, out object? result) + { + if (value.ValueKind == JsonValueKind.Number && value.TryGetDecimal(out var decimalValue)) + { + result = decimalValue; + return true; + } + + if (value.ValueKind == JsonValueKind.String + && decimal.TryParse(value.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedValue)) + { + result = parsedValue; + return true; + } + + result = null; + return false; + } + + private static bool TryParseDouble(JsonElement value, out object? result) + { + if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var doubleValue)) + { + result = doubleValue; + return true; + } + + if (value.ValueKind == JsonValueKind.String + && double.TryParse(value.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedValue)) + { + result = parsedValue; + return true; + } + + result = null; + return false; + } + + private static bool TryParseGuid(JsonElement value, out object? result) + { + if (value.ValueKind == JsonValueKind.String + && Guid.TryParse(value.GetString(), out var guidValue)) + { + result = guidValue; + return true; + } + + result = null; + return false; + } + + private static bool TryParseEnum(JsonElement value, Type enumType, out object? result) + { + if (value.ValueKind == JsonValueKind.String + && Enum.TryParse(enumType, value.GetString(), true, out var enumValue)) + { + result = enumValue; + return true; + } + + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var rawValue)) + { + result = Enum.ToObject(enumType, rawValue); + return true; + } + + result = null; + return false; + } + + private static bool TryDeserialize(JsonElement value, Type targetType, out object? result) + { + try + { + result = JsonSerializer.Deserialize(value.GetRawText(), targetType, SerializerOptions); + return result is not null; + } + catch (JsonException) + { + result = null; + return false; + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowModuleCatalog.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowModuleCatalog.cs new file mode 100644 index 000000000..22c402cf9 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowModuleCatalog.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace StellaOps.Workflow.Abstractions; + +public interface IWorkflowModuleCatalog +{ + IReadOnlyCollection GetInstalledModules(); +} + +internal sealed class WorkflowModuleCatalog(IEnumerable installedModules) + : IWorkflowModuleCatalog +{ + public IReadOnlyCollection GetInstalledModules() + { + return installedModules + .Where(x => !string.IsNullOrWhiteSpace(x.ModuleName) && !string.IsNullOrWhiteSpace(x.Version)) + .GroupBy(x => $"{x.ModuleName}\u0000{x.Version}".ToUpperInvariant(), StringComparer.Ordinal) + .Select(x => x.First()) + .OrderBy(x => x.ModuleName, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Version, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } +} + +public static class WorkflowModuleCatalogServiceCollectionExtensions +{ + public static IServiceCollection AddWorkflowModuleCatalog(this IServiceCollection services) + { + services.TryAddSingleton(); + return services; + } + + public static IServiceCollection AddWorkflowModule( + this IServiceCollection services, + string moduleName, + string version) + { + ArgumentException.ThrowIfNullOrWhiteSpace(moduleName); + ArgumentException.ThrowIfNullOrWhiteSpace(version); + + services.AddWorkflowModuleCatalog(); + services.AddSingleton(new WorkflowInstalledModule(moduleName, version)); + return services; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowModuleVersionExpression.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowModuleVersionExpression.cs new file mode 100644 index 000000000..35c70578d --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowModuleVersionExpression.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace StellaOps.Workflow.Abstractions; + +public enum WorkflowModuleVersionOperator +{ + Equal, + GreaterThan, + GreaterThanOrEqual, + LessThan, + LessThanOrEqual, +} + +public sealed record WorkflowModuleVersionRequirementClause( + WorkflowModuleVersionOperator Operator, + Version Version); + +public sealed record WorkflowModuleVersionRequirement +{ + public required IReadOnlyCollection Clauses { get; init; } + + public bool IsSatisfiedBy(string installedVersion) + { + if (!WorkflowVersioning.TryParseSemanticVersion(installedVersion, out var parsedInstalledVersion)) + { + return false; + } + + return Clauses.All(clause => clause.Operator switch + { + WorkflowModuleVersionOperator.Equal => parsedInstalledVersion == clause.Version, + WorkflowModuleVersionOperator.GreaterThan => parsedInstalledVersion > clause.Version, + WorkflowModuleVersionOperator.GreaterThanOrEqual => parsedInstalledVersion >= clause.Version, + WorkflowModuleVersionOperator.LessThan => parsedInstalledVersion < clause.Version, + WorkflowModuleVersionOperator.LessThanOrEqual => parsedInstalledVersion <= clause.Version, + _ => false, + }); + } +} + +public static class WorkflowModuleVersionExpression +{ + private static readonly Regex ClauseRegex = new( + @"(?v)?\s*(?>=|<=|>|<|=)?\s*v?(?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + public static bool TryParse( + string? expression, + out WorkflowModuleVersionRequirement requirement, + out string? error) + { + if (string.IsNullOrWhiteSpace(expression)) + { + requirement = new WorkflowModuleVersionRequirement + { + Clauses = [], + }; + error = "Module version expression is required."; + return false; + } + + var matches = ClauseRegex.Matches(expression); + if (matches.Count == 0) + { + requirement = new WorkflowModuleVersionRequirement + { + Clauses = [], + }; + error = $"Module version expression '{expression}' is not valid."; + return false; + } + + var currentIndex = 0; + var clauses = new List(); + foreach (Match match in matches) + { + if (!HasOnlyDelimiters(expression.AsSpan(currentIndex, match.Index - currentIndex))) + { + requirement = new WorkflowModuleVersionRequirement + { + Clauses = [], + }; + error = $"Module version expression '{expression}' contains unsupported syntax."; + return false; + } + + var versionText = match.Groups["version"].Value; + if (!WorkflowVersioning.TryParseSemanticVersion(versionText, out var version)) + { + requirement = new WorkflowModuleVersionRequirement + { + Clauses = [], + }; + error = $"Module version expression '{expression}' contains invalid semantic version '{versionText}'."; + return false; + } + + clauses.Add(new WorkflowModuleVersionRequirementClause( + ParseOperator(match.Groups["operator"].Value), + version)); + + currentIndex = match.Index + match.Length; + } + + if (!HasOnlyDelimiters(expression.AsSpan(currentIndex))) + { + requirement = new WorkflowModuleVersionRequirement + { + Clauses = [], + }; + error = $"Module version expression '{expression}' contains unsupported syntax."; + return false; + } + + requirement = new WorkflowModuleVersionRequirement + { + Clauses = clauses, + }; + error = null; + return true; + } + + private static WorkflowModuleVersionOperator ParseOperator(string operatorText) + { + return operatorText switch + { + "" or "=" => WorkflowModuleVersionOperator.Equal, + ">" => WorkflowModuleVersionOperator.GreaterThan, + ">=" => WorkflowModuleVersionOperator.GreaterThanOrEqual, + "<" => WorkflowModuleVersionOperator.LessThan, + "<=" => WorkflowModuleVersionOperator.LessThanOrEqual, + _ => throw new InvalidOperationException($"Unsupported module version operator '{operatorText}'."), + }; + } + + private static bool HasOnlyDelimiters(ReadOnlySpan value) + { + foreach (var character in value) + { + if (!char.IsWhiteSpace(character) && character != ',') + { + return false; + } + } + + return true; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowModuleVersioning.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowModuleVersioning.cs new file mode 100644 index 000000000..f4d4129c6 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowModuleVersioning.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public sealed record WorkflowInstalledModule( + string ModuleName, + string Version); + +public sealed record WorkflowModuleRequirementValidationError( + string Code, + string Path, + string Message); + +public static class WorkflowModuleVersioning +{ + private static readonly Regex VersionClauseRegex = new( + @"^(?>=|<=|>|<|=)?\s*(?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*))$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public static IReadOnlyCollection ValidateRequirementSyntax( + WorkflowRequiredModuleDeclaration requirement, + string path) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(requirement.ModuleName)) + { + errors.Add(new WorkflowModuleRequirementValidationError( + "WFVAL070", + $"{path}.moduleName", + "Required module entry requires a module name.")); + } + + if (!TryParseVersionExpression(requirement.VersionExpression, out _)) + { + errors.Add(new WorkflowModuleRequirementValidationError( + "WFVAL071", + $"{path}.versionExpression", + $"Module version expression '{requirement.VersionExpression}' is not valid.")); + } + + return errors; + } + + public static bool Satisfies( + WorkflowInstalledModule installedModule, + WorkflowRequiredModuleDeclaration requirement) + { + if (!string.Equals(installedModule.ModuleName, requirement.ModuleName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return WorkflowVersioning.TryParseSemanticVersion(installedModule.Version, out var installedVersion) + && TryParseVersionExpression(requirement.VersionExpression, out var clauses) + && clauses.All(clause => clause.IsSatisfiedBy(installedVersion)); + } + + private static bool TryParseVersionExpression( + string? versionExpression, + out IReadOnlyCollection clauses) + { + var parts = (versionExpression ?? string.Empty) + .Split([',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (parts.Length == 0) + { + clauses = []; + return false; + } + + var result = new List(); + foreach (var part in parts) + { + var match = VersionClauseRegex.Match(part); + if (!match.Success + || !WorkflowVersioning.TryParseSemanticVersion(match.Groups["version"].Value, out var version)) + { + clauses = []; + return false; + } + + var @operator = match.Groups["op"].Success && !string.IsNullOrWhiteSpace(match.Groups["op"].Value) + ? match.Groups["op"].Value + : "="; + + result.Add(new WorkflowVersionClause(@operator, version)); + } + + clauses = result; + return true; + } + + private sealed record WorkflowVersionClause(string Operator, Version Version) + { + public bool IsSatisfiedBy(Version candidate) + { + var comparison = candidate.CompareTo(Version); + return Operator switch + { + "=" => comparison == 0, + ">" => comparison > 0, + ">=" => comparison >= 0, + "<" => comparison < 0, + "<=" => comparison <= 0, + _ => false, + }; + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowMutationAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowMutationAbstractions.cs new file mode 100644 index 000000000..68b80fa4f --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowMutationAbstractions.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Workflow.Abstractions; + +public interface IWorkflowMutationScope : IAsyncDisposable +{ + void RegisterPostCommitAction(Func action); + + Task CommitAsync(CancellationToken cancellationToken = default); +} + +public interface IWorkflowMutationCoordinator +{ + Task BeginAsync(CancellationToken cancellationToken = default); +} + +public interface IWorkflowMutationScopeAccessor +{ + IWorkflowMutationScope? Current { get; set; } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowProjectionStoreAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowProjectionStoreAbstractions.cs new file mode 100644 index 000000000..9a042c0da --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowProjectionStoreAbstractions.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public interface IWorkflowProjectionStore +{ + Task CreateWorkflowAsync( + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + WorkflowStartExecutionPlan executionPlan, + CancellationToken cancellationToken = default); + + Task> GetTasksAsync( + WorkflowTasksGetRequest request, + CancellationToken cancellationToken = default); + + Task GetTaskAsync( + string workflowTaskId, + CancellationToken cancellationToken = default); + + Task GetExecutionSnapshotAsync( + string workflowTaskId, + CancellationToken cancellationToken = default); + + Task AssignTaskAsync( + string workflowTaskId, + string actorId, + string assignee, + CancellationToken cancellationToken = default); + + Task AssignTaskRolesAsync( + string workflowTaskId, + string actorId, + IReadOnlyCollection targetRoles, + CancellationToken cancellationToken = default); + + Task ReleaseTaskAsync( + string workflowTaskId, + string actorId, + CancellationToken cancellationToken = default); + + Task ApplyTaskCompletionAsync( + string workflowTaskId, + string actorId, + IDictionary payload, + WorkflowTaskCompletionPlan completionPlan, + WorkflowBusinessReference? businessReference, + CancellationToken cancellationToken = default); + + Task ApplyRuntimeProgressAsync( + string workflowInstanceId, + WorkflowTaskCompletionPlan progressPlan, + WorkflowBusinessReference? businessReference, + CancellationToken cancellationToken = default); + + Task> GetInstancesAsync( + WorkflowInstancesGetRequest request, + CancellationToken cancellationToken = default); + + Task GetInstanceAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default); + + Task GetInstanceDetailsAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRegistrationAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRegistrationAbstractions.cs new file mode 100644 index 000000000..770d41846 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRegistrationAbstractions.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +using Microsoft.Extensions.DependencyInjection; + +namespace StellaOps.Workflow.Abstractions; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public sealed class WorkflowBusinessIdAttribute : Attribute; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public sealed class WorkflowBusinessReferencePartAttribute : Attribute +{ + public WorkflowBusinessReferencePartAttribute(string? partName = null) + { + PartName = partName; + } + + public string? PartName { get; } +} + +public interface ISerdicaWorkflow +{ + string WorkflowName { get; } + string WorkflowVersion { get; } + string DisplayName { get; } + IReadOnlyCollection WorkflowRoles { get; } + IReadOnlyCollection Tasks { get; } +} + +public interface ISerdicaWorkflow : ISerdicaWorkflow + where TStartRequest : class; + +public sealed record WorkflowRegistration +{ + public required Type WorkflowType { get; init; } + public required Type StartRequestType { get; init; } + public Type? HandlerType { get; init; } + public required WorkflowDefinitionDescriptor Definition { get; init; } + public string? BusinessReferenceKeyPropertyName { get; init; } + public required Func, object> BindStartRequest { get; init; } + public required Func ExtractBusinessReference { get; init; } +} + +public interface IWorkflowRegistrationCatalog +{ + IReadOnlyCollection GetRegistrations(); + WorkflowRegistration? GetRegistration(string workflowName, string? workflowVersion = null); +} + +public sealed class NoopWorkflowExecutionHandler : IWorkflowExecutionHandler +{ + public Task StartAsync( + WorkflowStartExecutionContext context, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This handler should never be resolved."); + } + + public Task CompleteTaskAsync( + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This handler should never be resolved."); + } +} + +public static class WorkflowRegistrationServiceCollectionExtensions +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + }; + + public static IServiceCollection AddWorkflowRegistration( + this IServiceCollection services) + where TWorkflow : class, ISerdicaWorkflow, new() + { + return services.AddWorkflowRegistration(); + } + + public static IServiceCollection AddWorkflowRegistration( + this IServiceCollection services) + where TWorkflow : class, ISerdicaWorkflow, new() + where TRegistrationArgument : class + { + var registrationArgumentType = typeof(TRegistrationArgument); + return typeof(IWorkflowExecutionHandler).IsAssignableFrom(registrationArgumentType) + ? services.AddWorkflowRegistration(typeof(TWorkflow), ResolveStartRequestType(typeof(TWorkflow)), registrationArgumentType) + : services.AddWorkflowRegistration( + typeof(TWorkflow), + EnsureWorkflowSupportsStartRequest(typeof(TWorkflow), registrationArgumentType), + typeof(NoopWorkflowExecutionHandler)); + } + + public static IServiceCollection AddWorkflowRegistration( + this IServiceCollection services) + where TWorkflow : class, ISerdicaWorkflow, new() + where TStartRequest : class + where THandler : class, IWorkflowExecutionHandler + { + return services.AddWorkflowRegistration(typeof(TWorkflow), typeof(TStartRequest), typeof(THandler)); + } + + private static IServiceCollection AddWorkflowRegistration( + this IServiceCollection services, + Type workflowType, + Type? startRequestType, + Type handlerType) + { + if (Activator.CreateInstance(workflowType) is not ISerdicaWorkflow workflow) + { + throw new InvalidOperationException( + $"Unable to create workflow '{workflowType.FullName}'."); + } + + var businessReferenceKeyProperty = startRequestType is null + ? null + : ResolveBusinessReferenceKeyProperty(startRequestType); + var businessReferencePartProperties = startRequestType is null + ? [] + : ResolveBusinessReferencePartProperties(startRequestType); + + services.AddSingleton(new WorkflowRegistration + { + WorkflowType = workflowType, + StartRequestType = startRequestType ?? typeof(Dictionary), + HandlerType = handlerType == typeof(NoopWorkflowExecutionHandler) ? null : handlerType, + Definition = BuildDefinitionDescriptor(workflow), + BusinessReferenceKeyPropertyName = businessReferenceKeyProperty?.Name, + BindStartRequest = payload => BindStartRequest(payload, startRequestType), + ExtractBusinessReference = request => ExtractBusinessReference( + request, + businessReferenceKeyProperty, + businessReferencePartProperties), + }); + + if (handlerType != typeof(NoopWorkflowExecutionHandler)) + { + services.AddScoped(handlerType); + } + + return services; + } + + private static WorkflowDefinitionDescriptor BuildDefinitionDescriptor(ISerdicaWorkflow workflow) + { + return new WorkflowDefinitionDescriptor + { + WorkflowName = workflow.WorkflowName, + WorkflowVersion = workflow.WorkflowVersion, + DisplayName = workflow.DisplayName, + WorkflowRoles = workflow.WorkflowRoles.ToArray(), + Tasks = workflow.Tasks.ToArray(), + }; + } + + private static Type? ResolveStartRequestType(Type workflowType) + { + return workflowType + .GetInterfaces() + .FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(ISerdicaWorkflow<>)) + ?.GetGenericArguments()[0]; + } + + private static Type EnsureWorkflowSupportsStartRequest(Type workflowType, Type startRequestType) + { + var resolvedStartRequestType = ResolveStartRequestType(workflowType); + if (resolvedStartRequestType is null || resolvedStartRequestType != startRequestType) + { + throw new InvalidOperationException( + $"Workflow '{workflowType.FullName}' does not implement {typeof(ISerdicaWorkflow<>).Name}<{startRequestType.Name}>."); + } + + return resolvedStartRequestType; + } + + private static object BindStartRequest(IDictionary payload, Type? startRequestType) + { + if (startRequestType is null) + { + return new Dictionary(payload, StringComparer.OrdinalIgnoreCase); + } + + var json = JsonSerializer.Serialize(payload, SerializerOptions); + return JsonSerializer.Deserialize(json, startRequestType, SerializerOptions) + ?? throw new InvalidOperationException( + $"Unable to bind workflow payload to '{startRequestType.FullName}'."); + } + + private static PropertyInfo? ResolveBusinessReferenceKeyProperty(Type startRequestType) + { + var markedProperties = startRequestType + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(x => x.GetCustomAttribute() is not null) + .ToArray(); + + return markedProperties.Length switch + { + 1 => markedProperties[0], + 0 => null, + _ => throw new InvalidOperationException( + $"Workflow start request '{startRequestType.FullName}' declares more than one property marked with {nameof(WorkflowBusinessIdAttribute)}."), + }; + } + + private static IReadOnlyCollection ResolveBusinessReferencePartProperties( + Type startRequestType) + { + var partProperties = startRequestType + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Select(x => new + { + Property = x, + Attribute = x.GetCustomAttribute(), + }) + .Where(x => x.Attribute is not null) + .Select(x => new WorkflowBusinessReferencePartProperty( + x.Property, + string.IsNullOrWhiteSpace(x.Attribute!.PartName) ? x.Property.Name : x.Attribute.PartName!)) + .ToArray(); + + var duplicatePartNames = partProperties + .GroupBy(x => x.PartName, StringComparer.OrdinalIgnoreCase) + .Where(x => x.Count() > 1) + .Select(x => x.Key) + .ToArray(); + + if (duplicatePartNames.Length > 0) + { + throw new InvalidOperationException( + $"Workflow start request '{startRequestType.FullName}' declares duplicate business reference part names: {string.Join(", ", duplicatePartNames)}."); + } + + return partProperties; + } + + private static WorkflowBusinessReference? ExtractBusinessReference( + object request, + PropertyInfo? businessReferenceKeyProperty, + IReadOnlyCollection businessReferencePartProperties) + { + var parts = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var partProperty in businessReferencePartProperties) + { + parts[partProperty.PartName] = partProperty.Property.GetValue(request); + } + + var keyPropertyAlreadyRepresented = businessReferenceKeyProperty is not null + && businessReferencePartProperties.Any(x => x.Property == businessReferenceKeyProperty); + + if (businessReferenceKeyProperty is not null + && !keyPropertyAlreadyRepresented + && !parts.ContainsKey(businessReferenceKeyProperty.Name)) + { + parts[businessReferenceKeyProperty.Name] = businessReferenceKeyProperty.GetValue(request); + } + + var key = businessReferenceKeyProperty is null + ? WorkflowBusinessReferenceExtensions.BuildCanonicalBusinessReferenceKey(parts) + : ConvertValueToString(businessReferenceKeyProperty.GetValue(request)); + + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference + { + Key = key, + Parts = parts, + }); + } + + private static string? ConvertValueToString(object? value) + { + return value switch + { + null => null, + string text => text, + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.String => jsonElement.GetString(), + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Number => jsonElement.ToString(), + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.True => bool.TrueString, + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.False => bool.FalseString, + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString(), + }; + } + + private sealed record WorkflowBusinessReferencePartProperty(PropertyInfo Property, string PartName); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRenderingAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRenderingAbstractions.cs new file mode 100644 index 000000000..55d6fd568 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRenderingAbstractions.cs @@ -0,0 +1,167 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Workflow.Abstractions; + +public static class WorkflowRenderLayoutProviderNames +{ + public const string ElkSharp = "ElkSharp"; + public const string ElkJs = "ElkJs"; + public const string Msagl = "Msagl"; +} + +public sealed class WorkflowRenderingOptions +{ + public const string SectionName = "WorkflowRendering"; + + public string? LayoutProvider { get; set; } +} + +public enum WorkflowRenderLayoutDirection +{ + TopToBottom = 0, + LeftToRight = 1, +} + +public enum WorkflowRenderLayoutEffort +{ + Draft = 0, + Balanced = 1, + Best = 2, +} + +public sealed record WorkflowRenderPort +{ + public required string Id { get; init; } + public string? Side { get; init; } + public double Width { get; init; } = 8; + public double Height { get; init; } = 8; +} + +public sealed record WorkflowRenderNode +{ + public required string Id { get; init; } + public required string Label { get; init; } + public required string Kind { get; init; } + public string? IconKey { get; init; } + public string? SemanticType { get; init; } + public string? SemanticKey { get; init; } + public string? Route { get; init; } + public string? TaskType { get; init; } + public string? ParentNodeId { get; init; } + public double Width { get; init; } = 160; + public double Height { get; init; } = 72; + public IReadOnlyCollection Ports { get; init; } = []; +} + +public sealed record WorkflowRenderEdge +{ + public required string Id { get; init; } + public required string SourceNodeId { get; init; } + public required string TargetNodeId { get; init; } + public string? SourcePortId { get; init; } + public string? TargetPortId { get; init; } + public string? Kind { get; init; } + public string? Label { get; init; } +} + +public sealed record WorkflowRenderGraph +{ + public required string Id { get; init; } + public required IReadOnlyCollection Nodes { get; init; } + public required IReadOnlyCollection Edges { get; init; } +} + +public sealed record WorkflowRenderLayoutRequest +{ + public WorkflowRenderLayoutDirection Direction { get; init; } = WorkflowRenderLayoutDirection.LeftToRight; + public double NodeSpacing { get; init; } = 40; + public double LayerSpacing { get; init; } = 60; + public WorkflowRenderLayoutEffort Effort { get; init; } = WorkflowRenderLayoutEffort.Best; + public int? OrderingIterations { get; init; } + public int? PlacementIterations { get; init; } +} + +public sealed record WorkflowRenderPoint +{ + public required double X { get; init; } + public required double Y { get; init; } +} + +public sealed record WorkflowRenderPositionedPort +{ + public required string Id { get; init; } + public string? Side { get; init; } + public double X { get; init; } + public double Y { get; init; } + public double Width { get; init; } + public double Height { get; init; } +} + +public sealed record WorkflowRenderPositionedNode +{ + public required string Id { get; init; } + public required string Label { get; init; } + public required string Kind { get; init; } + public string? IconKey { get; init; } + public string? SemanticType { get; init; } + public string? SemanticKey { get; init; } + public string? Route { get; init; } + public string? TaskType { get; init; } + public string? ParentNodeId { get; init; } + public double X { get; init; } + public double Y { get; init; } + public double Width { get; init; } + public double Height { get; init; } + public IReadOnlyCollection Ports { get; init; } = []; +} + +public sealed record WorkflowRenderEdgeSection +{ + public required WorkflowRenderPoint StartPoint { get; init; } + public required WorkflowRenderPoint EndPoint { get; init; } + public IReadOnlyCollection BendPoints { get; init; } = []; +} + +public sealed record WorkflowRenderRoutedEdge +{ + public required string Id { get; init; } + public required string SourceNodeId { get; init; } + public required string TargetNodeId { get; init; } + public string? SourcePortId { get; init; } + public string? TargetPortId { get; init; } + public string? Kind { get; init; } + public string? Label { get; init; } + public IReadOnlyCollection Sections { get; init; } = []; +} + +public sealed record WorkflowRenderLayoutResult +{ + public required string GraphId { get; init; } + public required IReadOnlyCollection Nodes { get; init; } + public required IReadOnlyCollection Edges { get; init; } +} + +public interface IWorkflowRenderGraphLayoutEngine +{ + Task LayoutAsync( + WorkflowRenderGraph graph, + WorkflowRenderLayoutRequest? request = null, + CancellationToken cancellationToken = default); +} + +public interface INamedWorkflowRenderGraphLayoutEngine : IWorkflowRenderGraphLayoutEngine +{ + string ProviderName { get; } +} + +public interface IWorkflowRenderLayoutEngineResolver +{ + INamedWorkflowRenderGraphLayoutEngine Resolve(string? providerName = null); +} + +public interface IWorkflowRenderGraphCompiler +{ + WorkflowRenderGraph Compile(WorkflowRuntimeDefinition definition); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRetentionAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRetentionAbstractions.cs new file mode 100644 index 000000000..c537c79d3 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRetentionAbstractions.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Workflow.Abstractions; + +public sealed record WorkflowProjectionRetentionBatch +{ + public IReadOnlyCollection StaleWorkflowInstanceIds { get; init; } = []; + public int StaleInstancesMarked { get; init; } + public int StaleTasksMarked { get; init; } + public IReadOnlyCollection PurgedWorkflowInstanceIds { get; init; } = []; + public int PurgedInstances { get; init; } + public int PurgedTasks { get; init; } + public int PurgedTaskEvents { get; init; } +} + +public interface IWorkflowProjectionRetentionStore +{ + Task RunAsync( + DateTime nowUtc, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeDefinitionAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeDefinitionAbstractions.cs new file mode 100644 index 000000000..aa4cc823a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeDefinitionAbstractions.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public enum WorkflowRuntimeExecutionKind +{ + DefinitionOnly = 0, + CustomHandler = 1, + Declarative = 2, +} + +public sealed record WorkflowRuntimeDefinition +{ + public required WorkflowRegistration Registration { get; init; } + + public required WorkflowDefinitionDescriptor Descriptor { get; init; } + + public required WorkflowRuntimeExecutionKind ExecutionKind { get; init; } + + public WorkflowCanonicalDefinition? CanonicalDefinition { get; init; } +} + +public interface IWorkflowRuntimeDefinitionStore +{ + IReadOnlyCollection GetDefinitions(); + + WorkflowRuntimeDefinition? GetDefinition(string workflowName, string? workflowVersion = null); + + WorkflowRuntimeDefinition GetRequiredDefinition(string workflowName, string? workflowVersion = null); +} + +public interface IWorkflowRuntimeExecutionHandlerFactory +{ + IWorkflowExecutionHandler? TryCreateHandler(WorkflowRuntimeDefinition definition); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeEngineAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeEngineAbstractions.cs new file mode 100644 index 000000000..71d4d3d53 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeEngineAbstractions.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Workflow.Abstractions; + +public static class WorkflowSignalTypes +{ + public const string TaskCompleted = "TaskCompleted"; + public const string TimerDue = "TimerDue"; + public const string RetryDue = "RetryDue"; + public const string ExternalSignal = "ExternalSignal"; + public const string SubWorkflowCompleted = "SubWorkflowCompleted"; + public const string InternalContinue = "InternalContinue"; +} + +public sealed record WorkflowSignalEnvelope +{ + public required string SignalId { get; init; } + public required string WorkflowInstanceId { get; init; } + public required string RuntimeProvider { get; init; } + public required string SignalType { get; init; } + public required long ExpectedVersion { get; init; } + public string? WaitingToken { get; init; } + public DateTime OccurredAtUtc { get; init; } = DateTime.UtcNow; + public DateTime? DueAtUtc { get; init; } + public IReadOnlyDictionary Payload { get; init; } = new Dictionary(); +} + +public interface IWorkflowRuntimeProvider : IWorkflowRuntimeOrchestrator +{ + string ProviderName { get; } +} + +public interface IWorkflowSignalLease : IAsyncDisposable +{ + WorkflowSignalEnvelope Envelope { get; } + int DeliveryCount { get; } + + Task CompleteAsync(CancellationToken cancellationToken = default); + + Task AbandonAsync(CancellationToken cancellationToken = default); + + Task DeadLetterAsync(CancellationToken cancellationToken = default); +} + +public interface IWorkflowSignalBus +{ + Task PublishAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default); + + Task PublishDeadLetterAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default); + + Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default); +} + +public interface IWorkflowScheduleBus +{ + Task ScheduleAsync( + WorkflowSignalEnvelope envelope, + DateTime dueAtUtc, + CancellationToken cancellationToken = default); +} + +public interface IWorkflowSignalProcessor +{ + Task ProcessAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimePayloadKeys.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimePayloadKeys.cs new file mode 100644 index 000000000..c55876ba4 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimePayloadKeys.cs @@ -0,0 +1,8 @@ +namespace StellaOps.Workflow.Abstractions; + +public static class WorkflowRuntimePayloadKeys +{ + public const string RuntimeTaskTokenPayloadKey = "runtimeTaskToken"; + public const string RuntimeProviderPayloadKey = "runtimeProvider"; + public const string ProjectionWorkflowInstanceIdPayloadKey = "__serdica.projectionWorkflowInstanceId"; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeProviderNames.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeProviderNames.cs new file mode 100644 index 000000000..1263248b9 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeProviderNames.cs @@ -0,0 +1,7 @@ +namespace StellaOps.Workflow.Abstractions; + +public static class WorkflowRuntimeProviderNames +{ + public const string InProcess = "Stella.InProcess"; + public const string Engine = "Stella.Engine"; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeStateConcurrencyException.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeStateConcurrencyException.cs new file mode 100644 index 000000000..3d4dfbc34 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRuntimeStateConcurrencyException.cs @@ -0,0 +1,21 @@ +using System; + +namespace StellaOps.Workflow.Abstractions; + +public sealed class WorkflowRuntimeStateConcurrencyException : Exception +{ + public WorkflowRuntimeStateConcurrencyException(string workflowInstanceId, long expectedVersion, long actualVersion) + : base( + $"Workflow runtime state '{workflowInstanceId}' version conflict. Expected next version '{expectedVersion}' but found '{actualVersion}'.") + { + WorkflowInstanceId = workflowInstanceId; + ExpectedVersion = expectedVersion; + ActualVersion = actualVersion; + } + + public string WorkflowInstanceId { get; } + + public long ExpectedVersion { get; } + + public long ActualVersion { get; } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDeadLetterAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDeadLetterAbstractions.cs new file mode 100644 index 000000000..33713aa5e --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDeadLetterAbstractions.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public interface IWorkflowSignalDeadLetterStore +{ + Task GetMessagesAsync( + WorkflowSignalDeadLettersGetRequest request, + CancellationToken cancellationToken = default); + + Task ReplayAsync( + WorkflowSignalDeadLetterReplayRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverAbstractions.cs new file mode 100644 index 000000000..4689559e3 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverAbstractions.cs @@ -0,0 +1,92 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Workflow.Abstractions; + +public enum WorkflowSignalDriverDispatchMode +{ + NativeTransactional = 0, + PostCommitNotification = 1, + WakeOutbox = 2, +} + +public interface IWorkflowSignalDriverRegistrationMarker +{ + string DriverName { get; } +} + +public sealed record WorkflowSignalDriverRegistrationMarker(string DriverName) : IWorkflowSignalDriverRegistrationMarker; + +public sealed record WorkflowSignalWakeNotification +{ + public required string SignalId { get; init; } + public required string WorkflowInstanceId { get; init; } + public required string RuntimeProvider { get; init; } + public required string SignalType { get; init; } + public DateTime? DueAtUtc { get; init; } +} + +public interface IWorkflowSignalClaimStore +{ + Task TryClaimAsync( + string consumerName, + CancellationToken cancellationToken = default); +} + +public interface IWorkflowSignalStore +{ + Task PublishAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default); + + Task PublishDeadLetterAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default); +} + +public interface IWorkflowSignalDriver +{ + string DriverName { get; } + + WorkflowSignalDriverDispatchMode DispatchMode { get; } + + Task NotifySignalAvailableAsync( + WorkflowSignalWakeNotification notification, + CancellationToken cancellationToken = default); + + Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default); +} + +public interface IWorkflowSignalScheduler +{ + Task ScheduleAsync( + WorkflowSignalEnvelope envelope, + DateTime dueAtUtc, + CancellationToken cancellationToken = default); +} + +public interface IWorkflowWakeOutbox +{ + Task EnqueueAsync( + WorkflowSignalWakeNotification notification, + CancellationToken cancellationToken = default); +} + +public interface IWorkflowWakeOutboxLease : IAsyncDisposable +{ + WorkflowSignalWakeNotification Notification { get; } + string ConsumerName { get; } + + Task CompleteAsync(CancellationToken cancellationToken = default); + + Task AbandonAsync(CancellationToken cancellationToken = default); +} + +public interface IWorkflowWakeOutboxReceiver +{ + Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverConfigurationExtensions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverConfigurationExtensions.cs new file mode 100644 index 000000000..e6a225384 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverConfigurationExtensions.cs @@ -0,0 +1,18 @@ +using System; + +using Microsoft.Extensions.Configuration; + +namespace StellaOps.Workflow.Abstractions; + +public static class WorkflowSignalDriverConfigurationExtensions +{ + public static string GetWorkflowSignalDriverProvider(this IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + var providerName = configuration.GetSection(WorkflowSignalDriverOptions.SectionName)[nameof(WorkflowSignalDriverOptions.Provider)]; + return string.IsNullOrWhiteSpace(providerName) + ? WorkflowSignalDriverNames.Native + : providerName; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverNames.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverNames.cs new file mode 100644 index 000000000..0ec9036d3 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverNames.cs @@ -0,0 +1,7 @@ +namespace StellaOps.Workflow.Abstractions; + +public static class WorkflowSignalDriverNames +{ + public const string Native = "Native"; + public const string Redis = "Redis"; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverOptions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverOptions.cs new file mode 100644 index 000000000..25d5874b0 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalDriverOptions.cs @@ -0,0 +1,8 @@ +namespace StellaOps.Workflow.Abstractions; + +public sealed class WorkflowSignalDriverOptions +{ + public const string SectionName = "WorkflowSignalDriver"; + + public string Provider { get; set; } = WorkflowSignalDriverNames.Native; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalPayloadKeys.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalPayloadKeys.cs new file mode 100644 index 000000000..4991f9bc1 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSignalPayloadKeys.cs @@ -0,0 +1,7 @@ +namespace StellaOps.Workflow.Abstractions; + +public static class WorkflowSignalPayloadKeys +{ + public const string StartWorkflowRequestPayloadKey = "startWorkflowRequest"; + public const string ExternalSignalNamePayloadKey = "signalName"; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSpecExecutionContextContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSpecExecutionContextContracts.cs new file mode 100644 index 000000000..5a3e3ce9b --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSpecExecutionContextContracts.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public interface IWorkflowSpecExecutionContext +{ + string WorkflowName { get; } + Dictionary WorkflowState { get; } + IReadOnlyDictionary Payload { get; } + IReadOnlyDictionary ResultValues { get; } + WorkflowValueDictionary StateValues { get; } + WorkflowValueDictionary PayloadValues { get; } + + TResponse GetRequiredResult(string resultKey); + TResponse? GetOptionalResult(string resultKey); + bool CompareValue(WorkflowValueSource source, string key, object? expectedValue); +} + +public sealed class Address +{ + public Address( + string microserviceName, + string command) + { + MicroserviceName = string.IsNullOrWhiteSpace(microserviceName) + ? throw new ArgumentException("Microservice name is required.", nameof(microserviceName)) + : microserviceName; + Command = string.IsNullOrWhiteSpace(command) + ? throw new ArgumentException("Command is required.", nameof(command)) + : command; + } + + public string MicroserviceName { get; } + public string Command { get; } +} + +public sealed class LegacyRabbitAddress +{ + public LegacyRabbitAddress( + string command, + WorkflowLegacyRabbitMode mode = WorkflowLegacyRabbitMode.Envelope) + { + Command = string.IsNullOrWhiteSpace(command) + ? throw new ArgumentException("Command is required.", nameof(command)) + : command; + Mode = mode; + } + + public string Command { get; } + public WorkflowLegacyRabbitMode Mode { get; } +} + +public sealed class GraphqlAddress +{ + public GraphqlAddress( + string target, + string query, + string? operationName = null) + { + Target = string.IsNullOrWhiteSpace(target) + ? throw new ArgumentException("GraphQL target is required.", nameof(target)) + : target; + Query = string.IsNullOrWhiteSpace(query) + ? throw new ArgumentException("GraphQL query is required.", nameof(query)) + : query; + OperationName = operationName; + } + + public string Target { get; } + public string Query { get; } + public string? OperationName { get; } +} + +public sealed class HttpAddress +{ + public HttpAddress( + string target, + string path, + string method = "POST") + { + Target = string.IsNullOrWhiteSpace(target) + ? throw new ArgumentException("HTTP target is required.", nameof(target)) + : target; + Path = string.IsNullOrWhiteSpace(path) + ? throw new ArgumentException("HTTP path is required.", nameof(path)) + : path; + Method = string.IsNullOrWhiteSpace(method) + ? throw new ArgumentException("HTTP method is required.", nameof(method)) + : method.Trim().ToUpperInvariant(); + } + + public string Target { get; } + public string Method { get; } + public string Path { get; } +} + +public sealed class WorkflowReference +{ + private readonly string? staticWorkflowName; + private readonly string? staticWorkflowVersion; + + public WorkflowReference( + string workflowName, + string? workflowVersion = null) + : this( + _ => workflowName, + _ => workflowVersion) + { + staticWorkflowName = workflowName; + staticWorkflowVersion = workflowVersion; + } + + public WorkflowReference( + Func workflowNameFactory, + Func? workflowVersionFactory = null) + { + WorkflowNameFactory = workflowNameFactory ?? throw new ArgumentNullException(nameof(workflowNameFactory)); + WorkflowVersionFactory = workflowVersionFactory; + } + + public Func WorkflowNameFactory { get; } + public Func? WorkflowVersionFactory { get; } + public string? StaticWorkflowName => staticWorkflowName; + public string? StaticWorkflowVersion => staticWorkflowVersion; + + public StartWorkflowRequest BuildStartWorkflowRequest( + IWorkflowSpecExecutionContext context, + object? payload, + WorkflowBusinessReference? businessReference = null) + { + return new StartWorkflowRequest + { + WorkflowName = WorkflowNameFactory(context), + WorkflowVersion = WorkflowVersionFactory?.Invoke(context), + BusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference), + Payload = payload.AsWorkflowObjectDictionary(), + }; + } + + public WorkflowWorkflowInvocationDeclaration? TryBuildCanonicalInvocationDeclaration( + WorkflowExpressionDefinition? payloadExpression = null, + WorkflowBusinessReferenceDeclaration? businessReference = null) + { + if (string.IsNullOrWhiteSpace(staticWorkflowName)) + { + return null; + } + + return new WorkflowWorkflowInvocationDeclaration + { + WorkflowNameExpression = WorkflowExpr.String(staticWorkflowName), + WorkflowVersionExpression = string.IsNullOrWhiteSpace(staticWorkflowVersion) + ? null + : WorkflowExpr.String(staticWorkflowVersion), + PayloadExpression = payloadExpression, + BusinessReference = businessReference, + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowStepIdentityAssigner.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowStepIdentityAssigner.cs new file mode 100644 index 000000000..d83dc18d3 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowStepIdentityAssigner.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; + +namespace StellaOps.Workflow.Abstractions; + +internal static class WorkflowStepIdentityAssigner +{ + public static void Assign(WorkflowSpec spec) + where TStartRequest : class + { + ArgumentNullException.ThrowIfNull(spec); + + AssignSequence(spec.InitialSequence, "start"); + + foreach (var task in spec.TasksByName.Values) + { + AssignSequence(task.OnComplete, $"task:{task.TaskName}"); + } + } + + private static void AssignSequence( + WorkflowStepSequence sequence, + string prefix) + where TStartRequest : class + { + var steps = sequence.Steps; + var index = 0; + + foreach (var step in steps) + { + step.StepId = $"{prefix}/{index}"; + + switch (step) + { + case WorkflowDecisionStepDefinition decision: + AssignSequence(decision.WhenTrue, $"{step.StepId}/true"); + AssignSequence(decision.WhenFalse, $"{step.StepId}/false"); + break; + case WorkflowConditionalStepDefinition conditional: + AssignSequence(conditional.WhenTrue, $"{step.StepId}/true"); + AssignSequence(conditional.WhenElse, $"{step.StepId}/else"); + break; + case WorkflowMicroserviceCallStepDefinition microserviceCall: + AssignFailureHandlers(microserviceCall.FailureHandlers, step.StepId); + break; + case WorkflowLegacyRabbitCallStepDefinition legacyRabbitCall: + AssignFailureHandlers(legacyRabbitCall.FailureHandlers, step.StepId); + break; + case WorkflowGraphqlCallStepDefinition graphqlCall: + AssignFailureHandlers(graphqlCall.FailureHandlers, step.StepId); + break; + case WorkflowHttpCallStepDefinition httpCall: + AssignFailureHandlers(httpCall.FailureHandlers, step.StepId); + break; + case WorkflowInlineStepDefinition inlineStep: + AssignFailureHandlers(inlineStep.FailureHandlers, step.StepId); + break; + case WorkflowRepeatStepDefinition repeat: + AssignSequence(repeat.Body, $"{step.StepId}/repeat"); + break; + case WorkflowForkStepDefinition fork: + AssignForkBranches(fork.Branches, step.StepId); + break; + } + + index++; + } + } + + private static void AssignForkBranches( + IReadOnlyCollection> branches, + string stepId) + where TStartRequest : class + { + var index = 0; + foreach (var branch in branches) + { + AssignSequence(branch, $"{stepId}/branch:{index}"); + index++; + } + } + + private static void AssignFailureHandlers( + WorkflowFailureHandlers? failureHandlers, + string stepId) + where TStartRequest : class + { + if (failureHandlers is null) + { + return; + } + + AssignSequence(failureHandlers.WhenFailure, $"{stepId}/failure"); + AssignSequence(failureHandlers.WhenTimeout, $"{stepId}/timeout"); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowTimeoutDefaults.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowTimeoutDefaults.cs new file mode 100644 index 000000000..85e9c1282 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowTimeoutDefaults.cs @@ -0,0 +1,20 @@ +namespace StellaOps.Workflow.Abstractions; + +/// +/// Default timeout values used across the workflow engine when no explicit timeout is specified. +/// +public static class WorkflowTimeoutDefaults +{ + /// + /// Default timeout for service task (transport call) steps in seconds. + /// Applied when neither the step declaration nor the transport configuration specifies a timeout. + /// Value: 3600 seconds (1 hour). + /// + public const int DefaultTimeoutForServiceTaskCallsSeconds = 3600; + + /// + /// Default transport-level timeout in seconds, used as fallback when transport options + /// do not specify a timeout. Value: 30 seconds. + /// + public const int DefaultTransportTimeoutSeconds = 30; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowTransportAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowTransportAbstractions.cs new file mode 100644 index 000000000..3fbccf538 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowTransportAbstractions.cs @@ -0,0 +1,41 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Abstractions; + +public interface IWorkflowMicroserviceTransport +{ + Task ExecuteAsync( + WorkflowMicroserviceRequest request, + CancellationToken cancellationToken = default); +} + +public interface IWorkflowRabbitTransport +{ + Task ExecuteAsync( + WorkflowRabbitRequest request, + CancellationToken cancellationToken = default); +} + +public interface IWorkflowLegacyRabbitTransport +{ + Task ExecuteAsync( + WorkflowLegacyRabbitRequest request, + CancellationToken cancellationToken = default); +} + +public interface IWorkflowGraphqlTransport +{ + Task ExecuteAsync( + WorkflowGraphqlRequest request, + CancellationToken cancellationToken = default); +} + +public interface IWorkflowHttpTransport +{ + Task ExecuteAsync( + WorkflowHttpRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowValueNotFoundException.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowValueNotFoundException.cs new file mode 100644 index 000000000..96e3c546b --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowValueNotFoundException.cs @@ -0,0 +1,9 @@ +namespace StellaOps.Workflow.Abstractions; + +public class WorkflowValueNotFoundException : InvalidOperationException +{ + public string MessageKey { get; } + public WorkflowValueNotFoundException(string messageKey, params object[] args) + : base($"Workflow value not found: {messageKey} [{string.Join(", ", args)}]") + { MessageKey = messageKey; } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowVersioning.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowVersioning.cs new file mode 100644 index 000000000..c23f2ed0b --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowVersioning.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace StellaOps.Workflow.Abstractions; + +public static class WorkflowVersioning +{ + private static readonly Regex SemanticVersionRegex = new( + @"^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public static IComparer SemanticComparer { get; } = new SemanticWorkflowVersionComparer(); + + public static bool TryParseSemanticVersion(string? value, out Version version) + { + var match = value is null ? null : SemanticVersionRegex.Match(value); + if (match is { Success: true } + && int.TryParse(match.Groups["major"].Value, out var major) + && int.TryParse(match.Groups["minor"].Value, out var minor) + && int.TryParse(match.Groups["patch"].Value, out var patch)) + { + version = new Version(major, minor, patch); + return true; + } + + version = new Version(0, 0); + return false; + } + + /// + /// Extracts the base version (without build metadata) from a version string. + /// "1.0.0+2" returns "1.0.0". "1.0.0" returns "1.0.0". + /// + public static string GetBaseVersion(string version) + { + var plusIndex = version.IndexOf('+'); + return plusIndex >= 0 ? version[..plusIndex] : version; + } + + /// + /// Extracts the build iteration from a version string. + /// "1.0.0+2" returns 2. "1.0.0" returns 0. + /// + public static int GetBuildIteration(string version) + { + var plusIndex = version.IndexOf('+'); + if (plusIndex >= 0 && int.TryParse(version[(plusIndex + 1)..], out var iteration)) + { + return iteration; + } + + return 0; + } + + /// + /// Formats a version with build metadata suffix. + /// FormatVersion("1.0.0", 0) returns "1.0.0". + /// FormatVersion("1.0.0", 2) returns "1.0.0+2". + /// + public static string FormatVersion(string baseVersion, int buildIteration) + { + return buildIteration > 0 ? $"{baseVersion}+{buildIteration}" : baseVersion; + } + + private sealed class SemanticWorkflowVersionComparer : IComparer + { + public int Compare(string? x, string? y) + { + var xParsed = TryParseSemanticVersion(x, out var xVersion); + var yParsed = TryParseSemanticVersion(y, out var yVersion); + + if (xParsed && yParsed) + { + return xVersion.CompareTo(yVersion); + } + + if (xParsed) + { + return 1; + } + + if (yParsed) + { + return -1; + } + + return string.Compare(x, y, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowWebserviceServiceCollectionExtensions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowWebserviceServiceCollectionExtensions.cs new file mode 100644 index 000000000..e50c685c2 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowWebserviceServiceCollectionExtensions.cs @@ -0,0 +1,132 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// Extension methods for registering workflow webservice API implementations in DI. +/// +public static class WorkflowWebserviceServiceCollectionExtensions +{ + /// + /// Registers all workflow webservice API interfaces using the provided implementation types. + /// Only interfaces whose implementations are supplied will be registered. + /// + public static IServiceCollection AddWorkflowWebserviceApis( + this IServiceCollection services) + where TRuntime : class, IWorkflowRuntimeApi + { + services.TryAddScoped(); + return services; + } + + /// + /// Registers a specific workflow webservice API interface. + /// + public static IServiceCollection AddWorkflowRuntimeApi( + this IServiceCollection services) + where TImplementation : class, IWorkflowRuntimeApi + { + services.TryAddScoped(); + return services; + } + + /// + /// Registers the workflow definition deployment API. + /// + public static IServiceCollection AddWorkflowDefinitionDeploymentApi( + this IServiceCollection services) + where TImplementation : class, IWorkflowDefinitionDeploymentApi + { + services.TryAddScoped(); + return services; + } + + /// + /// Registers the workflow diagram API. + /// + public static IServiceCollection AddWorkflowDiagramApi( + this IServiceCollection services) + where TImplementation : class, IWorkflowDiagramApi + { + services.TryAddScoped(); + return services; + } + + /// + /// Registers the workflow definition query API. + /// + public static IServiceCollection AddWorkflowDefinitionQueryApi( + this IServiceCollection services) + where TImplementation : class, IWorkflowDefinitionQueryApi + { + services.TryAddScoped(); + return services; + } + + /// + /// Registers the workflow canonical definition API. + /// + public static IServiceCollection AddWorkflowCanonicalDefinitionApi( + this IServiceCollection services) + where TImplementation : class, IWorkflowCanonicalDefinitionApi + { + services.TryAddScoped(); + return services; + } + + /// + /// Registers the workflow retention API. + /// + public static IServiceCollection AddWorkflowRetentionApi( + this IServiceCollection services) + where TImplementation : class, IWorkflowRetentionApi + { + services.TryAddScoped(); + return services; + } + + /// + /// Registers the workflow signal dead-letter API. + /// + public static IServiceCollection AddWorkflowSignalDeadLetterApi( + this IServiceCollection services) + where TImplementation : class, IWorkflowSignalDeadLetterApi + { + services.TryAddScoped(); + return services; + } + + /// + /// Registers the workflow service metadata API. + /// + public static IServiceCollection AddWorkflowServiceMetadataApi( + this IServiceCollection services) + where TImplementation : class, IWorkflowServiceMetadataApi + { + services.TryAddScoped(); + return services; + } + + /// + /// Registers the workflow function catalog API. + /// + public static IServiceCollection AddWorkflowFunctionCatalogApi( + this IServiceCollection services) + where TImplementation : class, IWorkflowFunctionCatalogApi + { + services.TryAddScoped(); + return services; + } + + /// + /// Registers the workflow signal pump telemetry API. + /// + public static IServiceCollection AddWorkflowSignalPumpTelemetryApi( + this IServiceCollection services) + where TImplementation : class, IWorkflowSignalPumpTelemetryApi + { + services.TryAddScoped(); + return services; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/StellaOps.Workflow.Contracts.csproj b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/StellaOps.Workflow.Contracts.csproj new file mode 100644 index 000000000..60699d13b --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/StellaOps.Workflow.Contracts.csproj @@ -0,0 +1,8 @@ + + + net10.0 + enable + enable + false + + diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowBusinessReferenceContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowBusinessReferenceContracts.cs new file mode 100644 index 000000000..c8c003009 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowBusinessReferenceContracts.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace StellaOps.Workflow.Contracts; + +public sealed record WorkflowBusinessReference +{ + public string? Key { get; init; } + public IDictionary Parts { get; init; } = new Dictionary(); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowCanonicalDefinitionContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowCanonicalDefinitionContracts.cs new file mode 100644 index 000000000..f1e5bb15a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowCanonicalDefinitionContracts.cs @@ -0,0 +1,359 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StellaOps.Workflow.Contracts; + +public static class WorkflowCanonicalDefinitionSchema +{ + public const string Version1 = "stellaops.workflow.definition/v1"; + + /// + /// Obsolete alias retained for backward compatibility with definitions + /// serialized under the original Serdica schema version. + /// + [Obsolete("Use Version1 (\"stellaops.workflow.definition/v1\") instead.")] + public const string Version1Serdica = "serdica.workflow.definition/v1"; +} + +public sealed record WorkflowCanonicalDefinition +{ + public string SchemaVersion { get; init; } = WorkflowCanonicalDefinitionSchema.Version1; + public required string WorkflowName { get; init; } + public required string WorkflowVersion { get; init; } + public required string DisplayName { get; init; } + public WorkflowRequestContractDeclaration? StartRequest { get; init; } + public IReadOnlyCollection WorkflowRoles { get; init; } = []; + public WorkflowBusinessReferenceDeclaration? BusinessReference { get; init; } + public required WorkflowStartDeclaration Start { get; init; } + public IReadOnlyCollection Tasks { get; init; } = []; + public IReadOnlyCollection RequiredModules { get; init; } = []; + public IReadOnlyCollection RequiredCapabilities { get; init; } = []; +} + +public sealed record WorkflowRequiredModuleDeclaration +{ + public required string ModuleName { get; init; } + public string VersionExpression { get; init; } = ">=1.0.0"; + public bool Optional { get; init; } +} + +public sealed record WorkflowRequestContractDeclaration +{ + /// + /// CLR type name of the start request. Retained for backward compatibility but + /// not used for canonical portability — use instead. + /// + public string? ContractName { get; init; } + + /// + /// JSON Schema describing the start request shape. This is the portable, + /// CLR-independent contract definition for the workflow's input. + /// + public IDictionary? Schema { get; init; } + + public string? SchemaReference { get; init; } + public bool AllowAdditionalProperties { get; init; } = true; +} + +public sealed record WorkflowStartDeclaration +{ + public required WorkflowExpressionDefinition InitializeStateExpression { get; init; } + public string? InitialTaskName { get; init; } + public WorkflowStepSequenceDeclaration InitialSequence { get; init; } = new(); +} + +public sealed record WorkflowTaskDeclaration +{ + public required string TaskName { get; init; } + public required string TaskType { get; init; } + public required WorkflowExpressionDefinition RouteExpression { get; init; } + public required WorkflowExpressionDefinition PayloadExpression { get; init; } + public IReadOnlyCollection TaskRoles { get; init; } = []; + public WorkflowStepSequenceDeclaration OnComplete { get; init; } = new(); +} + +public sealed record WorkflowBusinessReferenceDeclaration +{ + public WorkflowExpressionDefinition? KeyExpression { get; init; } + public IReadOnlyCollection Parts { get; init; } = []; +} + +public sealed record WorkflowNamedExpressionDefinition +{ + public required string Name { get; init; } + public required WorkflowExpressionDefinition Expression { get; init; } +} + +public sealed record WorkflowStepSequenceDeclaration +{ + public IReadOnlyCollection Steps { get; init; } = []; +} + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(WorkflowSetStateStepDeclaration), "set-state")] +[JsonDerivedType(typeof(WorkflowAssignBusinessReferenceStepDeclaration), "assign-business-reference")] +[JsonDerivedType(typeof(WorkflowTransportCallStepDeclaration), "call-transport")] +[JsonDerivedType(typeof(WorkflowDecisionStepDeclaration), "decision")] +[JsonDerivedType(typeof(WorkflowActivateTaskStepDeclaration), "activate-task")] +[JsonDerivedType(typeof(WorkflowContinueWithWorkflowStepDeclaration), "continue-with-workflow")] +[JsonDerivedType(typeof(WorkflowSubWorkflowStepDeclaration), "sub-workflow")] +[JsonDerivedType(typeof(WorkflowRepeatStepDeclaration), "repeat")] +[JsonDerivedType(typeof(WorkflowTimerStepDeclaration), "timer")] +[JsonDerivedType(typeof(WorkflowExternalSignalStepDeclaration), "external-signal")] +[JsonDerivedType(typeof(WorkflowForkStepDeclaration), "fork")] +[JsonDerivedType(typeof(WorkflowCompleteStepDeclaration), "complete")] +public abstract record WorkflowStepDeclaration; + +public sealed record WorkflowSetStateStepDeclaration : WorkflowStepDeclaration +{ + public required string StateKey { get; init; } + public required WorkflowExpressionDefinition ValueExpression { get; init; } + public bool OnlyIfPresent { get; init; } +} + +public sealed record WorkflowAssignBusinessReferenceStepDeclaration : WorkflowStepDeclaration +{ + public required WorkflowBusinessReferenceDeclaration BusinessReference { get; init; } +} + +public sealed record WorkflowTransportCallStepDeclaration : WorkflowStepDeclaration +{ + public required string StepName { get; init; } + public required WorkflowTransportInvocationDeclaration Invocation { get; init; } + public string? ResultKey { get; init; } + + /// + /// Optional per-step timeout in seconds. Overrides the transport-level default. + /// When null, the default timeout (1 hour) is used. + /// + public int? TimeoutSeconds { get; init; } + + public WorkflowStepSequenceDeclaration? WhenFailure { get; init; } + public WorkflowStepSequenceDeclaration? WhenTimeout { get; init; } +} + +public sealed record WorkflowDecisionStepDeclaration : WorkflowStepDeclaration +{ + public required string DecisionName { get; init; } + public required WorkflowExpressionDefinition ConditionExpression { get; init; } + public WorkflowStepSequenceDeclaration WhenTrue { get; init; } = new(); + public WorkflowStepSequenceDeclaration WhenElse { get; init; } = new(); +} + +public sealed record WorkflowActivateTaskStepDeclaration : WorkflowStepDeclaration +{ + public required string TaskName { get; init; } + public WorkflowExpressionDefinition? RuntimeRolesExpression { get; init; } + + /// + /// Optional timeout for the human task in seconds. + /// When null, no deadline is set — the task runs indefinitely until completed or purged by retention. + /// When set, the task's DeadlineUtc is computed as CreatedOnUtc + TimeoutSeconds. + /// + public int? TimeoutSeconds { get; init; } +} + +public sealed record WorkflowContinueWithWorkflowStepDeclaration : WorkflowStepDeclaration +{ + public required string StepName { get; init; } + public required WorkflowWorkflowInvocationDeclaration Invocation { get; init; } +} + +public sealed record WorkflowSubWorkflowStepDeclaration : WorkflowStepDeclaration +{ + public required string StepName { get; init; } + public required WorkflowWorkflowInvocationDeclaration Invocation { get; init; } + public string? ResultKey { get; init; } +} + +public sealed record WorkflowRepeatStepDeclaration : WorkflowStepDeclaration +{ + public required string StepName { get; init; } + public required WorkflowExpressionDefinition MaxIterationsExpression { get; init; } + public string? IterationStateKey { get; init; } + public WorkflowExpressionDefinition? ContinueWhileExpression { get; init; } + public WorkflowStepSequenceDeclaration Body { get; init; } = new(); +} + +public sealed record WorkflowTimerStepDeclaration : WorkflowStepDeclaration +{ + public required string StepName { get; init; } + public required WorkflowExpressionDefinition DelayExpression { get; init; } +} + +public sealed record WorkflowExternalSignalStepDeclaration : WorkflowStepDeclaration +{ + public required string StepName { get; init; } + public required WorkflowExpressionDefinition SignalNameExpression { get; init; } + public string? ResultKey { get; init; } +} + +public sealed record WorkflowForkStepDeclaration : WorkflowStepDeclaration +{ + public required string StepName { get; init; } + public IReadOnlyCollection Branches { get; init; } = []; +} + +public sealed record WorkflowCompleteStepDeclaration : WorkflowStepDeclaration; + +public sealed record WorkflowTransportInvocationDeclaration +{ + public required WorkflowTransportAddressDeclaration Address { get; init; } + public WorkflowExpressionDefinition? PayloadExpression { get; init; } +} + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(WorkflowMicroserviceAddressDeclaration), "microservice")] +[JsonDerivedType(typeof(WorkflowRabbitAddressDeclaration), "rabbit")] +[JsonDerivedType(typeof(WorkflowLegacyRabbitAddressDeclaration), "legacy-rabbit")] +[JsonDerivedType(typeof(WorkflowGraphqlAddressDeclaration), "graphql")] +[JsonDerivedType(typeof(WorkflowHttpAddressDeclaration), "http")] +public abstract record WorkflowTransportAddressDeclaration +{ + public string? Alias { get; init; } +} + +public sealed record WorkflowMicroserviceAddressDeclaration : WorkflowTransportAddressDeclaration +{ + public required string MicroserviceName { get; init; } + public required string Command { get; init; } +} + +public sealed record WorkflowRabbitAddressDeclaration : WorkflowTransportAddressDeclaration +{ + public required string Exchange { get; init; } + public required string RoutingKey { get; init; } +} + +public sealed record WorkflowLegacyRabbitAddressDeclaration : WorkflowTransportAddressDeclaration +{ + public required string Command { get; init; } + public WorkflowLegacyRabbitMode Mode { get; init; } = WorkflowLegacyRabbitMode.Envelope; +} + +public sealed record WorkflowGraphqlAddressDeclaration : WorkflowTransportAddressDeclaration +{ + public required string Target { get; init; } + public required string Query { get; init; } + public string? OperationName { get; init; } +} + +public sealed record WorkflowHttpAddressDeclaration : WorkflowTransportAddressDeclaration +{ + public required string Target { get; init; } + public required string Path { get; init; } + public string Method { get; init; } = "POST"; +} + +public sealed record WorkflowWorkflowInvocationDeclaration +{ + public required WorkflowExpressionDefinition WorkflowNameExpression { get; init; } + public WorkflowExpressionDefinition? WorkflowVersionExpression { get; init; } + public WorkflowExpressionDefinition? PayloadExpression { get; init; } + public WorkflowBusinessReferenceDeclaration? BusinessReference { get; init; } +} + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(WorkflowNullExpressionDefinition), "null")] +[JsonDerivedType(typeof(WorkflowStringExpressionDefinition), "string")] +[JsonDerivedType(typeof(WorkflowNumberExpressionDefinition), "number")] +[JsonDerivedType(typeof(WorkflowBooleanExpressionDefinition), "boolean")] +[JsonDerivedType(typeof(WorkflowPathExpressionDefinition), "path")] +[JsonDerivedType(typeof(WorkflowObjectExpressionDefinition), "object")] +[JsonDerivedType(typeof(WorkflowArrayExpressionDefinition), "array")] +[JsonDerivedType(typeof(WorkflowFunctionExpressionDefinition), "function")] +[JsonDerivedType(typeof(WorkflowGroupExpressionDefinition), "group")] +[JsonDerivedType(typeof(WorkflowUnaryExpressionDefinition), "unary")] +[JsonDerivedType(typeof(WorkflowBinaryExpressionDefinition), "binary")] +public abstract record WorkflowExpressionDefinition; + +public sealed record WorkflowNullExpressionDefinition : WorkflowExpressionDefinition; + +public sealed record WorkflowStringExpressionDefinition : WorkflowExpressionDefinition +{ + public required string Value { get; init; } +} + +public sealed record WorkflowNumberExpressionDefinition : WorkflowExpressionDefinition +{ + public required string Value { get; init; } +} + +public sealed record WorkflowBooleanExpressionDefinition : WorkflowExpressionDefinition +{ + public required bool Value { get; init; } +} + +public sealed record WorkflowPathExpressionDefinition : WorkflowExpressionDefinition +{ + public required string Path { get; init; } +} + +public sealed record WorkflowObjectExpressionDefinition : WorkflowExpressionDefinition +{ + public IReadOnlyCollection Properties { get; init; } = []; +} + +public sealed record WorkflowArrayExpressionDefinition : WorkflowExpressionDefinition +{ + public IReadOnlyCollection Items { get; init; } = []; +} + +public sealed record WorkflowFunctionExpressionDefinition : WorkflowExpressionDefinition +{ + public required string FunctionName { get; init; } + public IReadOnlyCollection Arguments { get; init; } = []; +} + +public sealed record WorkflowGroupExpressionDefinition : WorkflowExpressionDefinition +{ + public required WorkflowExpressionDefinition Expression { get; init; } +} + +public sealed record WorkflowUnaryExpressionDefinition : WorkflowExpressionDefinition +{ + public required string Operator { get; init; } + public required WorkflowExpressionDefinition Operand { get; init; } +} + +public sealed record WorkflowBinaryExpressionDefinition : WorkflowExpressionDefinition +{ + public required string Operator { get; init; } + public required WorkflowExpressionDefinition Left { get; init; } + public required WorkflowExpressionDefinition Right { get; init; } +} + +public static class WorkflowCanonicalJsonSerializer +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + public static JsonSerializerOptions Options => SerializerOptions; + + public static string Serialize(WorkflowCanonicalDefinition definition) + { + return JsonSerializer.Serialize(definition, SerializerOptions); + } + + public static string SerializeFragment(TValue value) + { + return JsonSerializer.Serialize(value, SerializerOptions); + } + + public static WorkflowCanonicalDefinition Deserialize(string json) + { + return JsonSerializer.Deserialize(json, SerializerOptions) + ?? throw new JsonException("Unable to deserialize canonical workflow definition."); + } + + public static TValue DeserializeFragment(string json) + { + return JsonSerializer.Deserialize(json, SerializerOptions) + ?? throw new JsonException($"Unable to deserialize canonical workflow fragment '{typeof(TValue).FullName}'."); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowDefinitionDeploymentContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowDefinitionDeploymentContracts.cs new file mode 100644 index 000000000..aefcc7876 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowDefinitionDeploymentContracts.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; + +namespace StellaOps.Workflow.Contracts; + +// ═══════════════════════════════════════════════════════════ +// EXPORT +// ═══════════════════════════════════════════════════════════ + +public sealed record WorkflowDefinitionExportRequest +{ + /// Workflow name to export. + public required string WorkflowName { get; init; } + + /// Specific version to export. Null = export the active version. + public string? Version { get; init; } + + /// Include rendering assets (SVG, JSON, PNG) in the response. + public bool IncludeRendering { get; init; } +} + +public sealed record WorkflowDefinitionExportResponse +{ + public required string WorkflowName { get; init; } + public required string Version { get; init; } + public required string ContentHash { get; init; } + public bool IsActive { get; init; } + public required string CanonicalDefinitionJson { get; init; } + public string? RenderingSvgBase64 { get; init; } + public string? RenderingJsonBase64 { get; init; } + public string? RenderingPngBase64 { get; init; } +} + +// ═══════════════════════════════════════════════════════════ +// IMPORT +// ═══════════════════════════════════════════════════════════ + +public sealed record WorkflowDefinitionImportRequest +{ + /// Canonical definition JSON to import. + public required string CanonicalDefinitionJson { get; init; } + + /// Optional SVG diagram (base64-encoded). + public string? RenderingSvgBase64 { get; init; } + + /// Optional render graph JSON (base64-encoded). + public string? RenderingJsonBase64 { get; init; } + + /// Optional PNG screenshot (base64-encoded). + public string? RenderingPngBase64 { get; init; } + + /// User or system identifier performing the import. + public string? ImportedBy { get; init; } +} + +public sealed record WorkflowDefinitionImportResponse +{ + public required string WorkflowName { get; init; } + public required string Version { get; init; } + public required string ContentHash { get; init; } + + /// False if the content hash matched an existing version (no-op import). + public bool WasImported { get; init; } + + /// True if this was the first import for this base version. + public bool WasNewVersion { get; init; } + + /// Whether this version is now the active version. + public bool IsActive { get; init; } + + /// Validation issues found during import (empty if successful). + public IReadOnlyCollection ValidationIssues { get; init; } = []; +} + +// ═══════════════════════════════════════════════════════════ +// VERSIONS +// ═══════════════════════════════════════════════════════════ + +public sealed record WorkflowDefinitionVersionsGetRequest +{ + public required string WorkflowName { get; init; } +} + +public sealed record WorkflowDefinitionVersionsGetResponse +{ + public IReadOnlyCollection Versions { get; init; } = []; +} + +public sealed record WorkflowDefinitionVersionSummary +{ + public required string WorkflowName { get; init; } + public required string Version { get; init; } + public required string BaseVersion { get; init; } + public int BuildIteration { get; init; } + public required string ContentHash { get; init; } + public bool IsActive { get; init; } + public string? DisplayName { get; init; } + public DateTime CreatedOnUtc { get; init; } + public DateTime? ActivatedOnUtc { get; init; } + public string? ImportedBy { get; init; } +} + +// ═══════════════════════════════════════════════════════════ +// ACTIVATE +// ═══════════════════════════════════════════════════════════ + +public sealed record WorkflowDefinitionActivateRequest +{ + public required string WorkflowName { get; init; } + public required string Version { get; init; } +} + +public sealed record WorkflowDefinitionActivateResponse +{ + public required string WorkflowName { get; init; } + public required string Version { get; init; } + public bool Activated { get; init; } +} + +// ═══════════════════════════════════════════════════════════ +// BY-ID (single definition lookup) +// ═══════════════════════════════════════════════════════════ + +public sealed record WorkflowDefinitionByIdRequest +{ + /// Workflow name to retrieve. + public required string WorkflowName { get; init; } + + /// Specific version. Null = active version. + public string? Version { get; init; } + + /// Include rendering assets (SVG, JSON, PNG) in the response. + public bool IncludeRendering { get; init; } +} + +public sealed record WorkflowDefinitionByIdResponse +{ + public required string WorkflowName { get; init; } + public required string Version { get; init; } + public string? DisplayName { get; init; } + public required string CanonicalDefinitionJson { get; init; } + public string? ContentHash { get; init; } + public bool IsActive { get; init; } + public string? RenderingSvgBase64 { get; init; } + public string? RenderingJsonBase64 { get; init; } + public string? RenderingPngBase64 { get; init; } +} + +// ═══════════════════════════════════════════════════════════ +// RENDERING (format-specific output) +// ═══════════════════════════════════════════════════════════ + +public sealed record WorkflowRenderingRequest +{ + /// Workflow name to render. + public required string WorkflowName { get; init; } + + /// Specific version. Null = active version. + public string? Version { get; init; } + + /// Output format: "svg", "png", or "json" (render graph). + public string Format { get; init; } = "svg"; +} + +public sealed record WorkflowRenderingResponse +{ + public required string WorkflowName { get; init; } + public required string Version { get; init; } + public required string Format { get; init; } + + /// Rendered content as base64. + public string? ContentBase64 { get; init; } + + /// MIME type: image/svg+xml, image/png, or application/json. + public string? ContentType { get; init; } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowDefinitionsContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowDefinitionsContracts.cs new file mode 100644 index 000000000..bff1b4d95 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowDefinitionsContracts.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace StellaOps.Workflow.Contracts; + +public sealed record WorkflowTaskDescriptor +{ + public required string TaskName { get; init; } + public required string TaskType { get; init; } + public required string Route { get; init; } + public IReadOnlyCollection TaskRoles { get; init; } = []; +} + +public sealed record WorkflowDefinitionDescriptor +{ + public required string WorkflowName { get; init; } + public required string WorkflowVersion { get; init; } + public required string DisplayName { get; init; } + public IReadOnlyCollection WorkflowRoles { get; init; } = []; + public IReadOnlyCollection Tasks { get; init; } = []; +} + +public sealed record WorkflowDefinitionGetRequest +{ + public string? WorkflowName { get; init; } + public string? WorkflowVersion { get; init; } + + /// Filter by multiple workflow names. + public IReadOnlyCollection WorkflowNames { get; init; } = []; +} + +public sealed record WorkflowDefinitionGetResponse +{ + public IReadOnlyCollection Definitions { get; init; } = []; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowDiagramContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowDiagramContracts.cs new file mode 100644 index 000000000..3d4311573 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowDiagramContracts.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; + +namespace StellaOps.Workflow.Contracts; + +public sealed record WorkflowDiagramNode +{ + public required string Id { get; init; } + public required string Label { get; init; } + public required string NodeType { get; init; } + public string? IconKey { get; init; } + public string? SemanticType { get; init; } + public string? SemanticKey { get; init; } + public string? Route { get; init; } + public string? TaskType { get; init; } + public double X { get; init; } + public double Y { get; init; } + public double Width { get; init; } + public double Height { get; init; } + public IReadOnlyCollection WorkflowRoles { get; init; } = []; + public IReadOnlyCollection TaskRoles { get; init; } = []; + public string? NodeStatus { get; init; } + public string? WorkflowTaskId { get; init; } + public string? Assignee { get; init; } + public IReadOnlyCollection EffectiveRoles { get; init; } = []; + public int VisitCount { get; init; } +} + +public sealed record WorkflowDiagramPoint +{ + public required double X { get; init; } + public required double Y { get; init; } +} + +public sealed record WorkflowDiagramEdgeSection +{ + public required WorkflowDiagramPoint StartPoint { get; init; } + public required WorkflowDiagramPoint EndPoint { get; init; } + public IReadOnlyCollection BendPoints { get; init; } = []; +} + +public sealed record WorkflowDiagramEdge +{ + public required string SourceNodeId { get; init; } + public required string TargetNodeId { get; init; } + public string? Label { get; init; } + public IReadOnlyCollection Sections { get; init; } = []; +} + +public sealed record WorkflowDiagramGetRequest +{ + public required string WorkflowName { get; init; } + public string? WorkflowVersion { get; init; } + public string? WorkflowInstanceId { get; init; } + public string? LayoutProvider { get; init; } + public string? LayoutEffort { get; init; } + public int? LayoutOrderingIterations { get; init; } + public int? LayoutPlacementIterations { get; init; } +} + +public sealed record WorkflowDiagramGetResponse +{ + public required string WorkflowName { get; init; } + public required string WorkflowVersion { get; init; } + public required string DisplayName { get; init; } + public string? LayoutProvider { get; init; } + public string? LayoutEffort { get; init; } + public int? LayoutOrderingIterations { get; init; } + public int? LayoutPlacementIterations { get; init; } + public string? WorkflowInstanceId { get; init; } + public string? InstanceStatus { get; init; } + public string? RuntimeProvider { get; init; } + public string? RuntimeStatus { get; init; } + public IReadOnlyCollection Nodes { get; init; } = []; + public IReadOnlyCollection Edges { get; init; } = []; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowFunctionCatalogContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowFunctionCatalogContracts.cs new file mode 100644 index 000000000..ac6013617 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowFunctionCatalogContracts.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +namespace StellaOps.Workflow.Contracts; + +public sealed record WorkflowFunctionArgumentDescriptor +{ + public required string Name { get; init; } + public required string Type { get; init; } + public required bool Required { get; init; } + public bool Variadic { get; init; } + public required string Description { get; init; } +} + +public sealed record WorkflowFunctionReturnDescriptor +{ + public required string Type { get; init; } + public required string Description { get; init; } +} + +public sealed record WorkflowFunctionExample +{ + public required string Expression { get; init; } + public string? Description { get; init; } +} + +public sealed record WorkflowFunctionDescriptor +{ + public required string FunctionName { get; init; } + public required string ModuleName { get; init; } + public required string ModuleVersion { get; init; } + public required string Summary { get; init; } + public bool Deterministic { get; init; } = true; + public IReadOnlyCollection Aliases { get; init; } = []; + public IReadOnlyCollection Arguments { get; init; } = []; + public WorkflowFunctionReturnDescriptor? Return { get; init; } + public IReadOnlyCollection Examples { get; init; } = []; + public string IntroducedInSchemaVersion { get; init; } = WorkflowCanonicalDefinitionSchema.Version1; +} + +public sealed record WorkflowFunctionCatalogGetResponse +{ + public IReadOnlyCollection Functions { get; init; } = []; + public IReadOnlyCollection InstalledModules { get; init; } = []; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowInstanceContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowInstanceContracts.cs new file mode 100644 index 000000000..8967e60d5 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowInstanceContracts.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; + +namespace StellaOps.Workflow.Contracts; + +public sealed record WorkflowInstanceSummary +{ + public required string WorkflowInstanceId { get; init; } + public required string WorkflowName { get; init; } + public required string WorkflowVersion { get; init; } + public WorkflowBusinessReference? BusinessReference { get; init; } + public string Status { get; init; } = "Pending"; + public string? RuntimeProvider { get; init; } + public string? RuntimeInstanceId { get; init; } + public string? RuntimeStatus { get; init; } + public DateTime CreatedOnUtc { get; init; } = DateTime.UtcNow; + public DateTime? CompletedOnUtc { get; init; } + + /// The first open (non-completed) task for this instance, if any. + public WorkflowTaskSummary? ActiveTask { get; init; } + + /// Current workflow state variables. Empty when not requested or unavailable. + public IDictionary WorkflowState { get; init; } = new Dictionary(); +} + +public sealed record WorkflowInstancesGetRequest +{ + public string? WorkflowName { get; init; } + public string? WorkflowVersion { get; init; } + + /// Filter by a single workflow instance ID. + public string? WorkflowInstanceId { get; init; } + + /// Filter by multiple workflow instance IDs. + public IReadOnlyCollection WorkflowInstanceIds { get; init; } = []; + + public string? BusinessReferenceKey { get; init; } + public IDictionary BusinessReferenceParts { get; init; } = new Dictionary(); + public string? Status { get; init; } + + /// When true, populate ActiveTask and WorkflowState on each instance summary. + public bool IncludeDetails { get; init; } +} + +public sealed record WorkflowInstancesGetResponse +{ + public IReadOnlyCollection Instances { get; init; } = []; +} + +public sealed record WorkflowInstanceGetRequest +{ + public required string WorkflowInstanceId { get; init; } + public string? ActorId { get; init; } + public IReadOnlyCollection ActorRoles { get; init; } = []; +} + +public sealed record WorkflowInstanceGetResponse +{ + public required WorkflowInstanceSummary Instance { get; init; } + public IDictionary WorkflowState { get; init; } = new Dictionary(); + public IReadOnlyCollection Tasks { get; init; } = []; + public IReadOnlyCollection TaskEvents { get; init; } = []; + public WorkflowRuntimeStateSummary? RuntimeState { get; init; } +} + +public sealed record WorkflowRuntimeStateSummary +{ + public WorkflowBusinessReference? BusinessReference { get; init; } + public required string RuntimeProvider { get; init; } + public required string RuntimeInstanceId { get; init; } + public required string RuntimeStatus { get; init; } + public IDictionary State { get; init; } = new Dictionary(); + public DateTime CreatedOnUtc { get; init; } = DateTime.UtcNow; + public DateTime? CompletedOnUtc { get; init; } + public DateTime? StaleAfterUtc { get; init; } + public DateTime? PurgeAfterUtc { get; init; } + public DateTime LastUpdatedOnUtc { get; init; } = DateTime.UtcNow; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowOperationalContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowOperationalContracts.cs new file mode 100644 index 000000000..da5000022 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowOperationalContracts.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace StellaOps.Workflow.Contracts; + +public sealed record WorkflowServiceMetadata +{ + public required string ServiceName { get; init; } + public required string DiagramProvider { get; init; } + public required bool SupportsDefinitionInspection { get; init; } + public required bool SupportsInstanceInspection { get; init; } + public bool SupportsCanonicalSchemaInspection { get; init; } + public bool SupportsCanonicalImportValidation { get; init; } +} + +public sealed record WorkflowServiceMetadataGetResponse +{ + public required WorkflowServiceMetadata Metadata { get; init; } +} + +public sealed record WorkflowRetentionRunRequest +{ + public DateTime? ReferenceUtc { get; init; } +} + +public sealed record WorkflowRetentionRunResponse +{ + public required DateTime ExecutedOnUtc { get; init; } + public required int StaleInstancesMarked { get; init; } + public required int StaleTasksMarked { get; init; } + public required int PurgedInstances { get; init; } + public required int PurgedTasks { get; init; } + public required int PurgedTaskEvents { get; init; } + public required int PurgedRuntimeStates { get; init; } +} + +public sealed record WorkflowModuleInfo +{ + public required string ModuleName { get; init; } + public required string Version { get; init; } +} + +public sealed record WorkflowCanonicalValidationIssue +{ + public required string Code { get; init; } + public required string Path { get; init; } + public required string Message { get; init; } +} + +public sealed record WorkflowCanonicalSchemaGetResponse +{ + public required string SchemaVersion { get; init; } + public required string SchemaJson { get; init; } +} + +public sealed record WorkflowCanonicalValidateRequest +{ + public required string CanonicalDefinitionJson { get; init; } +} + +public sealed record WorkflowCanonicalValidateResponse +{ + public required bool IsValid { get; init; } + public string? WorkflowName { get; init; } + public string? WorkflowVersion { get; init; } + public IReadOnlyCollection SchemaIssues { get; init; } = []; + public IReadOnlyCollection SemanticIssues { get; init; } = []; + public IReadOnlyCollection ModuleIssues { get; init; } = []; + public IReadOnlyCollection InstalledModules { get; init; } = []; +} + +public sealed record WorkflowSignalDeadLettersGetRequest +{ + public string? SignalId { get; init; } + public string? WorkflowInstanceId { get; init; } + public string? SignalType { get; init; } + public int MaxMessages { get; init; } = 50; + public bool IncludeRawPayload { get; init; } +} + +public sealed record WorkflowSignalDeadLetterMessage +{ + public string? SignalId { get; init; } + public string? Correlation { get; init; } + public string? WorkflowInstanceId { get; init; } + public string? RuntimeProvider { get; init; } + public string? SignalType { get; init; } + public long? ExpectedVersion { get; init; } + public string? WaitingToken { get; init; } + public DateTime? OccurredAtUtc { get; init; } + public DateTime? DueAtUtc { get; init; } + public DateTime? EnqueuedOnUtc { get; init; } + public int DeliveryCount { get; init; } + public bool IsEnvelopeReadable { get; init; } + public string? ReadError { get; init; } + public IReadOnlyDictionary Payload { get; init; } = new Dictionary(); + public string? RawPayloadBase64 { get; init; } +} + +public sealed record WorkflowSignalDeadLettersGetResponse +{ + public IReadOnlyCollection Messages { get; init; } = []; +} + +public sealed record WorkflowSignalDeadLetterReplayRequest +{ + public required string SignalId { get; init; } +} + +public sealed record WorkflowSignalDeadLetterReplayResponse +{ + public required string SignalId { get; init; } + public bool Replayed { get; init; } + public string? WorkflowInstanceId { get; init; } + public string? SignalType { get; init; } + public bool WasEnvelopeReadable { get; init; } +} + +public sealed record WorkflowSignalPumpStatsSignalType +{ + public required string SignalType { get; init; } + public required long ProcessedCount { get; init; } + public required long FailureCount { get; init; } + public required long DeadLetterCount { get; init; } + public long ConcurrencySkipCount { get; init; } +} + +public sealed record WorkflowSignalPumpLastEvent +{ + public required string ConsumerName { get; init; } + public string? SignalId { get; init; } + public string? WorkflowInstanceId { get; init; } + public string? SignalType { get; init; } + public int DeliveryCount { get; init; } + public DateTime OccurredOnUtc { get; init; } + public long DurationMs { get; init; } + public string? Message { get; init; } +} + +public sealed record WorkflowSignalPumpStats +{ + public required DateTime StartedOnUtc { get; init; } + public DateTime? LastActivityOnUtc { get; init; } + public DateTime? LastSuccessOnUtc { get; init; } + public DateTime? LastFailureOnUtc { get; init; } + public DateTime? LastDeadLetterOnUtc { get; init; } + public required long EmptyPollCount { get; init; } + public required long ProcessedCount { get; init; } + public required long FailureCount { get; init; } + public required long DeadLetterCount { get; init; } + public long ConcurrencySkipCount { get; init; } + public IReadOnlyCollection SignalsByType { get; init; } = []; + public WorkflowSignalPumpLastEvent? LastSuccess { get; init; } + public WorkflowSignalPumpLastEvent? LastFailure { get; init; } + public WorkflowSignalPumpLastEvent? LastDeadLetter { get; init; } +} + +public sealed record WorkflowSignalPumpStatsGetResponse +{ + public required WorkflowSignalPumpStats Stats { get; init; } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowProjectionContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowProjectionContracts.cs new file mode 100644 index 000000000..a8c140e99 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowProjectionContracts.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Text.Json; + +namespace StellaOps.Workflow.Contracts; + +public sealed record WorkflowExecutionSnapshot +{ + public required WorkflowTaskSummary Task { get; init; } + public IReadOnlyDictionary WorkflowState { get; init; } = new Dictionary(); +} + +public sealed record WorkflowInstanceProjectionDetails +{ + public required WorkflowInstanceSummary Instance { get; init; } + public IDictionary WorkflowState { get; init; } = new Dictionary(); + public IReadOnlyCollection Tasks { get; init; } = []; + public IReadOnlyCollection TaskEvents { get; init; } = []; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowSignalContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowSignalContracts.cs new file mode 100644 index 000000000..fe9e367b0 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowSignalContracts.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace StellaOps.Workflow.Contracts; + +public sealed record WorkflowSignalRaiseRequest +{ + public required string WorkflowInstanceId { get; init; } + public string? WaitingToken { get; init; } + public string? SignalName { get; init; } + public IDictionary Payload { get; init; } = new Dictionary(); +} + +public sealed record WorkflowSignalRaiseResponse +{ + public required string WorkflowInstanceId { get; init; } + public required string SignalId { get; init; } + public required bool Queued { get; init; } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowStartContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowStartContracts.cs new file mode 100644 index 000000000..8c4d3716e --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowStartContracts.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace StellaOps.Workflow.Contracts; + +public sealed record StartWorkflowRequest +{ + public required string WorkflowName { get; init; } + public string? WorkflowVersion { get; init; } + public WorkflowBusinessReference? BusinessReference { get; init; } + public IDictionary Payload { get; init; } = new Dictionary(); +} + +public sealed record StartWorkflowResponse +{ + public required string WorkflowInstanceId { get; init; } + public required string WorkflowName { get; init; } + public required string WorkflowVersion { get; init; } + public WorkflowBusinessReference? BusinessReference { get; init; } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowTaskContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowTaskContracts.cs new file mode 100644 index 000000000..0df8f01ac --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowTaskContracts.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; + +namespace StellaOps.Workflow.Contracts; + +public sealed record WorkflowTaskSummary +{ + public required string WorkflowTaskId { get; init; } + public required string WorkflowInstanceId { get; init; } + public required string WorkflowName { get; init; } + public required string WorkflowVersion { get; init; } + public required string TaskName { get; init; } + public required string TaskType { get; init; } + public required string Route { get; init; } + public WorkflowBusinessReference? BusinessReference { get; init; } + public string? Assignee { get; init; } + public string Status { get; init; } = "Pending"; + public IReadOnlyCollection WorkflowRoles { get; init; } = []; + public IReadOnlyCollection TaskRoles { get; init; } = []; + public IReadOnlyCollection RuntimeRoles { get; init; } = []; + public IReadOnlyCollection EffectiveRoles { get; init; } = []; + public IReadOnlyCollection AllowedActions { get; init; } = []; + public IDictionary Payload { get; init; } = new Dictionary(); + public DateTime CreatedOnUtc { get; init; } = DateTime.UtcNow; + public DateTime? CompletedOnUtc { get; init; } + public DateTime? StaleAfterUtc { get; init; } + public DateTime? PurgeAfterUtc { get; init; } + + /// + /// The deadline by which the task should be completed. + /// Computed as CreatedOnUtc + TimeoutSeconds from the task declaration. + /// Null means no deadline — the task runs indefinitely until completed or purged by retention. + /// + public DateTime? DeadlineUtc { get; init; } +} + +public sealed record WorkflowTaskEventSummary +{ + public required string WorkflowTaskId { get; init; } + public string? TaskName { get; init; } + public required string EventType { get; init; } + public string? ActorId { get; init; } + public IDictionary Payload { get; init; } = new Dictionary(); + public DateTime CreatedOnUtc { get; init; } = DateTime.UtcNow; +} + +public sealed record WorkflowTasksGetRequest +{ + public string? WorkflowName { get; init; } + public string? WorkflowVersion { get; init; } + public string? WorkflowInstanceId { get; init; } + public string? BusinessReferenceKey { get; init; } + public IDictionary BusinessReferenceParts { get; init; } = new Dictionary(); + public string? Assignee { get; init; } + public string? Status { get; init; } + public string? ActorId { get; init; } + public IReadOnlyCollection ActorRoles { get; init; } = []; + public IReadOnlyCollection CandidateRoles { get; init; } = []; +} + +public sealed record WorkflowTasksGetResponse +{ + public IReadOnlyCollection Tasks { get; init; } = []; +} + +public sealed record WorkflowTaskGetRequest +{ + public required string WorkflowTaskId { get; init; } + public string? ActorId { get; init; } + public IReadOnlyCollection ActorRoles { get; init; } = []; +} + +public sealed record WorkflowTaskGetResponse +{ + public required WorkflowTaskSummary Task { get; init; } +} + +public sealed record WorkflowTaskCompleteRequest +{ + public required string WorkflowTaskId { get; init; } + public required string ActorId { get; init; } + public IReadOnlyCollection ActorRoles { get; init; } = []; + public IDictionary Payload { get; init; } = new Dictionary(); +} + +public sealed record WorkflowTaskCompleteResponse +{ + public required string WorkflowTaskId { get; init; } + public required bool Completed { get; init; } +} + +public sealed record WorkflowTaskAssignRequest +{ + public required string WorkflowTaskId { get; init; } + public required string ActorId { get; init; } + public string? TargetUserId { get; init; } + public IReadOnlyCollection TargetRoles { get; init; } = []; + public IReadOnlyCollection ActorRoles { get; init; } = []; +} + +public sealed record WorkflowTaskAssignResponse +{ + public required string WorkflowTaskId { get; init; } + public required string? Assignee { get; init; } + public required string Status { get; init; } + public IReadOnlyCollection RuntimeRoles { get; init; } = []; + public IReadOnlyCollection EffectiveRoles { get; init; } = []; +} + +public sealed record WorkflowTaskReleaseRequest +{ + public required string WorkflowTaskId { get; init; } + public required string ActorId { get; init; } + public IReadOnlyCollection ActorRoles { get; init; } = []; +} + +public sealed record WorkflowTaskReleaseResponse +{ + public required string WorkflowTaskId { get; init; } + public required bool Released { get; init; } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowTransportContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowTransportContracts.cs new file mode 100644 index 000000000..1c88aa01c --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowTransportContracts.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; + +namespace StellaOps.Workflow.Contracts; + +public sealed record WorkflowMicroserviceRequest +{ + public required string MicroserviceName { get; init; } + public required string Command { get; init; } + public object? Payload { get; init; } +} + +public sealed record WorkflowMicroserviceResponse +{ + public required bool Succeeded { get; init; } + public object? Payload { get; init; } + public string? Error { get; init; } +} + +public sealed record WorkflowRabbitRequest +{ + public required string Exchange { get; init; } + public required string RoutingKey { get; init; } + public object? Payload { get; init; } +} + +public sealed record WorkflowRabbitResponse +{ + public required bool Succeeded { get; init; } + public object? Payload { get; init; } + public string? Error { get; init; } +} + +public enum WorkflowLegacyRabbitMode +{ + Envelope = 1, + MicroserviceConsumer = 2, +} + +public sealed record WorkflowLegacyRabbitRequest +{ + public required string Command { get; init; } + public required WorkflowLegacyRabbitMode Mode { get; init; } + public object? Payload { get; init; } +} + +public sealed record WorkflowGraphqlRequest +{ + public required string Target { get; init; } + public required string Query { get; init; } + public string? OperationName { get; init; } + public IDictionary Variables { get; init; } = new Dictionary(); +} + +public sealed record WorkflowGraphqlResponse +{ + public required bool Succeeded { get; init; } + public string? JsonPayload { get; init; } + public string? Error { get; init; } +} + +public sealed record WorkflowHttpRequest +{ + public required string Target { get; init; } + public string Method { get; init; } = "POST"; + public required string Path { get; init; } + public object? Payload { get; init; } +} + +public sealed record WorkflowHttpResponse +{ + public required bool Succeeded { get; init; } + public int? StatusCode { get; init; } + public string? JsonPayload { get; init; } + public string? Error { get; init; } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowDataStoreExtensions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowDataStoreExtensions.cs new file mode 100644 index 000000000..0cd2e7ad7 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowDataStoreExtensions.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using MongoDB.Driver; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.DataStore.MongoDB; + +public static class MongoWorkflowDataStoreExtensions +{ + public static IServiceCollection AddWorkflowMongoDataStore( + this IServiceCollection services, IConfiguration configuration) + { + services.AddWorkflowModule("workflow-store.mongo", "1.0.0"); + services.AddSingleton( + new WorkflowBackendRegistrationMarker(WorkflowBackendNames.Mongo)); + + if (!string.Equals(configuration.GetWorkflowBackendProvider(), WorkflowBackendNames.Mongo, StringComparison.OrdinalIgnoreCase)) + { + return services; + } + + var useNativeSignalDriver = string.Equals( + configuration.GetWorkflowSignalDriverProvider(), + WorkflowSignalDriverNames.Native, + StringComparison.OrdinalIgnoreCase); + + services.Configure(configuration.GetSection(WorkflowStoreMongoOptions.SectionName)); + services.AddSingleton(_ => + { + var connectionString = configuration.GetConnectionString( + configuration[$"{WorkflowStoreMongoOptions.SectionName}:ConnectionStringName"] + ?? "WorkflowMongo") + ?? throw new InvalidOperationException( + "MongoDB workflow backend requires a configured connection string."); + return new MongoClient(connectionString); + }); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + if (useNativeSignalDriver) + { + services.AddScoped(); + } + + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Scoped()); + + if (useNativeSignalDriver) + { + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + } + + services.AddSingleton( + new WorkflowSignalDriverRegistrationMarker(WorkflowSignalDriverNames.Native)); + + return services; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowDatabase.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowDatabase.cs new file mode 100644 index 000000000..916b4ac41 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowDatabase.cs @@ -0,0 +1,280 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +using MongoDB.Bson; +using MongoDB.Driver; + +namespace StellaOps.Workflow.DataStore.MongoDB; + +public sealed class MongoWorkflowDatabase( + IMongoClient mongoClient, + IConfiguration configuration, + IOptions options, + MongoWorkflowMutationSessionAccessor sessionAccessor, + IWorkflowMutationScopeAccessor scopeAccessor) +{ + private static readonly ConcurrentDictionary InitializationLocks = new(StringComparer.Ordinal); + private readonly WorkflowStoreMongoOptions mongo = options.Value; + + internal WorkflowStoreMongoOptions Options => mongo; + + internal IMongoDatabase Database => mongoClient.GetDatabase(mongo.DatabaseName); + + internal IMongoClient Client => mongoClient; + + internal IConfiguration Configuration => configuration; + + internal IClientSessionHandle? CurrentSession => sessionAccessor.Current; + + internal IMongoCollection GetCollection(string collectionName) + { + return Database.GetCollection(collectionName); + } + + internal async Task EnsureInitializedAsync(CancellationToken cancellationToken = default) + { + if (await CollectionExistsAsync(mongo.InstancesCollectionName, cancellationToken)) + { + return; + } + + var lockKey = $"{mongo.DatabaseName}:{mongo.ConnectionStringName}"; + var gate = InitializationLocks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken); + try + { + if (await CollectionExistsAsync(mongo.InstancesCollectionName, cancellationToken)) + { + return; + } + + foreach (var collectionName in GetRequiredCollectionNames()) + { + if (await CollectionExistsAsync(collectionName, cancellationToken)) + { + continue; + } + + try + { + await Database.CreateCollectionAsync(collectionName, cancellationToken: cancellationToken); + } + catch (MongoCommandException exception) + when (exception.Message.Contains("NamespaceExists", StringComparison.OrdinalIgnoreCase) + || exception.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)) + { + } + } + } + finally + { + gate.Release(); + } + } + + internal async Task BeginMutationAsync(CancellationToken cancellationToken = default) + { + await EnsureInitializedAsync(cancellationToken); + + if (scopeAccessor.Current is not null) + { + return new DelegatingMutationScope(scopeAccessor.Current); + } + + if (sessionAccessor.Current is not null) + { + var currentScope = new MongoWorkflowMutationScope(sessionAccessor, scopeAccessor, session: null, ownsSession: true); + scopeAccessor.Current = currentScope; + return currentScope; + } + + var session = await mongoClient.StartSessionAsync(cancellationToken: cancellationToken); + session.StartTransaction(); + sessionAccessor.Current = session; + var scope = new MongoWorkflowMutationScope(sessionAccessor, scopeAccessor, session, ownsSession: true); + scopeAccessor.Current = scope; + return scope; + } + + internal async Task OpenOwnedSessionAsync( + bool startTransaction, + CancellationToken cancellationToken = default) + { + await EnsureInitializedAsync(cancellationToken); + + if (sessionAccessor.Current is not null) + { + return new MongoWorkflowOwnedSession(sessionAccessor.Current, ownsSession: false, startedTransaction: false); + } + + var session = await mongoClient.StartSessionAsync(cancellationToken: cancellationToken); + if (startTransaction) + { + session.StartTransaction(); + } + + return new MongoWorkflowOwnedSession(session, ownsSession: true, startedTransaction: startTransaction); + } + + private IEnumerable GetRequiredCollectionNames() + { + return + [ + mongo.RuntimeStatesCollectionName, + mongo.HostedJobLocksCollectionName, + mongo.InstancesCollectionName, + mongo.TasksCollectionName, + mongo.TaskEventsCollectionName, + mongo.SignalQueueCollectionName, + mongo.DeadLetterCollectionName, + mongo.WakeOutboxCollectionName, + ]; + } + + private async Task CollectionExistsAsync(string collectionName, CancellationToken cancellationToken) + { + using var cursor = await Database.ListCollectionNamesAsync( + new ListCollectionNamesOptions + { + Filter = new BsonDocument("name", collectionName), + }, + cancellationToken); + return await cursor.AnyAsync(cancellationToken); + } +} + +public sealed class MongoWorkflowMutationSessionAccessor +{ + internal IClientSessionHandle? Current { get; set; } +} + +internal sealed class MongoWorkflowOwnedSession( + IClientSessionHandle session, + bool ownsSession, + bool startedTransaction) : IAsyncDisposable +{ + private bool isCommitted; + + public IClientSessionHandle Session => session; + + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + if (!ownsSession || !startedTransaction || isCommitted) + { + return; + } + + await session.CommitTransactionAsync(cancellationToken); + isCommitted = true; + } + + public async ValueTask DisposeAsync() + { + if (ownsSession && startedTransaction && !isCommitted) + { + try + { + await session.AbortTransactionAsync(CancellationToken.None); + } + catch + { + } + } + + if (ownsSession) + { + session.Dispose(); + } + } +} + +internal sealed class MongoWorkflowMutationScope( + MongoWorkflowMutationSessionAccessor sessionAccessor, + IWorkflowMutationScopeAccessor scopeAccessor, + IClientSessionHandle? session, + bool ownsSession) : IWorkflowMutationScope +{ + private readonly List> postCommitActions = []; + private bool isCommitted; + + public void RegisterPostCommitAction(Func action) + { + ArgumentNullException.ThrowIfNull(action); + postCommitActions.Add(action); + } + + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + if (!ownsSession || isCommitted) + { + return; + } + + if (session is not null) + { + await session.CommitTransactionAsync(cancellationToken); + } + + foreach (var action in postCommitActions) + { + await action(cancellationToken); + } + + isCommitted = true; + } + + public async ValueTask DisposeAsync() + { + try + { + if (ownsSession && session is not null && !isCommitted) + { + try + { + await session.AbortTransactionAsync(CancellationToken.None); + } + catch + { + } + } + } + finally + { + if (ownsSession) + { + sessionAccessor.Current = null; + if (scopeAccessor.Current == this) + { + scopeAccessor.Current = null; + } + session?.Dispose(); + } + } + } +} + +internal sealed class DelegatingMutationScope(IWorkflowMutationScope inner) : IWorkflowMutationScope +{ + public void RegisterPostCommitAction(Func action) + { + inner.RegisterPostCommitAction(action); + } + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowHostedJobLockService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowHostedJobLockService.cs new file mode 100644 index 000000000..6249c04b5 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowHostedJobLockService.cs @@ -0,0 +1,109 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; + +namespace StellaOps.Workflow.DataStore.MongoDB; + +public sealed class MongoWorkflowHostedJobLockService( + MongoWorkflowDatabase database) : IWorkflowHostedJobLockService +{ + private readonly IMongoCollection collection = + database.GetCollection(database.Options.HostedJobLocksCollectionName); + + public async Task TryAcquireAsync( + string lockName, + string lockOwner, + DateTime acquiredOnUtc, + TimeSpan lease, + CancellationToken cancellationToken = default) + { + var expiresOnUtc = acquiredOnUtc.Add(lease); + var filter = Builders.Filter.And( + Builders.Filter.Eq(x => x.LockName, lockName), + Builders.Filter.Or( + Builders.Filter.Lte(x => x.ExpiresOnUtc, acquiredOnUtc), + Builders.Filter.Eq(x => x.LockOwner, lockOwner))); + + var update = Builders.Update + .Set(x => x.LockOwner, lockOwner) + .Set(x => x.AcquiredOnUtc, acquiredOnUtc) + .Set(x => x.ExpiresOnUtc, expiresOnUtc); + + var session = database.CurrentSession; + UpdateResult updated; + if (session is null) + { + updated = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken); + } + else + { + updated = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken); + } + + if (updated.ModifiedCount > 0) + { + return true; + } + + try + { + var document = new WorkflowHostedJobLockDocument + { + LockName = lockName, + LockOwner = lockOwner, + AcquiredOnUtc = acquiredOnUtc, + ExpiresOnUtc = expiresOnUtc, + }; + + if (session is null) + { + await collection.InsertOneAsync(document, cancellationToken: cancellationToken); + } + else + { + await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken); + } + + return true; + } + catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + return false; + } + } + + public async Task ReleaseAsync( + string lockName, + string lockOwner, + CancellationToken cancellationToken = default) + { + var session = database.CurrentSession; + if (session is null) + { + await collection.DeleteOneAsync( + x => x.LockName == lockName && x.LockOwner == lockOwner, + cancellationToken: cancellationToken); + } + else + { + await collection.DeleteOneAsync( + session, + x => x.LockName == lockName && x.LockOwner == lockOwner, + cancellationToken: cancellationToken); + } + } + + private sealed class WorkflowHostedJobLockDocument + { + [BsonId] + public string LockName { get; set; } = string.Empty; + public string LockOwner { get; set; } = string.Empty; + public DateTime AcquiredOnUtc { get; set; } + public DateTime ExpiresOnUtc { get; set; } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowJson.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowJson.cs new file mode 100644 index 000000000..6274b405a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowJson.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +using Microsoft.Extensions.Configuration; + +namespace StellaOps.Workflow.DataStore.MongoDB; + +internal static class MongoWorkflowJson +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + public static string Serialize(object value) + { + return JsonSerializer.Serialize(value, SerializerOptions); + } + + public static string? SerializeBusinessReference(WorkflowBusinessReference? businessReference) + { + var normalizedReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + return normalizedReference is null ? null : Serialize(normalizedReference); + } + + public static WorkflowBusinessReference? DeserializeBusinessReference(string? key, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference + { + Key = key, + }); + } + + var businessReference = JsonSerializer.Deserialize(value, SerializerOptions); + if (businessReference is null) + { + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference + { + Key = key, + }); + } + + if (string.IsNullOrWhiteSpace(businessReference.Key) && !string.IsNullOrWhiteSpace(key)) + { + businessReference = businessReference with { Key = key }; + } + + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + } + + public static IReadOnlyDictionary DeserializeJsonDictionary(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return JsonSerializer.Deserialize>(value, SerializerOptions) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public static IDictionary DeserializeObjectDictionary(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return JsonSerializer.Deserialize>(value, SerializerOptions) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public static IDictionary DeserializePublicTaskPayload(string value) + { + var payload = DeserializeObjectDictionary(value); + payload.Remove(WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey); + return payload; + } + + public static string? TryReadProjectionWorkflowInstanceId(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var payload = DeserializeJsonDictionary(value); + if (!payload.TryGetValue(WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey, out var element) + || element.ValueKind != JsonValueKind.String) + { + return null; + } + + return element.GetString(); + } +} + +internal static class MongoWorkflowRoleResolver +{ + public static IReadOnlyCollection NormalizeRoles(IReadOnlyCollection roles) + { + return roles + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public static IReadOnlyCollection ResolveEffectiveRoles( + IReadOnlyCollection workflowRoles, + IReadOnlyCollection taskRoles, + IReadOnlyCollection? runtimeRoles = null) + { + if (runtimeRoles is { Count: > 0 }) + { + return NormalizeRoles(runtimeRoles); + } + + if (taskRoles.Count > 0) + { + return NormalizeRoles(taskRoles); + } + + return NormalizeRoles(workflowRoles); + } +} + +internal sealed class MongoWorkflowRetentionSettings +{ + public int OpenStaleAfterDays { get; init; } = 30; + public int CompletedPurgeAfterDays { get; init; } = 180; + + public static MongoWorkflowRetentionSettings FromConfiguration(IConfiguration configuration) + { + var section = configuration.GetSection("WorkflowRetention"); + return new MongoWorkflowRetentionSettings + { + OpenStaleAfterDays = ReadInt(section["OpenStaleAfterDays"], 30), + CompletedPurgeAfterDays = ReadInt(section["CompletedPurgeAfterDays"], 180), + }; + } + + private static int ReadInt(string? value, int fallback) + { + return int.TryParse(value, out var parsed) ? parsed : fallback; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowMutationCoordinator.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowMutationCoordinator.cs new file mode 100644 index 000000000..d5127fd19 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowMutationCoordinator.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.DataStore.MongoDB; + +public sealed class MongoWorkflowMutationCoordinator( + MongoWorkflowDatabase database) : IWorkflowMutationCoordinator +{ + public Task BeginAsync(CancellationToken cancellationToken = default) + { + return database.BeginMutationAsync(cancellationToken); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowProjectionRetentionStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowProjectionRetentionStore.cs new file mode 100644 index 000000000..9fd3dfc3d --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowProjectionRetentionStore.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; + +namespace StellaOps.Workflow.DataStore.MongoDB; + +public sealed class MongoWorkflowProjectionRetentionStore( + MongoWorkflowDatabase database) : IWorkflowProjectionRetentionStore +{ + private const string OpenInstanceStatus = "Open"; + private const string StaleInstanceStatus = "Stale"; + private const string OpenTaskStatus = "Open"; + private const string AssignedTaskStatus = "Assigned"; + private const string StaleTaskStatus = "Stale"; + + private readonly IMongoCollection instances = + database.GetCollection(database.Options.InstancesCollectionName); + private readonly IMongoCollection tasks = + database.GetCollection(database.Options.TasksCollectionName); + private readonly IMongoCollection taskEvents = + database.GetCollection(database.Options.TaskEventsCollectionName); + + public async Task RunAsync( + DateTime nowUtc, + CancellationToken cancellationToken = default) + { + await using var ownedSession = await database.OpenOwnedSessionAsync( + startTransaction: database.CurrentSession is null, + cancellationToken); + var session = ownedSession.Session; + + var staleInstanceIds = await GetStaleInstanceIdsAsync(session, nowUtc, cancellationToken); + var staleInstancesMarked = await MarkStaleInstancesAsync(session, staleInstanceIds, cancellationToken); + var staleTaskIds = await GetStaleTaskIdsAsync(session, nowUtc, cancellationToken); + var staleTasksMarked = await MarkStaleTasksAsync(session, staleTaskIds, cancellationToken); + var expiredInstanceIds = await GetExpiredInstanceIdsAsync(session, nowUtc, cancellationToken); + var taskIdsToPurge = await GetTaskIdsToPurgeAsync(session, expiredInstanceIds, nowUtc, cancellationToken); + var purgedTaskEvents = await PurgeTaskEventsAsync(session, taskIdsToPurge, cancellationToken); + var purgedTasks = await PurgeTasksAsync(session, taskIdsToPurge, cancellationToken); + var purgedInstances = await PurgeInstancesAsync(session, expiredInstanceIds, cancellationToken); + + await ownedSession.CommitAsync(cancellationToken); + + return new WorkflowProjectionRetentionBatch + { + StaleWorkflowInstanceIds = staleInstanceIds, + StaleInstancesMarked = staleInstancesMarked, + StaleTasksMarked = staleTasksMarked, + PurgedWorkflowInstanceIds = expiredInstanceIds, + PurgedInstances = purgedInstances, + PurgedTasks = purgedTasks, + PurgedTaskEvents = purgedTaskEvents, + }; + } + + private async Task> GetStaleInstanceIdsAsync( + IClientSessionHandle session, + DateTime nowUtc, + CancellationToken cancellationToken) + { + return await instances.Find( + session, + x => x.Status == OpenInstanceStatus && x.StaleAfterUtc != null && x.StaleAfterUtc <= nowUtc) + .Project(x => x.WorkflowInstanceId) + .ToListAsync(cancellationToken); + } + + private async Task MarkStaleInstancesAsync( + IClientSessionHandle session, + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken) + { + if (workflowInstanceIds.Count == 0) + { + return 0; + } + + var result = await instances.UpdateManyAsync( + session, + Builders.Filter.In(x => x.WorkflowInstanceId, workflowInstanceIds), + Builders.Update.Set(x => x.Status, StaleInstanceStatus), + cancellationToken: cancellationToken); + return (int)result.ModifiedCount; + } + + private async Task> GetStaleTaskIdsAsync( + IClientSessionHandle session, + DateTime nowUtc, + CancellationToken cancellationToken) + { + return await tasks.Find( + session, + x => (x.Status == OpenTaskStatus || x.Status == AssignedTaskStatus) + && x.StaleAfterUtc != null + && x.StaleAfterUtc <= nowUtc) + .Project(x => x.WorkflowTaskId) + .ToListAsync(cancellationToken); + } + + private async Task MarkStaleTasksAsync( + IClientSessionHandle session, + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) + { + if (taskIds.Count == 0) + { + return 0; + } + + var result = await tasks.UpdateManyAsync( + session, + Builders.Filter.In(x => x.WorkflowTaskId, taskIds), + Builders.Update.Set(x => x.Status, StaleTaskStatus), + cancellationToken: cancellationToken); + return (int)result.ModifiedCount; + } + + private async Task> GetExpiredInstanceIdsAsync( + IClientSessionHandle session, + DateTime nowUtc, + CancellationToken cancellationToken) + { + return await instances.Find( + session, + x => x.PurgeAfterUtc != null && x.PurgeAfterUtc <= nowUtc) + .Project(x => x.WorkflowInstanceId) + .ToListAsync(cancellationToken); + } + + private async Task> GetTaskIdsToPurgeAsync( + IClientSessionHandle session, + IReadOnlyCollection expiredInstanceIds, + DateTime nowUtc, + CancellationToken cancellationToken) + { + var filter = Builders.Filter.Or( + Builders.Filter.And( + Builders.Filter.In(x => x.WorkflowInstanceId, expiredInstanceIds), + Builders.Filter.Empty), + Builders.Filter.And( + Builders.Filter.Ne(x => x.PurgeAfterUtc, null), + Builders.Filter.Lte(x => x.PurgeAfterUtc, nowUtc))); + + return await tasks.Find(session, filter) + .Project(x => x.WorkflowTaskId) + .ToListAsync(cancellationToken); + } + + private async Task PurgeTaskEventsAsync( + IClientSessionHandle session, + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) + { + if (taskIds.Count == 0) + { + return 0; + } + + var result = await taskEvents.DeleteManyAsync( + session, + Builders.Filter.In(x => x.WorkflowTaskId, taskIds), + options: null, + cancellationToken: cancellationToken); + return (int)result.DeletedCount; + } + + private async Task PurgeTasksAsync( + IClientSessionHandle session, + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) + { + if (taskIds.Count == 0) + { + return 0; + } + + var result = await tasks.DeleteManyAsync( + session, + Builders.Filter.In(x => x.WorkflowTaskId, taskIds), + options: null, + cancellationToken: cancellationToken); + return (int)result.DeletedCount; + } + + private async Task PurgeInstancesAsync( + IClientSessionHandle session, + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken) + { + if (workflowInstanceIds.Count == 0) + { + return 0; + } + + var result = await instances.DeleteManyAsync( + session, + Builders.Filter.In(x => x.WorkflowInstanceId, workflowInstanceIds), + options: null, + cancellationToken: cancellationToken); + return (int)result.DeletedCount; + } + + private sealed class WorkflowInstanceRetentionDocument + { + [BsonId] + public string WorkflowInstanceId { get; set; } = string.Empty; + public string Status { get; set; } = OpenInstanceStatus; + public DateTime? StaleAfterUtc { get; set; } + public DateTime? PurgeAfterUtc { get; set; } + } + + private sealed class WorkflowTaskRetentionDocument + { + [BsonId] + public string WorkflowTaskId { get; set; } = string.Empty; + public string WorkflowInstanceId { get; set; } = string.Empty; + public string Status { get; set; } = OpenTaskStatus; + public DateTime? StaleAfterUtc { get; set; } + public DateTime? PurgeAfterUtc { get; set; } + } + + private sealed class WorkflowTaskEventRetentionDocument + { + [BsonId] + public string WorkflowTaskEventId { get; set; } = string.Empty; + public string WorkflowTaskId { get; set; } = string.Empty; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowProjectionStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowProjectionStore.cs new file mode 100644 index 000000000..3e6456bd0 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowProjectionStore.cs @@ -0,0 +1,1031 @@ +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 Microsoft.Extensions.Configuration; + +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; + +namespace StellaOps.Workflow.DataStore.MongoDB; + +public sealed class MongoWorkflowProjectionStore( + MongoWorkflowDatabase database, + IConfiguration configuration) : IWorkflowProjectionStore +{ + private readonly MongoWorkflowRetentionSettings retention = MongoWorkflowRetentionSettings.FromConfiguration(configuration); + private readonly IMongoCollection instances = + database.GetCollection(database.Options.InstancesCollectionName); + private readonly IMongoCollection tasks = + database.GetCollection(database.Options.TasksCollectionName); + private readonly IMongoCollection taskEvents = + database.GetCollection(database.Options.TaskEventsCollectionName); + + public Task CreateWorkflowAsync( + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + WorkflowStartExecutionPlan executionPlan, + CancellationToken cancellationToken = default) + { + return WithWriteScopeAsync(async session => + { + var now = DateTime.UtcNow; + var normalizedBusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + var workflowRoles = MongoWorkflowRoleResolver.NormalizeRoles(definition.WorkflowRoles); + var workflowInstanceId = $"wf-{Guid.NewGuid():N}"; + + await InsertInstanceAsync( + session, + new WorkflowInstanceDocument + { + WorkflowInstanceId = workflowInstanceId, + WorkflowName = definition.WorkflowName, + WorkflowVersion = definition.WorkflowVersion, + BusinessReferenceKey = normalizedBusinessReference?.Key, + BusinessReferenceJson = MongoWorkflowJson.SerializeBusinessReference(normalizedBusinessReference), + Status = executionPlan.InstanceStatus, + StateJson = MongoWorkflowJson.Serialize(executionPlan.WorkflowState), + CreatedOnUtc = now, + CompletedOnUtc = ResolveCompletedOnUtc(executionPlan.InstanceStatus, now), + StaleAfterUtc = ResolveOpenStaleAfterUtc(executionPlan.InstanceStatus, now), + PurgeAfterUtc = ResolvePurgeAfterUtc(executionPlan.InstanceStatus, now), + }, + cancellationToken); + + await UpsertProjectionWorkflowInstancesAsync( + session, + definition.WorkflowName, + definition.WorkflowVersion, + normalizedBusinessReference, + executionPlan.WorkflowState, + executionPlan.Tasks, + now, + cancellationToken); + + foreach (var taskPlan in executionPlan.Tasks) + { + await InsertTaskPlanAsync( + session, + definition.WorkflowName, + definition.WorkflowVersion, + workflowInstanceId, + normalizedBusinessReference, + workflowRoles, + taskPlan, + now, + cancellationToken); + } + + return new StartWorkflowResponse + { + WorkflowInstanceId = workflowInstanceId, + WorkflowName = definition.WorkflowName, + WorkflowVersion = definition.WorkflowVersion, + BusinessReference = normalizedBusinessReference, + }; + }, cancellationToken); + } + + public async Task> GetTasksAsync( + WorkflowTasksGetRequest request, + CancellationToken cancellationToken = default) + { + var filter = FilterDefinition.Empty; + if (!string.IsNullOrWhiteSpace(request.WorkflowName)) + { + filter &= Builders.Filter.Eq(x => x.WorkflowName, request.WorkflowName); + } + + if (!string.IsNullOrWhiteSpace(request.WorkflowVersion)) + { + filter &= Builders.Filter.Eq(x => x.WorkflowVersion, request.WorkflowVersion); + } + + if (!string.IsNullOrWhiteSpace(request.WorkflowInstanceId)) + { + filter &= Builders.Filter.Eq(x => x.WorkflowInstanceId, request.WorkflowInstanceId); + } + + if (!string.IsNullOrWhiteSpace(request.BusinessReferenceKey)) + { + filter &= Builders.Filter.Eq(x => x.BusinessReferenceKey, request.BusinessReferenceKey); + } + + if (!string.IsNullOrWhiteSpace(request.Assignee)) + { + filter &= Builders.Filter.Eq(x => x.Assignee, request.Assignee); + } + + if (!string.IsNullOrWhiteSpace(request.Status)) + { + filter &= Builders.Filter.Eq(x => x.Status, request.Status); + } + + var taskDocuments = await FindTasksAsync( + filter, + Builders.Sort.Ascending(x => x.CreatedOnUtc), + cancellationToken); + var summaries = taskDocuments + .Select(MapTaskSummary) + .Where(x => x.BusinessReference.MatchesBusinessReferenceFilter(request.BusinessReferenceKey, request.BusinessReferenceParts)) + .ToArray(); + + if (request.CandidateRoles.Count == 0) + { + return summaries; + } + + return summaries + .Where(x => x.EffectiveRoles.Intersect(request.CandidateRoles, StringComparer.OrdinalIgnoreCase).Any()) + .ToArray(); + } + + public async Task GetTaskAsync( + string workflowTaskId, + CancellationToken cancellationToken = default) + { + var task = await GetTaskDocumentAsync(workflowTaskId, cancellationToken); + return task is null ? null : MapTaskSummary(task); + } + + public async Task GetExecutionSnapshotAsync( + string workflowTaskId, + CancellationToken cancellationToken = default) + { + var task = await GetTaskDocumentAsync(workflowTaskId, cancellationToken); + if (task is null) + { + return null; + } + + var instance = await GetInstanceDocumentAsync(task.WorkflowInstanceId, cancellationToken) + ?? throw new InvalidOperationException($"Workflow instance '{task.WorkflowInstanceId}' was not found."); + + return new WorkflowExecutionSnapshot + { + Task = MapTaskSummary(task), + WorkflowState = MongoWorkflowJson.DeserializeJsonDictionary(instance.StateJson), + }; + } + + public Task AssignTaskAsync( + string workflowTaskId, + string actorId, + string assignee, + CancellationToken cancellationToken = default) + { + return WithWriteScopeAsync(async session => + { + var now = DateTime.UtcNow; + var task = await GetRequiredTaskAsync(session, workflowTaskId, cancellationToken); + task.Assignee = assignee; + task.Status = ProjectionTaskStatuses.Assigned; + task.StaleAfterUtc = now.AddDays(retention.OpenStaleAfterDays); + + await ReplaceTaskAsync(session, task, cancellationToken); + await InsertTaskEventAsync( + session, + new WorkflowTaskEventDocument + { + WorkflowTaskEventId = $"wfte-{Guid.NewGuid():N}", + WorkflowTaskId = workflowTaskId, + EventType = ProjectionTaskEventTypes.Assigned, + ActorId = actorId, + PayloadJson = MongoWorkflowJson.Serialize(new { Assignee = assignee }), + CreatedOnUtc = now, + }, + cancellationToken); + + return MapTaskSummary(task); + }, cancellationToken); + } + + public Task AssignTaskRolesAsync( + string workflowTaskId, + string actorId, + IReadOnlyCollection targetRoles, + CancellationToken cancellationToken = default) + { + return WithWriteScopeAsync(async session => + { + var now = DateTime.UtcNow; + var task = await GetRequiredTaskAsync(session, workflowTaskId, cancellationToken); + var workflowRoles = DeserializeStringArray(task.WorkflowRolesJson); + var taskRoles = DeserializeStringArray(task.TaskRolesJson); + var runtimeRoles = MongoWorkflowRoleResolver.NormalizeRoles(targetRoles); + var effectiveRoles = MongoWorkflowRoleResolver.ResolveEffectiveRoles(workflowRoles, taskRoles, runtimeRoles); + + task.Assignee = null; + task.Status = ProjectionTaskStatuses.Open; + task.RuntimeRolesJson = MongoWorkflowJson.Serialize(runtimeRoles); + task.EffectiveRolesJson = MongoWorkflowJson.Serialize(effectiveRoles); + task.StaleAfterUtc = now.AddDays(retention.OpenStaleAfterDays); + + await ReplaceTaskAsync(session, task, cancellationToken); + await InsertTaskEventAsync( + session, + new WorkflowTaskEventDocument + { + WorkflowTaskEventId = $"wfte-{Guid.NewGuid():N}", + WorkflowTaskId = workflowTaskId, + EventType = ProjectionTaskEventTypes.Reassigned, + ActorId = actorId, + PayloadJson = MongoWorkflowJson.Serialize(new + { + RuntimeRoles = runtimeRoles, + EffectiveRoles = effectiveRoles, + }), + CreatedOnUtc = now, + }, + cancellationToken); + + return MapTaskSummary(task); + }, cancellationToken); + } + + public Task ReleaseTaskAsync( + string workflowTaskId, + string actorId, + CancellationToken cancellationToken = default) + { + return WithWriteScopeAsync(async session => + { + var now = DateTime.UtcNow; + var task = await GetRequiredTaskAsync(session, workflowTaskId, cancellationToken); + task.Assignee = null; + task.Status = ProjectionTaskStatuses.Open; + task.StaleAfterUtc = now.AddDays(retention.OpenStaleAfterDays); + + await ReplaceTaskAsync(session, task, cancellationToken); + await InsertTaskEventAsync( + session, + new WorkflowTaskEventDocument + { + WorkflowTaskEventId = $"wfte-{Guid.NewGuid():N}", + WorkflowTaskId = workflowTaskId, + EventType = ProjectionTaskEventTypes.Released, + ActorId = actorId, + PayloadJson = MongoWorkflowJson.Serialize(new { Released = true }), + CreatedOnUtc = now, + }, + cancellationToken); + + return MapTaskSummary(task); + }, cancellationToken); + } + + public Task ApplyTaskCompletionAsync( + string workflowTaskId, + string actorId, + IDictionary payload, + WorkflowTaskCompletionPlan completionPlan, + WorkflowBusinessReference? businessReference, + CancellationToken cancellationToken = default) + { + return WithWriteScopeAsync(async session => + { + var now = DateTime.UtcNow; + var task = await GetRequiredTaskAsync(session, workflowTaskId, cancellationToken); + var instance = await GetRequiredInstanceAsync(session, task.WorkflowInstanceId, cancellationToken); + var updatedBusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference( + businessReference ?? MongoWorkflowJson.DeserializeBusinessReference(task.BusinessReferenceKey, task.BusinessReferenceJson)); + var serializedBusinessReference = MongoWorkflowJson.SerializeBusinessReference(updatedBusinessReference); + + task.Assignee ??= actorId; + task.Status = ProjectionTaskStatuses.Completed; + task.CompletedOnUtc = now; + task.StaleAfterUtc = null; + task.PurgeAfterUtc = now.AddDays(retention.CompletedPurgeAfterDays); + task.BusinessReferenceKey = updatedBusinessReference?.Key; + task.BusinessReferenceJson = serializedBusinessReference; + await ReplaceTaskAsync(session, task, cancellationToken); + + instance.Status = completionPlan.InstanceStatus; + instance.StateJson = MongoWorkflowJson.Serialize(completionPlan.WorkflowState); + instance.BusinessReferenceKey = updatedBusinessReference?.Key; + instance.BusinessReferenceJson = serializedBusinessReference; + instance.CompletedOnUtc = ResolveCompletedOnUtc(completionPlan.InstanceStatus, now); + instance.StaleAfterUtc = ResolveOpenStaleAfterUtc(completionPlan.InstanceStatus, now); + instance.PurgeAfterUtc = ResolvePurgeAfterUtc(completionPlan.InstanceStatus, now); + await ReplaceInstanceAsync(session, instance, cancellationToken); + + await UpdateTasksBusinessReferenceAsync(session, task.WorkflowInstanceId, updatedBusinessReference, workflowTaskId, cancellationToken); + + await InsertTaskEventAsync( + session, + new WorkflowTaskEventDocument + { + WorkflowTaskEventId = $"wfte-{Guid.NewGuid():N}", + WorkflowTaskId = workflowTaskId, + EventType = ProjectionTaskEventTypes.Completed, + ActorId = actorId, + PayloadJson = MongoWorkflowJson.Serialize(payload), + CreatedOnUtc = now, + }, + cancellationToken); + + var completedProjectionWorkflowInstanceId = MongoWorkflowJson.TryReadProjectionWorkflowInstanceId(task.PayloadJson); + if (completionPlan.NextTasks.Count > 0) + { + var workflowRoles = DeserializeStringArray(task.WorkflowRolesJson); + foreach (var nextTask in completionPlan.NextTasks) + { + await InsertTaskPlanAsync( + session, + task.WorkflowName, + task.WorkflowVersion, + task.WorkflowInstanceId, + updatedBusinessReference, + workflowRoles, + nextTask, + now, + cancellationToken); + } + } + + await UpsertProjectionWorkflowInstancesAsync( + session, + instance.WorkflowName, + instance.WorkflowVersion, + updatedBusinessReference, + completionPlan.WorkflowState, + completionPlan.NextTasks, + now, + cancellationToken); + + if (!string.IsNullOrWhiteSpace(completedProjectionWorkflowInstanceId) + && !completionPlan.NextTasks.Any(x => + string.Equals( + TryReadProjectionWorkflowInstanceId(x.Payload), + completedProjectionWorkflowInstanceId, + StringComparison.OrdinalIgnoreCase))) + { + await CompleteProjectionWorkflowInstanceAsync( + session, + completedProjectionWorkflowInstanceId, + task.WorkflowName, + task.WorkflowVersion, + updatedBusinessReference, + completionPlan.WorkflowState, + now, + cancellationToken); + } + + return MapTaskSummary(task); + }, cancellationToken); + } + + public Task ApplyRuntimeProgressAsync( + string workflowInstanceId, + WorkflowTaskCompletionPlan progressPlan, + WorkflowBusinessReference? businessReference, + CancellationToken cancellationToken = default) + { + return WithWriteScopeAsync(async session => + { + var now = DateTime.UtcNow; + var instance = await GetRequiredInstanceAsync(session, workflowInstanceId, cancellationToken); + var updatedBusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference( + businessReference ?? MongoWorkflowJson.DeserializeBusinessReference(instance.BusinessReferenceKey, instance.BusinessReferenceJson)); + var serializedBusinessReference = MongoWorkflowJson.SerializeBusinessReference(updatedBusinessReference); + + instance.Status = progressPlan.InstanceStatus; + instance.StateJson = MongoWorkflowJson.Serialize(progressPlan.WorkflowState); + instance.BusinessReferenceKey = updatedBusinessReference?.Key; + instance.BusinessReferenceJson = serializedBusinessReference; + instance.CompletedOnUtc = ResolveCompletedOnUtc(progressPlan.InstanceStatus, now); + instance.StaleAfterUtc = ResolveOpenStaleAfterUtc(progressPlan.InstanceStatus, now); + instance.PurgeAfterUtc = ResolvePurgeAfterUtc(progressPlan.InstanceStatus, now); + await ReplaceInstanceAsync(session, instance, cancellationToken); + + await UpdateTasksBusinessReferenceAsync(session, workflowInstanceId, updatedBusinessReference, excludedWorkflowTaskId: null, cancellationToken); + await UpsertProjectionWorkflowInstancesAsync( + session, + instance.WorkflowName, + instance.WorkflowVersion, + updatedBusinessReference, + progressPlan.WorkflowState, + progressPlan.NextTasks, + now, + cancellationToken); + + if (progressPlan.NextTasks.Count > 0) + { + var existingTasks = await FindTasksAsync( + Builders.Filter.Eq(x => x.WorkflowInstanceId, workflowInstanceId), + null, + cancellationToken); + var workflowRoles = existingTasks.Count > 0 + ? DeserializeStringArray(existingTasks[0].WorkflowRolesJson) + : []; + foreach (var nextTask in progressPlan.NextTasks) + { + await InsertTaskPlanAsync( + session, + instance.WorkflowName, + instance.WorkflowVersion, + workflowInstanceId, + updatedBusinessReference, + workflowRoles, + nextTask, + now, + cancellationToken); + } + } + }, cancellationToken); + } + + public async Task> GetInstancesAsync( + WorkflowInstancesGetRequest request, + CancellationToken cancellationToken = default) + { + var filter = FilterDefinition.Empty; + if (!string.IsNullOrWhiteSpace(request.WorkflowName)) + { + filter &= Builders.Filter.Eq(x => x.WorkflowName, request.WorkflowName); + } + + if (!string.IsNullOrWhiteSpace(request.WorkflowVersion)) + { + filter &= Builders.Filter.Eq(x => x.WorkflowVersion, request.WorkflowVersion); + } + + if (!string.IsNullOrWhiteSpace(request.BusinessReferenceKey)) + { + filter &= Builders.Filter.Eq(x => x.BusinessReferenceKey, request.BusinessReferenceKey); + } + + if (!string.IsNullOrWhiteSpace(request.Status)) + { + filter &= Builders.Filter.Eq(x => x.Status, request.Status); + } + + var instanceDocuments = await FindInstancesAsync( + filter, + Builders.Sort.Descending(x => x.CreatedOnUtc), + cancellationToken); + + return instanceDocuments + .Select(MapInstanceSummary) + .Where(x => x.BusinessReference.MatchesBusinessReferenceFilter(request.BusinessReferenceKey, request.BusinessReferenceParts)) + .ToArray(); + } + + public async Task GetInstanceAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default) + { + var instance = await GetInstanceDocumentAsync(workflowInstanceId, cancellationToken); + return instance is null ? null : MapInstanceSummary(instance); + } + + public async Task GetInstanceDetailsAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default) + { + var instance = await GetInstanceDocumentAsync(workflowInstanceId, cancellationToken); + if (instance is null) + { + return null; + } + + var taskDocuments = await FindTasksAsync( + Builders.Filter.Eq(x => x.WorkflowInstanceId, workflowInstanceId), + Builders.Sort.Ascending(x => x.CreatedOnUtc), + cancellationToken); + + if (taskDocuments.Count == 0) + { + var projectionMarker = BuildProjectionWorkflowInstancePayloadMarker(workflowInstanceId); + var projectionTaskCandidates = await FindTasksAsync( + Builders.Filter.And( + Builders.Filter.Eq(x => x.WorkflowName, instance.WorkflowName), + Builders.Filter.Eq(x => x.WorkflowVersion, instance.WorkflowVersion)), + Builders.Sort.Ascending(x => x.CreatedOnUtc), + cancellationToken); + taskDocuments = projectionTaskCandidates + .Where(x => x.PayloadJson.Contains(projectionMarker, StringComparison.Ordinal)) + .ToList(); + } + + var taskLookup = taskDocuments.ToDictionary(x => x.WorkflowTaskId, StringComparer.OrdinalIgnoreCase); + var taskIds = taskLookup.Keys.ToArray(); + var taskEventDocuments = taskIds.Length == 0 + ? [] + : await FindTaskEventsAsync( + Builders.Filter.In(x => x.WorkflowTaskId, taskIds), + Builders.Sort.Ascending(x => x.CreatedOnUtc), + cancellationToken); + + return new WorkflowInstanceProjectionDetails + { + Instance = MapInstanceSummary(instance), + WorkflowState = MongoWorkflowJson.DeserializeObjectDictionary(instance.StateJson), + Tasks = taskDocuments.Select(MapTaskSummary).ToArray(), + TaskEvents = taskEventDocuments.Select(x => new WorkflowTaskEventSummary + { + WorkflowTaskId = x.WorkflowTaskId, + TaskName = taskLookup.TryGetValue(x.WorkflowTaskId, out var task) ? task.TaskName : null, + EventType = x.EventType, + ActorId = x.ActorId, + Payload = MongoWorkflowJson.DeserializeObjectDictionary(x.PayloadJson), + CreatedOnUtc = x.CreatedOnUtc, + }).ToArray(), + }; + } + + private async Task WithWriteScopeAsync( + Func> action, + CancellationToken cancellationToken) + { + await using var ownedSession = await database.OpenOwnedSessionAsync( + startTransaction: database.CurrentSession is null, + cancellationToken); + var result = await action(ownedSession.Session); + await ownedSession.CommitAsync(cancellationToken); + return result; + } + + private async Task WithWriteScopeAsync( + Func action, + CancellationToken cancellationToken) + { + await using var ownedSession = await database.OpenOwnedSessionAsync( + startTransaction: database.CurrentSession is null, + cancellationToken); + await action(ownedSession.Session); + await ownedSession.CommitAsync(cancellationToken); + } + + private async Task InsertTaskPlanAsync( + IClientSessionHandle session, + string workflowName, + string workflowVersion, + string workflowInstanceId, + WorkflowBusinessReference? businessReference, + IReadOnlyCollection workflowRoles, + WorkflowExecutionTaskPlan taskPlan, + DateTime createdOnUtc, + CancellationToken cancellationToken) + { + var normalizedWorkflowRoles = MongoWorkflowRoleResolver.NormalizeRoles( + taskPlan.WorkflowRoles.Count > 0 ? taskPlan.WorkflowRoles : workflowRoles); + var taskRoles = MongoWorkflowRoleResolver.NormalizeRoles(taskPlan.TaskRoles); + var runtimeRoles = MongoWorkflowRoleResolver.NormalizeRoles(taskPlan.RuntimeRoles); + var effectiveRoles = MongoWorkflowRoleResolver.ResolveEffectiveRoles(normalizedWorkflowRoles, taskRoles, runtimeRoles); + var workflowTaskId = $"wft-{Guid.NewGuid():N}"; + + await InsertTaskAsync( + session, + new WorkflowTaskDocument + { + WorkflowTaskId = workflowTaskId, + WorkflowInstanceId = workflowInstanceId, + WorkflowName = taskPlan.WorkflowName ?? workflowName, + WorkflowVersion = taskPlan.WorkflowVersion ?? workflowVersion, + TaskName = taskPlan.TaskName, + TaskType = taskPlan.TaskType, + Route = taskPlan.Route, + BusinessReferenceKey = businessReference?.Key, + BusinessReferenceJson = MongoWorkflowJson.SerializeBusinessReference(businessReference), + Status = ProjectionTaskStatuses.Open, + WorkflowRolesJson = MongoWorkflowJson.Serialize(normalizedWorkflowRoles), + TaskRolesJson = MongoWorkflowJson.Serialize(taskRoles), + RuntimeRolesJson = MongoWorkflowJson.Serialize(runtimeRoles), + EffectiveRolesJson = MongoWorkflowJson.Serialize(effectiveRoles), + PayloadJson = MongoWorkflowJson.Serialize(taskPlan.Payload), + CreatedOnUtc = createdOnUtc, + StaleAfterUtc = createdOnUtc.AddDays(retention.OpenStaleAfterDays), + }, + cancellationToken); + + await InsertTaskEventAsync( + session, + new WorkflowTaskEventDocument + { + WorkflowTaskEventId = $"wfte-{Guid.NewGuid():N}", + WorkflowTaskId = workflowTaskId, + EventType = ProjectionTaskEventTypes.Created, + PayloadJson = MongoWorkflowJson.Serialize(new + { + taskPlan.TaskName, + taskPlan.TaskType, + taskPlan.Route, + Payload = ToPublicTaskPayload(taskPlan.Payload), + }), + CreatedOnUtc = createdOnUtc, + }, + cancellationToken); + } + + private async Task UpdateTasksBusinessReferenceAsync( + IClientSessionHandle session, + string workflowInstanceId, + WorkflowBusinessReference? businessReference, + string? excludedWorkflowTaskId, + CancellationToken cancellationToken) + { + var filter = Builders.Filter.Eq(x => x.WorkflowInstanceId, workflowInstanceId); + if (!string.IsNullOrWhiteSpace(excludedWorkflowTaskId)) + { + filter &= Builders.Filter.Ne(x => x.WorkflowTaskId, excludedWorkflowTaskId); + } + + var update = Builders.Update + .Set(x => x.BusinessReferenceKey, businessReference?.Key) + .Set(x => x.BusinessReferenceJson, MongoWorkflowJson.SerializeBusinessReference(businessReference)); + + await tasks.UpdateManyAsync(session, filter, update, cancellationToken: cancellationToken); + } + + private async Task UpsertProjectionWorkflowInstancesAsync( + IClientSessionHandle session, + string rootWorkflowName, + string rootWorkflowVersion, + WorkflowBusinessReference? businessReference, + IReadOnlyDictionary workflowState, + IReadOnlyCollection taskPlans, + DateTime now, + CancellationToken cancellationToken) + { + var projectionGroups = taskPlans + .Select(taskPlan => new + { + TaskPlan = taskPlan, + ProjectionWorkflowInstanceId = TryReadProjectionWorkflowInstanceId(taskPlan.Payload), + }) + .Where(x => !string.IsNullOrWhiteSpace(x.ProjectionWorkflowInstanceId)) + .GroupBy(x => x.ProjectionWorkflowInstanceId!, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (projectionGroups.Length == 0) + { + return; + } + + var projectionIds = projectionGroups.Select(x => x.Key).ToArray(); + var existingProjectionInstances = await FindInstancesAsync( + Builders.Filter.In(x => x.WorkflowInstanceId, projectionIds), + sort: null, + cancellationToken); + var existingLookup = existingProjectionInstances.ToDictionary(x => x.WorkflowInstanceId, StringComparer.OrdinalIgnoreCase); + + foreach (var projectionGroup in projectionGroups) + { + var firstTaskPlan = projectionGroup.First().TaskPlan; + var projectionInstance = existingLookup.TryGetValue(projectionGroup.Key, out var existing) + ? existing + : new WorkflowInstanceDocument + { + WorkflowInstanceId = projectionGroup.Key, + CreatedOnUtc = now, + }; + + projectionInstance.WorkflowName = firstTaskPlan.WorkflowName ?? rootWorkflowName; + projectionInstance.WorkflowVersion = firstTaskPlan.WorkflowVersion ?? rootWorkflowVersion; + projectionInstance.BusinessReferenceKey = businessReference?.Key; + projectionInstance.BusinessReferenceJson = MongoWorkflowJson.SerializeBusinessReference(businessReference); + projectionInstance.Status = ProjectionInstanceStatuses.Open; + projectionInstance.StateJson = MongoWorkflowJson.Serialize(workflowState); + projectionInstance.CompletedOnUtc = null; + projectionInstance.StaleAfterUtc = ResolveOpenStaleAfterUtc(ProjectionInstanceStatuses.Open, now); + projectionInstance.PurgeAfterUtc = null; + + await ReplaceInstanceAsync(session, projectionInstance, cancellationToken, isUpsert: true); + } + } + + private async Task CompleteProjectionWorkflowInstanceAsync( + IClientSessionHandle session, + string projectionWorkflowInstanceId, + string workflowName, + string workflowVersion, + WorkflowBusinessReference? businessReference, + IReadOnlyDictionary workflowState, + DateTime now, + CancellationToken cancellationToken) + { + var projectionInstance = await GetInstanceDocumentAsync(projectionWorkflowInstanceId, cancellationToken) + ?? new WorkflowInstanceDocument + { + WorkflowInstanceId = projectionWorkflowInstanceId, + CreatedOnUtc = now, + }; + + projectionInstance.WorkflowName = workflowName; + projectionInstance.WorkflowVersion = workflowVersion; + projectionInstance.BusinessReferenceKey = businessReference?.Key; + projectionInstance.BusinessReferenceJson = MongoWorkflowJson.SerializeBusinessReference(businessReference); + projectionInstance.Status = ProjectionInstanceStatuses.Completed; + projectionInstance.StateJson = MongoWorkflowJson.Serialize(workflowState); + projectionInstance.CompletedOnUtc = now; + projectionInstance.StaleAfterUtc = null; + projectionInstance.PurgeAfterUtc = ResolvePurgeAfterUtc(ProjectionInstanceStatuses.Completed, now); + + await ReplaceInstanceAsync(session, projectionInstance, cancellationToken, isUpsert: true); + } + + private async Task InsertInstanceAsync( + IClientSessionHandle session, + WorkflowInstanceDocument instance, + CancellationToken cancellationToken) + { + await instances.InsertOneAsync(session, instance, cancellationToken: cancellationToken); + } + + private async Task ReplaceInstanceAsync( + IClientSessionHandle session, + WorkflowInstanceDocument instance, + CancellationToken cancellationToken, + bool isUpsert = false) + { + await instances.ReplaceOneAsync( + session, + x => x.WorkflowInstanceId == instance.WorkflowInstanceId, + instance, + new ReplaceOptions { IsUpsert = isUpsert }, + cancellationToken); + } + + private async Task InsertTaskAsync( + IClientSessionHandle session, + WorkflowTaskDocument task, + CancellationToken cancellationToken) + { + await tasks.InsertOneAsync(session, task, cancellationToken: cancellationToken); + } + + private async Task ReplaceTaskAsync( + IClientSessionHandle session, + WorkflowTaskDocument task, + CancellationToken cancellationToken) + { + await tasks.ReplaceOneAsync( + session, + x => x.WorkflowTaskId == task.WorkflowTaskId, + task, + cancellationToken: cancellationToken); + } + + private async Task InsertTaskEventAsync( + IClientSessionHandle session, + WorkflowTaskEventDocument taskEvent, + CancellationToken cancellationToken) + { + await taskEvents.InsertOneAsync(session, taskEvent, cancellationToken: cancellationToken); + } + + private async Task GetTaskDocumentAsync( + string workflowTaskId, + CancellationToken cancellationToken) + { + if (database.CurrentSession is null) + { + return await tasks.Find(x => x.WorkflowTaskId == workflowTaskId).FirstOrDefaultAsync(cancellationToken); + } + + return await tasks.Find(database.CurrentSession, x => x.WorkflowTaskId == workflowTaskId).FirstOrDefaultAsync(cancellationToken); + } + + private async Task GetInstanceDocumentAsync( + string workflowInstanceId, + CancellationToken cancellationToken) + { + if (database.CurrentSession is null) + { + return await instances.Find(x => x.WorkflowInstanceId == workflowInstanceId).FirstOrDefaultAsync(cancellationToken); + } + + return await instances.Find(database.CurrentSession, x => x.WorkflowInstanceId == workflowInstanceId).FirstOrDefaultAsync(cancellationToken); + } + + private async Task GetRequiredTaskAsync( + IClientSessionHandle session, + string workflowTaskId, + CancellationToken cancellationToken) + { + return await tasks.Find(session, x => x.WorkflowTaskId == workflowTaskId).FirstOrDefaultAsync(cancellationToken) + ?? throw new InvalidOperationException($"Workflow task '{workflowTaskId}' was not found."); + } + + private async Task GetRequiredInstanceAsync( + IClientSessionHandle session, + string workflowInstanceId, + CancellationToken cancellationToken) + { + return await instances.Find(session, x => x.WorkflowInstanceId == workflowInstanceId).FirstOrDefaultAsync(cancellationToken) + ?? throw new InvalidOperationException($"Workflow instance '{workflowInstanceId}' was not found."); + } + + private async Task> FindTasksAsync( + FilterDefinition filter, + SortDefinition? sort, + CancellationToken cancellationToken) + { + var fluent = database.CurrentSession is null ? tasks.Find(filter) : tasks.Find(database.CurrentSession, filter); + if (sort is not null) + { + fluent = fluent.Sort(sort); + } + + return await fluent.ToListAsync(cancellationToken); + } + + private async Task> FindInstancesAsync( + FilterDefinition filter, + SortDefinition? sort, + CancellationToken cancellationToken) + { + var fluent = database.CurrentSession is null ? instances.Find(filter) : instances.Find(database.CurrentSession, filter); + if (sort is not null) + { + fluent = fluent.Sort(sort); + } + + return await fluent.ToListAsync(cancellationToken); + } + + private async Task> FindTaskEventsAsync( + FilterDefinition filter, + SortDefinition? sort, + CancellationToken cancellationToken) + { + var fluent = database.CurrentSession is null ? taskEvents.Find(filter) : taskEvents.Find(database.CurrentSession, filter); + if (sort is not null) + { + fluent = fluent.Sort(sort); + } + + return await fluent.ToListAsync(cancellationToken); + } + + private static WorkflowTaskSummary MapTaskSummary(WorkflowTaskDocument task) + { + return new WorkflowTaskSummary + { + WorkflowTaskId = task.WorkflowTaskId, + WorkflowInstanceId = task.WorkflowInstanceId, + WorkflowName = task.WorkflowName, + WorkflowVersion = task.WorkflowVersion, + TaskName = task.TaskName, + TaskType = task.TaskType, + Route = task.Route, + BusinessReference = MongoWorkflowJson.DeserializeBusinessReference(task.BusinessReferenceKey, task.BusinessReferenceJson), + Assignee = task.Assignee, + Status = task.Status, + WorkflowRoles = DeserializeStringArray(task.WorkflowRolesJson), + TaskRoles = DeserializeStringArray(task.TaskRolesJson), + RuntimeRoles = DeserializeStringArray(task.RuntimeRolesJson), + EffectiveRoles = DeserializeStringArray(task.EffectiveRolesJson), + Payload = MongoWorkflowJson.DeserializePublicTaskPayload(task.PayloadJson), + CreatedOnUtc = task.CreatedOnUtc, + CompletedOnUtc = task.CompletedOnUtc, + StaleAfterUtc = task.StaleAfterUtc, + PurgeAfterUtc = task.PurgeAfterUtc, + }; + } + + private static WorkflowInstanceSummary MapInstanceSummary(WorkflowInstanceDocument instance) + { + return new WorkflowInstanceSummary + { + WorkflowInstanceId = instance.WorkflowInstanceId, + WorkflowName = instance.WorkflowName, + WorkflowVersion = instance.WorkflowVersion, + BusinessReference = MongoWorkflowJson.DeserializeBusinessReference(instance.BusinessReferenceKey, instance.BusinessReferenceJson), + Status = instance.Status, + CreatedOnUtc = instance.CreatedOnUtc, + CompletedOnUtc = instance.CompletedOnUtc, + }; + } + + private static IReadOnlyCollection DeserializeStringArray(string value) + { + return JsonSerializer.Deserialize(value) ?? []; + } + + private static IReadOnlyDictionary ToPublicTaskPayload( + IReadOnlyDictionary payload) + { + return payload + .Where(x => !string.Equals(x.Key, WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey, StringComparison.OrdinalIgnoreCase)) + .ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase); + } + + private static string? TryReadProjectionWorkflowInstanceId( + IReadOnlyDictionary payload) + { + if (!payload.TryGetValue(WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey, out var value) + || value.ValueKind != JsonValueKind.String) + { + return null; + } + + return value.GetString(); + } + + private static string BuildProjectionWorkflowInstancePayloadMarker(string projectionWorkflowInstanceId) + { + return $"\"{WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey}\":\"{projectionWorkflowInstanceId}\""; + } + + private static DateTime? ResolveCompletedOnUtc(string instanceStatus, DateTime now) + { + return string.Equals(instanceStatus, ProjectionInstanceStatuses.Completed, StringComparison.OrdinalIgnoreCase) + ? now + : null; + } + + private DateTime? ResolveOpenStaleAfterUtc(string instanceStatus, DateTime now) + { + return string.Equals(instanceStatus, ProjectionInstanceStatuses.Completed, StringComparison.OrdinalIgnoreCase) + ? null + : now.AddDays(retention.OpenStaleAfterDays); + } + + private DateTime? ResolvePurgeAfterUtc(string instanceStatus, DateTime now) + { + return string.Equals(instanceStatus, ProjectionInstanceStatuses.Completed, StringComparison.OrdinalIgnoreCase) + ? now.AddDays(retention.CompletedPurgeAfterDays) + : null; + } + + private static class ProjectionInstanceStatuses + { + public const string Open = "Open"; + public const string Completed = "Completed"; + } + + private static class ProjectionTaskStatuses + { + public const string Open = "Open"; + public const string Assigned = "Assigned"; + public const string Completed = "Completed"; + } + + private static class ProjectionTaskEventTypes + { + public const string Created = "Created"; + public const string Assigned = "Assigned"; + public const string Reassigned = "Reassigned"; + public const string Released = "Released"; + public const string Completed = "Completed"; + } + + private sealed class WorkflowInstanceDocument + { + [BsonId] + public string WorkflowInstanceId { get; set; } = string.Empty; + public string WorkflowName { get; set; } = string.Empty; + public string WorkflowVersion { get; set; } = "1.0.0"; + public string? BusinessReferenceKey { get; set; } + public string? BusinessReferenceJson { get; set; } + public string Status { get; set; } = ProjectionInstanceStatuses.Open; + public string StateJson { get; set; } = "{}"; + public DateTime CreatedOnUtc { get; set; } = DateTime.UtcNow; + public DateTime? CompletedOnUtc { get; set; } + public DateTime? StaleAfterUtc { get; set; } + public DateTime? PurgeAfterUtc { get; set; } + } + + private sealed class WorkflowTaskDocument + { + [BsonId] + public string WorkflowTaskId { get; set; } = string.Empty; + public string WorkflowInstanceId { get; set; } = string.Empty; + public string WorkflowName { get; set; } = string.Empty; + public string WorkflowVersion { get; set; } = "1.0.0"; + public string TaskName { get; set; } = string.Empty; + public string TaskType { get; set; } = string.Empty; + public string Route { get; set; } = string.Empty; + public string? BusinessReferenceKey { get; set; } + public string? BusinessReferenceJson { get; set; } + public string? Assignee { get; set; } + public string Status { get; set; } = ProjectionTaskStatuses.Open; + public string WorkflowRolesJson { get; set; } = "[]"; + public string TaskRolesJson { get; set; } = "[]"; + public string RuntimeRolesJson { get; set; } = "[]"; + public string EffectiveRolesJson { get; set; } = "[]"; + public string PayloadJson { get; set; } = "{}"; + public DateTime CreatedOnUtc { get; set; } = DateTime.UtcNow; + public DateTime? CompletedOnUtc { get; set; } + public DateTime? StaleAfterUtc { get; set; } + public DateTime? PurgeAfterUtc { get; set; } + } + + private sealed class WorkflowTaskEventDocument + { + [BsonId] + public string WorkflowTaskEventId { get; set; } = string.Empty; + public string WorkflowTaskId { get; set; } = string.Empty; + public string EventType { get; set; } = string.Empty; + public string? ActorId { get; set; } + public string PayloadJson { get; set; } = "{}"; + public DateTime CreatedOnUtc { get; set; } = DateTime.UtcNow; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowRuntimeStateStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowRuntimeStateStore.cs new file mode 100644 index 000000000..04ade701f --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowRuntimeStateStore.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; + +namespace StellaOps.Workflow.DataStore.MongoDB; + +public sealed class MongoWorkflowRuntimeStateStore( + MongoWorkflowDatabase database) : IWorkflowRuntimeStateStore +{ + private const string StaleRuntimeStatus = "Stale"; + private readonly IMongoCollection collection = + database.GetCollection(database.Options.RuntimeStatesCollectionName); + + public async Task UpsertAsync( + WorkflowRuntimeStateRecord state, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(state); + + var document = Map(state); + var session = database.CurrentSession; + + if (state.Version <= 1) + { + try + { + if (session is null) + { + await collection.InsertOneAsync(document, cancellationToken: cancellationToken); + } + else + { + await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken); + } + + return; + } + catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + var actual = await GetRequiredCurrentAsync(state.WorkflowInstanceId, cancellationToken); + throw new WorkflowRuntimeStateConcurrencyException(state.WorkflowInstanceId, state.Version, actual.Version); + } + } + + var previousVersion = state.Version - 1; + ReplaceOneResult result; + if (session is null) + { + result = await collection.ReplaceOneAsync( + x => x.WorkflowInstanceId == state.WorkflowInstanceId && x.Version == previousVersion, + document, + cancellationToken: cancellationToken); + } + else + { + result = await collection.ReplaceOneAsync( + session, + x => x.WorkflowInstanceId == state.WorkflowInstanceId && x.Version == previousVersion, + document, + cancellationToken: cancellationToken); + } + + if (result.ModifiedCount > 0) + { + return; + } + + var actualRecord = await GetRequiredCurrentAsync(state.WorkflowInstanceId, cancellationToken); + throw new WorkflowRuntimeStateConcurrencyException(state.WorkflowInstanceId, state.Version, actualRecord.Version); + } + + public async Task GetAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default) + { + var session = database.CurrentSession; + WorkflowRuntimeStateDocument? document; + if (session is null) + { + document = await collection + .Find(x => x.WorkflowInstanceId == workflowInstanceId) + .FirstOrDefaultAsync(cancellationToken); + } + else + { + document = await collection + .Find(session, x => x.WorkflowInstanceId == workflowInstanceId) + .FirstOrDefaultAsync(cancellationToken); + } + + return document is null ? null : Map(document); + } + + public async Task> GetManyAsync( + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken = default) + { + if (workflowInstanceIds.Count == 0) + { + return []; + } + + var ids = workflowInstanceIds.ToArray(); + var session = database.CurrentSession; + List documents; + if (session is null) + { + documents = await collection + .Find(x => ids.Contains(x.WorkflowInstanceId)) + .ToListAsync(cancellationToken); + } + else + { + documents = await collection + .Find(session, x => ids.Contains(x.WorkflowInstanceId)) + .ToListAsync(cancellationToken); + } + + return documents.Select(Map).ToArray(); + } + + public async Task MarkStaleAsync( + IReadOnlyCollection workflowInstanceIds, + DateTime updatedOnUtc, + CancellationToken cancellationToken = default) + { + if (workflowInstanceIds.Count == 0) + { + return 0; + } + + var ids = workflowInstanceIds.ToArray(); + var update = Builders.Update + .Set(x => x.RuntimeStatus, StaleRuntimeStatus) + .Set(x => x.StaleAfterUtc, null) + .Set(x => x.LastUpdatedOnUtc, updatedOnUtc); + + var session = database.CurrentSession; + UpdateResult result; + if (session is null) + { + result = await collection.UpdateManyAsync( + x => ids.Contains(x.WorkflowInstanceId), + update, + cancellationToken: cancellationToken); + } + else + { + result = await collection.UpdateManyAsync( + session, + x => ids.Contains(x.WorkflowInstanceId), + update, + cancellationToken: cancellationToken); + } + + return (int)result.ModifiedCount; + } + + public async Task DeleteAsync( + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken = default) + { + if (workflowInstanceIds.Count == 0) + { + return 0; + } + + var ids = workflowInstanceIds.ToArray(); + var session = database.CurrentSession; + DeleteResult result; + if (session is null) + { + result = await collection.DeleteManyAsync(x => ids.Contains(x.WorkflowInstanceId), cancellationToken: cancellationToken); + } + else + { + result = await collection.DeleteManyAsync(session, x => ids.Contains(x.WorkflowInstanceId), cancellationToken: cancellationToken); + } + + return (int)result.DeletedCount; + } + + private async Task GetRequiredCurrentAsync( + string workflowInstanceId, + CancellationToken cancellationToken) + { + return await GetAsync(workflowInstanceId, cancellationToken) + ?? throw new WorkflowRuntimeStateConcurrencyException(workflowInstanceId, 0, 0); + } + + private static WorkflowRuntimeStateDocument Map(WorkflowRuntimeStateRecord state) + { + return new WorkflowRuntimeStateDocument + { + WorkflowInstanceId = state.WorkflowInstanceId, + WorkflowName = state.WorkflowName, + WorkflowVersion = state.WorkflowVersion, + Version = state.Version, + BusinessReferenceKey = state.BusinessReference?.Key, + BusinessReferenceJson = MongoWorkflowJson.SerializeBusinessReference(state.BusinessReference), + RuntimeProvider = state.RuntimeProvider, + RuntimeInstanceId = state.RuntimeInstanceId, + RuntimeStatus = state.RuntimeStatus, + StateJson = state.StateJson, + CreatedOnUtc = state.CreatedOnUtc, + CompletedOnUtc = state.CompletedOnUtc, + StaleAfterUtc = state.StaleAfterUtc, + PurgeAfterUtc = state.PurgeAfterUtc, + LastUpdatedOnUtc = state.LastUpdatedOnUtc, + }; + } + + private static WorkflowRuntimeStateRecord Map(WorkflowRuntimeStateDocument document) + { + return new WorkflowRuntimeStateRecord + { + WorkflowInstanceId = document.WorkflowInstanceId, + WorkflowName = document.WorkflowName, + WorkflowVersion = document.WorkflowVersion, + Version = document.Version, + BusinessReference = MongoWorkflowJson.DeserializeBusinessReference(document.BusinessReferenceKey, document.BusinessReferenceJson), + RuntimeProvider = document.RuntimeProvider, + RuntimeInstanceId = document.RuntimeInstanceId, + RuntimeStatus = document.RuntimeStatus, + StateJson = document.StateJson, + CreatedOnUtc = document.CreatedOnUtc, + CompletedOnUtc = document.CompletedOnUtc, + StaleAfterUtc = document.StaleAfterUtc, + PurgeAfterUtc = document.PurgeAfterUtc, + LastUpdatedOnUtc = document.LastUpdatedOnUtc, + }; + } + + private sealed class WorkflowRuntimeStateDocument + { + [BsonId] + public string WorkflowInstanceId { get; set; } = string.Empty; + public string WorkflowName { get; set; } = string.Empty; + public string WorkflowVersion { get; set; } = "1.0.0"; + public long Version { get; set; } + public string? BusinessReferenceKey { get; set; } + public string? BusinessReferenceJson { get; set; } + public string RuntimeProvider { get; set; } = WorkflowRuntimeProviderNames.Engine; + public string RuntimeInstanceId { get; set; } = string.Empty; + public string RuntimeStatus { get; set; } = "Open"; + public string StateJson { get; set; } = "{}"; + public DateTime CreatedOnUtc { get; set; } = DateTime.UtcNow; + public DateTime? CompletedOnUtc { get; set; } + public DateTime? StaleAfterUtc { get; set; } + public DateTime? PurgeAfterUtc { get; set; } + public DateTime LastUpdatedOnUtc { get; set; } = DateTime.UtcNow; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowScheduleBus.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowScheduleBus.cs new file mode 100644 index 000000000..97d1aecd4 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowScheduleBus.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.DataStore.MongoDB; + +public sealed class MongoWorkflowScheduleBus( + MongoWorkflowSignalStore signalStore) : IWorkflowSignalScheduler +{ + public Task ScheduleAsync( + WorkflowSignalEnvelope envelope, + DateTime dueAtUtc, + CancellationToken cancellationToken = default) + { + return signalStore.EnqueueLiveAsync(envelope with { DueAtUtc = dueAtUtc }, dueAtUtc, cancellationToken); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowSignalBus.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowSignalBus.cs new file mode 100644 index 000000000..d5e5db8c9 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowSignalBus.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.DataStore.MongoDB; + +public sealed class MongoWorkflowSignalBus( + MongoWorkflowSignalStore signalStore) : IWorkflowSignalDriver +{ + public string DriverName => "Mongo.ChangeStream"; + + public WorkflowSignalDriverDispatchMode DispatchMode => WorkflowSignalDriverDispatchMode.NativeTransactional; + + public Task NotifySignalAvailableAsync( + WorkflowSignalWakeNotification notification, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public async Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + var lease = await signalStore.TryClaimAsync(consumerName, cancellationToken); + if (lease is not null) + { + return lease; + } + + await signalStore.WaitForWakeAsync(null, cancellationToken); + if (cancellationToken.IsCancellationRequested) + { + return null; + } + + return await signalStore.TryClaimAsync(consumerName, cancellationToken); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowSignalDeadLetterStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowSignalDeadLetterStore.cs new file mode 100644 index 000000000..bc438046b --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowSignalDeadLetterStore.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.DataStore.MongoDB; + +public sealed class MongoWorkflowSignalDeadLetterStore( + MongoWorkflowSignalStore signalStore) : IWorkflowSignalDeadLetterStore +{ + public Task GetMessagesAsync( + WorkflowSignalDeadLettersGetRequest request, + CancellationToken cancellationToken = default) + { + return signalStore.GetDeadLettersAsync(request, cancellationToken); + } + + public Task ReplayAsync( + WorkflowSignalDeadLetterReplayRequest request, + CancellationToken cancellationToken = default) + { + return signalStore.ReplayAsync(request, cancellationToken); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowSignalStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowSignalStore.cs new file mode 100644 index 000000000..f88cbb897 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowSignalStore.cs @@ -0,0 +1,503 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; + +namespace StellaOps.Workflow.DataStore.MongoDB; + +public sealed class MongoWorkflowSignalStore( + MongoWorkflowDatabase database) : IWorkflowSignalStore, IWorkflowSignalClaimStore +{ + private readonly IMongoCollection liveSignals = + database.GetCollection(database.Options.SignalQueueCollectionName); + private readonly IMongoCollection deadLetters = + database.GetCollection(database.Options.DeadLetterCollectionName); + + internal WorkflowStoreMongoOptions Options => database.Options; + + public Task PublishAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope); + return EnqueueLiveAsync(envelope, envelope.DueAtUtc, cancellationToken); + } + + public Task PublishDeadLetterAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope); + return EnqueueDeadLetterAsync(envelope, cancellationToken); + } + + public async Task TryClaimAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + var claimed = await TryClaimInternalAsync(consumerName, cancellationToken); + return claimed is null + ? null + : new MongoWorkflowSignalLease(this, claimed); + } + + internal async Task EnqueueLiveAsync( + WorkflowSignalEnvelope envelope, + DateTime? dueAtUtc, + CancellationToken cancellationToken = default) + { + await database.EnsureInitializedAsync(cancellationToken); + var document = WorkflowSignalDocument.FromEnvelope(envelope, dueAtUtc, deliveryCount: 0, DateTime.UtcNow); + var session = database.CurrentSession; + if (session is null) + { + await liveSignals.InsertOneAsync(document, cancellationToken: cancellationToken); + } + else + { + await liveSignals.InsertOneAsync(session, document, cancellationToken: cancellationToken); + } + } + + internal async Task EnqueueDeadLetterAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + await database.EnsureInitializedAsync(cancellationToken); + var document = WorkflowSignalDocument.FromEnvelope(envelope, envelope.DueAtUtc, deliveryCount: 0, DateTime.UtcNow); + document.DeadLetteredOnUtc = DateTime.UtcNow; + var session = database.CurrentSession; + if (session is null) + { + await deadLetters.InsertOneAsync(document, cancellationToken: cancellationToken); + } + else + { + await deadLetters.InsertOneAsync(session, document, cancellationToken: cancellationToken); + } + } + + internal async Task TryClaimInternalAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + await database.EnsureInitializedAsync(cancellationToken); + var now = DateTime.UtcNow; + var claimedUntilUtc = now.AddSeconds(Math.Max(1, Options.ClaimTimeoutSeconds)); + var filter = Builders.Filter.And( + Builders.Filter.Or( + Builders.Filter.Eq(x => x.DueAtUtc, null), + Builders.Filter.Lte(x => x.DueAtUtc, now)), + Builders.Filter.Or( + Builders.Filter.Eq(x => x.ClaimedBy, null), + Builders.Filter.Lte(x => x.ClaimedUntilUtc, now))); + var update = Builders.Update + .Set(x => x.ClaimedBy, consumerName) + .Set(x => x.ClaimedUntilUtc, claimedUntilUtc) + .Inc(x => x.DeliveryCount, 1); + var options = new FindOneAndUpdateOptions + { + ReturnDocument = ReturnDocument.After, + Sort = Builders.Sort + .Ascending(x => x.DueAtUtc) + .Ascending(x => x.EnqueuedOnUtc), + }; + + var session = database.CurrentSession; + WorkflowSignalDocument? document; + if (session is null) + { + document = await liveSignals.FindOneAndUpdateAsync(filter, update, options, cancellationToken); + } + else + { + document = await liveSignals.FindOneAndUpdateAsync(session, filter, update, options, cancellationToken); + } + + return document is null + ? null + : new MongoClaimedSignal + { + SignalId = document.SignalId, + ClaimedBy = consumerName, + DeliveryCount = document.DeliveryCount, + Envelope = document.ToEnvelope(), + }; + } + + internal async Task WaitForWakeAsync( + TimeSpan? maxWait, + CancellationToken cancellationToken = default) + { + await database.EnsureInitializedAsync(cancellationToken); + var defaultWait = TimeSpan.FromSeconds(Math.Max(1, Options.BlockingWaitSeconds)); + var nextDueAtUtc = await GetNextDueAtUtcAsync(cancellationToken); + TimeSpan? waitForDue = null; + if (nextDueAtUtc.HasValue) + { + var dueDelay = nextDueAtUtc.Value - DateTime.UtcNow; + if (dueDelay <= TimeSpan.Zero) + { + return; + } + + waitForDue = dueDelay; + } + + TimeSpan? effectiveWait = maxWait ?? defaultWait; + if (waitForDue.HasValue) + { + effectiveWait = effectiveWait.HasValue + ? TimeSpan.FromMilliseconds(Math.Min(effectiveWait.Value.TotalMilliseconds, waitForDue.Value.TotalMilliseconds)) + : waitForDue; + } + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (effectiveWait.HasValue) + { + linkedCts.CancelAfter(effectiveWait.Value); + } + + var pipeline = new EmptyPipelineDefinition>() + .Match(x => + x.OperationType == ChangeStreamOperationType.Insert + || x.OperationType == ChangeStreamOperationType.Update + || x.OperationType == ChangeStreamOperationType.Replace); + var changeStreamOptions = new ChangeStreamOptions + { + FullDocument = ChangeStreamFullDocumentOption.UpdateLookup, + MaxAwaitTime = effectiveWait, + }; + + try + { + using var cursor = await liveSignals.WatchAsync( + pipeline, + changeStreamOptions, + linkedCts.Token); + await cursor.MoveNextAsync(linkedCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + } + } + + internal async Task CompleteAsync( + string signalId, + string claimedBy, + CancellationToken cancellationToken = default) + { + var session = database.CurrentSession; + if (session is null) + { + await liveSignals.DeleteOneAsync( + x => x.SignalId == signalId && x.ClaimedBy == claimedBy, + cancellationToken: cancellationToken); + } + else + { + await liveSignals.DeleteOneAsync( + session, + x => x.SignalId == signalId && x.ClaimedBy == claimedBy, + cancellationToken: cancellationToken); + } + } + + internal async Task AbandonAsync( + string signalId, + string claimedBy, + CancellationToken cancellationToken = default) + { + var update = Builders.Update + .Set(x => x.ClaimedBy, null) + .Set(x => x.ClaimedUntilUtc, null); + var session = database.CurrentSession; + if (session is null) + { + await liveSignals.UpdateOneAsync( + x => x.SignalId == signalId && x.ClaimedBy == claimedBy, + update, + cancellationToken: cancellationToken); + } + else + { + await liveSignals.UpdateOneAsync( + session, + x => x.SignalId == signalId && x.ClaimedBy == claimedBy, + update, + cancellationToken: cancellationToken); + } + } + + internal async Task DeadLetterAsync( + string signalId, + string claimedBy, + CancellationToken cancellationToken = default) + { + await using var ownedSession = await database.OpenOwnedSessionAsync(startTransaction: true, cancellationToken); + var document = await liveSignals.Find(ownedSession.Session, x => x.SignalId == signalId && x.ClaimedBy == claimedBy) + .FirstOrDefaultAsync(cancellationToken); + if (document is null) + { + return; + } + + document.DeadLetteredOnUtc = DateTime.UtcNow; + await deadLetters.ReplaceOneAsync( + ownedSession.Session, + x => x.SignalId == signalId, + document, + new ReplaceOptions { IsUpsert = true }, + cancellationToken); + await liveSignals.DeleteOneAsync( + ownedSession.Session, + x => x.SignalId == signalId && x.ClaimedBy == claimedBy, + cancellationToken: cancellationToken); + await ownedSession.CommitAsync(cancellationToken); + } + + internal async Task GetDeadLettersAsync( + WorkflowSignalDeadLettersGetRequest request, + CancellationToken cancellationToken = default) + { + await database.EnsureInitializedAsync(cancellationToken); + var filter = Builders.Filter.Empty; + if (!string.IsNullOrWhiteSpace(request.SignalId)) + { + filter &= Builders.Filter.Eq(x => x.SignalId, request.SignalId); + } + + if (!string.IsNullOrWhiteSpace(request.WorkflowInstanceId)) + { + filter &= Builders.Filter.Eq(x => x.WorkflowInstanceId, request.WorkflowInstanceId); + } + + if (!string.IsNullOrWhiteSpace(request.SignalType)) + { + filter &= Builders.Filter.Eq(x => x.SignalType, request.SignalType); + } + + var documents = await deadLetters.Find(filter) + .SortByDescending(x => x.DeadLetteredOnUtc) + .ThenBy(x => x.SignalId) + .Limit(Math.Clamp(request.MaxMessages, 1, 200)) + .ToListAsync(cancellationToken); + + return new WorkflowSignalDeadLettersGetResponse + { + Messages = documents.Select(document => + { + try + { + return new WorkflowSignalDeadLetterMessage + { + SignalId = document.SignalId, + Correlation = document.SignalId, + WorkflowInstanceId = document.WorkflowInstanceId, + RuntimeProvider = document.RuntimeProvider, + SignalType = document.SignalType, + ExpectedVersion = document.ExpectedVersion, + WaitingToken = document.WaitingToken, + OccurredAtUtc = document.OccurredAtUtc, + DueAtUtc = document.DueAtUtc, + Payload = MongoWorkflowJson.DeserializeJsonDictionary(document.PayloadJson), + DeliveryCount = document.DeliveryCount, + EnqueuedOnUtc = document.EnqueuedOnUtc, + IsEnvelopeReadable = true, + RawPayloadBase64 = request.IncludeRawPayload + ? Convert.ToBase64String(global::System.Text.Encoding.UTF8.GetBytes(document.PayloadJson)) + : null, + }; + } + catch (Exception exception) + { + return new WorkflowSignalDeadLetterMessage + { + SignalId = document.SignalId, + Correlation = document.SignalId, + WorkflowInstanceId = document.WorkflowInstanceId, + RuntimeProvider = document.RuntimeProvider, + SignalType = document.SignalType, + ExpectedVersion = document.ExpectedVersion, + WaitingToken = document.WaitingToken, + OccurredAtUtc = document.OccurredAtUtc, + DueAtUtc = document.DueAtUtc, + DeliveryCount = document.DeliveryCount, + EnqueuedOnUtc = document.EnqueuedOnUtc, + IsEnvelopeReadable = false, + ReadError = exception.Message, + RawPayloadBase64 = request.IncludeRawPayload + ? Convert.ToBase64String(global::System.Text.Encoding.UTF8.GetBytes(document.PayloadJson)) + : null, + }; + } + }).ToArray(), + }; + } + + internal async Task ReplayAsync( + WorkflowSignalDeadLetterReplayRequest request, + CancellationToken cancellationToken = default) + { + await database.EnsureInitializedAsync(cancellationToken); + await using var ownedSession = await database.OpenOwnedSessionAsync(startTransaction: true, cancellationToken); + var document = await deadLetters.Find(ownedSession.Session, x => x.SignalId == request.SignalId) + .FirstOrDefaultAsync(cancellationToken); + if (document is null) + { + return new WorkflowSignalDeadLetterReplayResponse + { + SignalId = request.SignalId, + Replayed = false, + }; + } + + document.ClaimedBy = null; + document.ClaimedUntilUtc = null; + document.DeadLetteredOnUtc = null; + document.DeliveryCount = 0; + document.EnqueuedOnUtc = DateTime.UtcNow; + + await liveSignals.ReplaceOneAsync( + ownedSession.Session, + x => x.SignalId == request.SignalId, + document, + new ReplaceOptions { IsUpsert = true }, + cancellationToken); + await deadLetters.DeleteOneAsync( + ownedSession.Session, + x => x.SignalId == request.SignalId, + cancellationToken: cancellationToken); + await ownedSession.CommitAsync(cancellationToken); + + return new WorkflowSignalDeadLetterReplayResponse + { + SignalId = request.SignalId, + Replayed = true, + WorkflowInstanceId = document.WorkflowInstanceId, + SignalType = document.SignalType, + WasEnvelopeReadable = true, + }; + } + + private async Task GetNextDueAtUtcAsync(CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var filter = Builders.Filter.And( + Builders.Filter.Ne(x => x.DueAtUtc, null), + Builders.Filter.Or( + Builders.Filter.Eq(x => x.ClaimedBy, null), + Builders.Filter.Lte(x => x.ClaimedUntilUtc, now))); + var document = await liveSignals.Find(filter) + .SortBy(x => x.DueAtUtc) + .Project(x => new WorkflowSignalDueProjection { DueAtUtc = x.DueAtUtc }) + .FirstOrDefaultAsync(cancellationToken); + return document?.DueAtUtc; + } + + internal sealed class WorkflowSignalDocument + { + [BsonId] + public string SignalId { get; set; } = string.Empty; + public string WorkflowInstanceId { get; set; } = string.Empty; + public string RuntimeProvider { get; set; } = WorkflowRuntimeProviderNames.Engine; + public string SignalType { get; set; } = WorkflowSignalTypes.ExternalSignal; + public long ExpectedVersion { get; set; } + public string? WaitingToken { get; set; } + public DateTime OccurredAtUtc { get; set; } + public DateTime? DueAtUtc { get; set; } + public string PayloadJson { get; set; } = "{}"; + public int DeliveryCount { get; set; } + public DateTime EnqueuedOnUtc { get; set; } + public string? ClaimedBy { get; set; } + public DateTime? ClaimedUntilUtc { get; set; } + public string? LastError { get; set; } + public DateTime? DeadLetteredOnUtc { get; set; } + + public static WorkflowSignalDocument FromEnvelope( + WorkflowSignalEnvelope envelope, + DateTime? dueAtUtc, + int deliveryCount, + DateTime enqueuedOnUtc) + { + return new WorkflowSignalDocument + { + SignalId = envelope.SignalId, + WorkflowInstanceId = envelope.WorkflowInstanceId, + RuntimeProvider = envelope.RuntimeProvider, + SignalType = envelope.SignalType, + ExpectedVersion = envelope.ExpectedVersion, + WaitingToken = envelope.WaitingToken, + OccurredAtUtc = envelope.OccurredAtUtc, + DueAtUtc = dueAtUtc, + PayloadJson = MongoWorkflowJson.Serialize(envelope.Payload), + DeliveryCount = deliveryCount, + EnqueuedOnUtc = enqueuedOnUtc, + }; + } + + public WorkflowSignalEnvelope ToEnvelope() + { + return new WorkflowSignalEnvelope + { + SignalId = SignalId, + WorkflowInstanceId = WorkflowInstanceId, + RuntimeProvider = RuntimeProvider, + SignalType = SignalType, + ExpectedVersion = ExpectedVersion, + WaitingToken = WaitingToken, + OccurredAtUtc = OccurredAtUtc, + DueAtUtc = DueAtUtc, + Payload = MongoWorkflowJson.DeserializeJsonDictionary(PayloadJson), + }; + } + } + + internal sealed class MongoClaimedSignal + { + public required string SignalId { get; init; } + public required string ClaimedBy { get; init; } + public required int DeliveryCount { get; init; } + public required WorkflowSignalEnvelope Envelope { get; init; } + } + + private sealed class MongoWorkflowSignalLease( + MongoWorkflowSignalStore signalStore, + MongoClaimedSignal claimedSignal) : IWorkflowSignalLease + { + public WorkflowSignalEnvelope Envelope { get; } = claimedSignal.Envelope; + public int DeliveryCount => claimedSignal.DeliveryCount; + + public Task CompleteAsync(CancellationToken cancellationToken = default) + { + return signalStore.CompleteAsync(claimedSignal.SignalId, claimedSignal.ClaimedBy, cancellationToken); + } + + public Task AbandonAsync(CancellationToken cancellationToken = default) + { + return signalStore.AbandonAsync(claimedSignal.SignalId, claimedSignal.ClaimedBy, cancellationToken); + } + + public Task DeadLetterAsync(CancellationToken cancellationToken = default) + { + return signalStore.DeadLetterAsync(claimedSignal.SignalId, claimedSignal.ClaimedBy, cancellationToken); + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + } + + private sealed class WorkflowSignalDueProjection + { + public DateTime? DueAtUtc { get; set; } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowWakeOutbox.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowWakeOutbox.cs new file mode 100644 index 000000000..1e78175b6 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/MongoWorkflowWakeOutbox.cs @@ -0,0 +1,216 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; + +namespace StellaOps.Workflow.DataStore.MongoDB; + +public sealed class MongoWorkflowWakeOutbox( + MongoWorkflowDatabase database) : IWorkflowWakeOutbox, IWorkflowWakeOutboxReceiver +{ + private readonly IMongoCollection outbox = + database.GetCollection(database.Options.WakeOutboxCollectionName); + + private WorkflowStoreMongoOptions Options => database.Options; + + public async Task EnqueueAsync( + WorkflowSignalWakeNotification notification, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(notification); + + await database.EnsureInitializedAsync(cancellationToken); + var document = new WakeOutboxDocument + { + OutboxId = Guid.NewGuid().ToString("N"), + SignalId = notification.SignalId, + WorkflowInstanceId = notification.WorkflowInstanceId, + RuntimeProvider = notification.RuntimeProvider, + SignalType = notification.SignalType, + DueAtUtc = notification.DueAtUtc, + CreatedOnUtc = DateTime.UtcNow, + }; + + var session = database.CurrentSession; + if (session is null) + { + await outbox.InsertOneAsync(document, cancellationToken: cancellationToken); + } + else + { + await outbox.InsertOneAsync(session, document, cancellationToken: cancellationToken); + } + } + + public async Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + var lease = await TryClaimInternalAsync(consumerName, cancellationToken); + if (lease is not null) + { + return lease; + } + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linkedCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, Options.BlockingWaitSeconds))); + + var pipeline = new EmptyPipelineDefinition>() + .Match(x => + x.OperationType == ChangeStreamOperationType.Insert + || x.OperationType == ChangeStreamOperationType.Update + || x.OperationType == ChangeStreamOperationType.Replace); + var options = new ChangeStreamOptions + { + FullDocument = ChangeStreamFullDocumentOption.UpdateLookup, + MaxAwaitTime = TimeSpan.FromSeconds(Math.Max(1, Options.BlockingWaitSeconds)), + }; + + try + { + using var cursor = await outbox.WatchAsync( + pipeline, + options, + linkedCts.Token); + await cursor.MoveNextAsync(linkedCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + } + + return await TryClaimInternalAsync(consumerName, cancellationToken); + } + + internal async Task CompleteAsync( + string outboxId, + string consumerName, + CancellationToken cancellationToken = default) + { + var session = database.CurrentSession; + if (session is null) + { + await outbox.DeleteOneAsync( + x => x.OutboxId == outboxId && x.ClaimedBy == consumerName, + cancellationToken: cancellationToken); + } + else + { + await outbox.DeleteOneAsync( + session, + x => x.OutboxId == outboxId && x.ClaimedBy == consumerName, + cancellationToken: cancellationToken); + } + } + + internal async Task AbandonAsync( + string outboxId, + string consumerName, + CancellationToken cancellationToken = default) + { + var update = Builders.Update + .Set(x => x.ClaimedBy, null) + .Set(x => x.ClaimedUntilUtc, null); + var session = database.CurrentSession; + if (session is null) + { + await outbox.UpdateOneAsync( + x => x.OutboxId == outboxId && x.ClaimedBy == consumerName, + update, + cancellationToken: cancellationToken); + } + else + { + await outbox.UpdateOneAsync( + session, + x => x.OutboxId == outboxId && x.ClaimedBy == consumerName, + update, + cancellationToken: cancellationToken); + } + } + + private async Task TryClaimInternalAsync( + string consumerName, + CancellationToken cancellationToken) + { + await database.EnsureInitializedAsync(cancellationToken); + var now = DateTime.UtcNow; + var claimedUntilUtc = now.AddSeconds(Math.Max(1, Options.ClaimTimeoutSeconds)); + var filter = Builders.Filter.Or( + Builders.Filter.Eq(x => x.ClaimedBy, null), + Builders.Filter.Lte(x => x.ClaimedUntilUtc, now)); + var update = Builders.Update + .Set(x => x.ClaimedBy, consumerName) + .Set(x => x.ClaimedUntilUtc, claimedUntilUtc); + var options = new FindOneAndUpdateOptions + { + ReturnDocument = ReturnDocument.After, + Sort = Builders.Sort + .Ascending(x => x.CreatedOnUtc) + .Ascending(x => x.OutboxId), + }; + + var session = database.CurrentSession; + WakeOutboxDocument? document; + if (session is null) + { + document = await outbox.FindOneAndUpdateAsync(filter, update, options, cancellationToken); + } + else + { + document = await outbox.FindOneAndUpdateAsync(session, filter, update, options, cancellationToken); + } + + return document is null + ? null + : new Lease(this, document); + } + + private sealed class Lease( + MongoWorkflowWakeOutbox outbox, + WakeOutboxDocument document) : IWorkflowWakeOutboxLease + { + public WorkflowSignalWakeNotification Notification { get; } = new() + { + SignalId = document.SignalId, + WorkflowInstanceId = document.WorkflowInstanceId, + RuntimeProvider = document.RuntimeProvider, + SignalType = document.SignalType, + DueAtUtc = document.DueAtUtc, + }; + + public string ConsumerName => document.ClaimedBy ?? string.Empty; + + public Task CompleteAsync(CancellationToken cancellationToken = default) + { + return outbox.CompleteAsync(document.OutboxId, ConsumerName, cancellationToken); + } + + public Task AbandonAsync(CancellationToken cancellationToken = default) + { + return outbox.AbandonAsync(document.OutboxId, ConsumerName, cancellationToken); + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + } + + internal sealed class WakeOutboxDocument + { + [BsonId] + public string OutboxId { get; set; } = string.Empty; + public string SignalId { get; set; } = string.Empty; + public string WorkflowInstanceId { get; set; } = string.Empty; + public string RuntimeProvider { get; set; } = string.Empty; + public string SignalType { get; set; } = string.Empty; + public DateTime? DueAtUtc { get; set; } + public DateTime CreatedOnUtc { get; set; } + public string? ClaimedBy { get; set; } + public DateTime? ClaimedUntilUtc { get; set; } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/StellaOps.Workflow.DataStore.MongoDB.csproj b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/StellaOps.Workflow.DataStore.MongoDB.csproj new file mode 100644 index 000000000..bd0e145d7 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/StellaOps.Workflow.DataStore.MongoDB.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/WorkflowStoreMongoOptions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/WorkflowStoreMongoOptions.cs new file mode 100644 index 000000000..191a6a39e --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.MongoDB/WorkflowStoreMongoOptions.cs @@ -0,0 +1,21 @@ +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.DataStore.MongoDB; + +public sealed class WorkflowStoreMongoOptions +{ + public const string SectionName = $"{WorkflowBackendOptions.SectionName}:Mongo"; + + public string ConnectionStringName { get; set; } = "WorkflowMongo"; + public string DatabaseName { get; set; } = "serdica_workflow_store"; + public string RuntimeStatesCollectionName { get; set; } = "workflow_runtime_states"; + public string HostedJobLocksCollectionName { get; set; } = "workflow_host_locks"; + public string InstancesCollectionName { get; set; } = "workflow_instances"; + public string TasksCollectionName { get; set; } = "workflow_tasks"; + public string TaskEventsCollectionName { get; set; } = "workflow_task_events"; + public string SignalQueueCollectionName { get; set; } = "workflow_signals"; + public string DeadLetterCollectionName { get; set; } = "workflow_signal_dead_letters"; + public string WakeOutboxCollectionName { get; set; } = "workflow_signal_wake_outbox"; + public int ClaimTimeoutSeconds { get; set; } = 60; + public int BlockingWaitSeconds { get; set; } = 30; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowHostedJobLockEntity.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowHostedJobLockEntity.cs new file mode 100644 index 000000000..34c36082a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowHostedJobLockEntity.cs @@ -0,0 +1,11 @@ +using System; + +namespace StellaOps.Workflow.DataStore.Oracle.Entities; + +public class WorkflowHostedJobLockEntity +{ + public string LockName { get; set; } = string.Empty; + public string LockOwner { get; set; } = string.Empty; + public DateTime AcquiredOnUtc { get; set; } = DateTime.UtcNow; + public DateTime ExpiresOnUtc { get; set; } = DateTime.UtcNow; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowInstanceProjection.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowInstanceProjection.cs new file mode 100644 index 000000000..c5dcfa961 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowInstanceProjection.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace StellaOps.Workflow.DataStore.Oracle.Entities; + +public class WorkflowInstanceProjection +{ + public decimal Id { get; set; } + public string WorkflowInstanceId { get; set; } = string.Empty; + public string WorkflowName { get; set; } = string.Empty; + public string WorkflowVersion { get; set; } = "1.0.0"; + public string? BusinessReferenceKey { get; set; } + public string? BusinessReferenceJson { get; set; } + public string Status { get; set; } = "Open"; + public string StateJson { get; set; } = "{}"; + public DateTime CreatedOnUtc { get; set; } = DateTime.UtcNow; + public DateTime? CompletedOnUtc { get; set; } + public DateTime? StaleAfterUtc { get; set; } + public DateTime? PurgeAfterUtc { get; set; } + public virtual ICollection Tasks { get; set; } = new HashSet(); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowRuntimeStateEntity.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowRuntimeStateEntity.cs new file mode 100644 index 000000000..d4ed0c2e0 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowRuntimeStateEntity.cs @@ -0,0 +1,22 @@ +using System; + +namespace StellaOps.Workflow.DataStore.Oracle.Entities; + +public class WorkflowRuntimeStateEntity +{ + public string WorkflowInstanceId { get; set; } = string.Empty; + public string WorkflowName { get; set; } = string.Empty; + public string WorkflowVersion { get; set; } = "1.0.0"; + public long Version { get; set; } + public string? BusinessReferenceKey { get; set; } + public string? BusinessReferenceJson { get; set; } + public string RuntimeProvider { get; set; } = "Elsa"; + public string RuntimeInstanceId { get; set; } = string.Empty; + public string RuntimeStatus { get; set; } = "Open"; + public string StateJson { get; set; } = "{}"; + public DateTime CreatedOnUtc { get; set; } = DateTime.UtcNow; + public DateTime? CompletedOnUtc { get; set; } + public DateTime? StaleAfterUtc { get; set; } + public DateTime? PurgeAfterUtc { get; set; } + public DateTime LastUpdatedOnUtc { get; set; } = DateTime.UtcNow; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowTaskEvent.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowTaskEvent.cs new file mode 100644 index 000000000..ae37606c4 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowTaskEvent.cs @@ -0,0 +1,14 @@ +using System; + +namespace StellaOps.Workflow.DataStore.Oracle.Entities; + +public class WorkflowTaskEvent +{ + public decimal Id { get; set; } + public string WorkflowTaskId { get; set; } = string.Empty; + public string EventType { get; set; } = string.Empty; + public string? ActorId { get; set; } + public string PayloadJson { get; set; } = "{}"; + public DateTime CreatedOnUtc { get; set; } = DateTime.UtcNow; + public virtual WorkflowTaskProjection? WorkflowTask { get; set; } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowTaskProjection.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowTaskProjection.cs new file mode 100644 index 000000000..44d332e9d --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/Entities/WorkflowTaskProjection.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; + +namespace StellaOps.Workflow.DataStore.Oracle.Entities; + +public class WorkflowTaskProjection +{ + public decimal Id { get; set; } + public string WorkflowTaskId { get; set; } = string.Empty; + public string WorkflowInstanceId { get; set; } = string.Empty; + public string WorkflowName { get; set; } = string.Empty; + public string WorkflowVersion { get; set; } = "1.0.0"; + public string TaskName { get; set; } = string.Empty; + public string TaskType { get; set; } = string.Empty; + public string Route { get; set; } = string.Empty; + public string? BusinessReferenceKey { get; set; } + public string? BusinessReferenceJson { get; set; } + public string? Assignee { get; set; } + public string Status { get; set; } = "Open"; + public string WorkflowRolesJson { get; set; } = "[]"; + public string TaskRolesJson { get; set; } = "[]"; + public string RuntimeRolesJson { get; set; } = "[]"; + public string EffectiveRolesJson { get; set; } = "[]"; + public string PayloadJson { get; set; } = "{}"; + public DateTime CreatedOnUtc { get; set; } = DateTime.UtcNow; + public DateTime? CompletedOnUtc { get; set; } + public DateTime? StaleAfterUtc { get; set; } + public DateTime? PurgeAfterUtc { get; set; } + public virtual WorkflowInstanceProjection? WorkflowInstance { get; set; } + public virtual ICollection Events { get; set; } = new HashSet(); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/OptimizedWorkflowDbContextAssemblyAttributes.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/OptimizedWorkflowDbContextAssemblyAttributes.cs new file mode 100644 index 000000000..9aaa5426f --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/OptimizedWorkflowDbContextAssemblyAttributes.cs @@ -0,0 +1,8 @@ +// +using StellaOps.Workflow.DataStore.Oracle; +using Microsoft.EntityFrameworkCore.Infrastructure; + +#pragma warning disable 219, 612, 618 +#nullable disable + +[assembly: DbContextModel(typeof(OptimizedWorkflowDbContext), typeof(OptimizedWorkflowDbContextModel))] diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/OptimizedWorkflowDbContextModel.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/OptimizedWorkflowDbContextModel.cs new file mode 100644 index 000000000..87786e38d --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/OptimizedWorkflowDbContextModel.cs @@ -0,0 +1,47 @@ +// +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace StellaOps.Workflow.DataStore.Oracle +{ + [DbContext(typeof(OptimizedWorkflowDbContext))] + public partial class OptimizedWorkflowDbContextModel : RuntimeModel + { + private static readonly bool _useOldBehavior31751 = + System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751; + + static OptimizedWorkflowDbContextModel() + { + var model = new OptimizedWorkflowDbContextModel(); + + if (_useOldBehavior31751) + { + model.Initialize(); + } + else + { + var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024); + thread.Start(); + thread.Join(); + + void RunInitialization() + { + model.Initialize(); + } + } + + model.Customize(); + _instance = (OptimizedWorkflowDbContextModel)model.FinalizeModel(); + } + + private static OptimizedWorkflowDbContextModel _instance; + public static IModel Instance => _instance; + + partial void Initialize(); + + partial void Customize(); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/OptimizedWorkflowDbContextModelBuilder.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/OptimizedWorkflowDbContextModelBuilder.cs new file mode 100644 index 000000000..a0eaf902c --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/OptimizedWorkflowDbContextModelBuilder.cs @@ -0,0 +1,68 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Oracle.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace StellaOps.Workflow.DataStore.Oracle +{ + public partial class OptimizedWorkflowDbContextModel + { + private OptimizedWorkflowDbContextModel() + : base(skipDetectChanges: false, modelId: new Guid("369ee602-d994-46ac-bdae-c2eddd92ccb0"), entityTypeCount: 5) + { + } + + partial void Initialize() + { + var workflowHostedJobLockEntity = WorkflowHostedJobLockEntityEntityType.Create(this); + var workflowInstanceProjection = WorkflowInstanceProjectionEntityType.Create(this); + var workflowRuntimeStateEntity = WorkflowRuntimeStateEntityEntityType.Create(this); + var workflowTaskEvent = WorkflowTaskEventEntityType.Create(this); + var workflowTaskProjection = WorkflowTaskProjectionEntityType.Create(this); + + WorkflowTaskEventEntityType.CreateForeignKey1(workflowTaskEvent, workflowTaskProjection); + WorkflowTaskProjectionEntityType.CreateForeignKey1(workflowTaskProjection, workflowInstanceProjection); + + WorkflowHostedJobLockEntityEntityType.CreateAnnotations(workflowHostedJobLockEntity); + WorkflowInstanceProjectionEntityType.CreateAnnotations(workflowInstanceProjection); + WorkflowRuntimeStateEntityEntityType.CreateAnnotations(workflowRuntimeStateEntity); + WorkflowTaskEventEntityType.CreateAnnotations(workflowTaskEvent); + WorkflowTaskProjectionEntityType.CreateAnnotations(workflowTaskProjection); + + var sequences = new Dictionary<(string, string), ISequence>(); + var wF_INSTANCES_SEQ = new RuntimeSequence( + "WF_INSTANCES_SEQ", + this, + typeof(decimal), + schema: "SRD_WFKLW"); + + sequences[("WF_INSTANCES_SEQ", "SRD_WFKLW")] = wF_INSTANCES_SEQ; + + var wF_TASK_EVENTS_SEQ = new RuntimeSequence( + "WF_TASK_EVENTS_SEQ", + this, + typeof(decimal), + schema: "SRD_WFKLW"); + + sequences[("WF_TASK_EVENTS_SEQ", "SRD_WFKLW")] = wF_TASK_EVENTS_SEQ; + + var wF_TASKS_SEQ = new RuntimeSequence( + "WF_TASKS_SEQ", + this, + typeof(decimal), + schema: "SRD_WFKLW"); + + sequences[("WF_TASKS_SEQ", "SRD_WFKLW")] = wF_TASKS_SEQ; + + AddAnnotation("Relational:Sequences", sequences); + AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.IdentityColumn); + AddAnnotation("ProductVersion", "9.0.5"); + AddAnnotation("Relational:MaxIdentifierLength", 128); + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowHostedJobLockEntityEntityType.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowHostedJobLockEntityEntityType.cs new file mode 100644 index 000000000..b2aa85633 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowHostedJobLockEntityEntityType.cs @@ -0,0 +1,87 @@ +// +using System; +using System.Reflection; +using StellaOps.Workflow.DataStore.Oracle.Entities; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Oracle.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace StellaOps.Workflow.DataStore.Oracle +{ + [EntityFrameworkInternal] + public partial class WorkflowHostedJobLockEntityEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "StellaOps.Workflow.DataStore.Oracle.Entities.WorkflowHostedJobLockEntity", + typeof(WorkflowHostedJobLockEntity), + baseEntityType, + propertyCount: 4, + keyCount: 1); + + var lockName = runtimeEntityType.AddProperty( + "LockName", + typeof(string), + propertyInfo: typeof(WorkflowHostedJobLockEntity).GetProperty("LockName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowHostedJobLockEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + afterSaveBehavior: PropertySaveBehavior.Throw, + maxLength: 128); + lockName.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + lockName.AddAnnotation("Relational:ColumnName", "LOCK_NAME"); + + var acquiredOnUtc = runtimeEntityType.AddProperty( + "AcquiredOnUtc", + typeof(DateTime), + propertyInfo: typeof(WorkflowHostedJobLockEntity).GetProperty("AcquiredOnUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowHostedJobLockEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + acquiredOnUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + acquiredOnUtc.AddAnnotation("Relational:ColumnName", "ACQUIRED_ON_UTC"); + acquiredOnUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var expiresOnUtc = runtimeEntityType.AddProperty( + "ExpiresOnUtc", + typeof(DateTime), + propertyInfo: typeof(WorkflowHostedJobLockEntity).GetProperty("ExpiresOnUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowHostedJobLockEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + expiresOnUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + expiresOnUtc.AddAnnotation("Relational:ColumnName", "EXPIRES_ON_UTC"); + expiresOnUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var lockOwner = runtimeEntityType.AddProperty( + "LockOwner", + typeof(string), + propertyInfo: typeof(WorkflowHostedJobLockEntity).GetProperty("LockOwner", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowHostedJobLockEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 256); + lockOwner.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + lockOwner.AddAnnotation("Relational:ColumnName", "LOCK_OWNER"); + + var key = runtimeEntityType.AddKey( + new[] { lockName }); + runtimeEntityType.SetPrimaryKey(key); + key.AddAnnotation("Relational:Name", "WF_HOST_LOCKS_PK"); + + return runtimeEntityType; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", "SRD_WFKLW"); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "WF_HOST_LOCKS"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowInstanceProjectionEntityType.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowInstanceProjectionEntityType.cs new file mode 100644 index 000000000..accc44ead --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowInstanceProjectionEntityType.cs @@ -0,0 +1,185 @@ +// +using System; +using System.Reflection; +using StellaOps.Workflow.DataStore.Oracle.Entities; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Oracle.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace StellaOps.Workflow.DataStore.Oracle +{ + [EntityFrameworkInternal] + public partial class WorkflowInstanceProjectionEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "StellaOps.Workflow.DataStore.Oracle.Entities.WorkflowInstanceProjection", + typeof(WorkflowInstanceProjection), + baseEntityType, + propertyCount: 12, + navigationCount: 1, + namedIndexCount: 3, + keyCount: 2); + + var id = runtimeEntityType.AddProperty( + "Id", + typeof(decimal), + propertyInfo: typeof(WorkflowInstanceProjection).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowInstanceProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + afterSaveBehavior: PropertySaveBehavior.Throw, + precision: 18, + scale: 0, + sentinel: 0m); + id.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + id.AddAnnotation("Relational:ColumnName", "WF_INSTANCE_PK"); + id.AddAnnotation("Relational:DefaultValueSql", "\"SRD_WFKLW\".\"WF_INSTANCES_SEQ\".\"NEXTVAL\" "); + + var businessReferenceJson = runtimeEntityType.AddProperty( + "BusinessReferenceJson", + typeof(string), + propertyInfo: typeof(WorkflowInstanceProjection).GetProperty("BusinessReferenceJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowInstanceProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + businessReferenceJson.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + businessReferenceJson.AddAnnotation("Relational:ColumnName", "BUSINESS_REFERENCE_JSON"); + businessReferenceJson.AddAnnotation("Relational:ColumnType", "CLOB"); + + var businessReferenceKey = runtimeEntityType.AddProperty( + "BusinessReferenceKey", + typeof(string), + propertyInfo: typeof(WorkflowInstanceProjection).GetProperty("BusinessReferenceKey", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowInstanceProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true, + maxLength: 128); + businessReferenceKey.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + businessReferenceKey.AddAnnotation("Relational:ColumnName", "BUSINESS_ID"); + + var completedOnUtc = runtimeEntityType.AddProperty( + "CompletedOnUtc", + typeof(DateTime?), + propertyInfo: typeof(WorkflowInstanceProjection).GetProperty("CompletedOnUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowInstanceProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + completedOnUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + completedOnUtc.AddAnnotation("Relational:ColumnName", "COMPLETED_ON_UTC"); + completedOnUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var createdOnUtc = runtimeEntityType.AddProperty( + "CreatedOnUtc", + typeof(DateTime), + propertyInfo: typeof(WorkflowInstanceProjection).GetProperty("CreatedOnUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowInstanceProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + createdOnUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + createdOnUtc.AddAnnotation("Relational:ColumnName", "CREATED_ON_UTC"); + createdOnUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var purgeAfterUtc = runtimeEntityType.AddProperty( + "PurgeAfterUtc", + typeof(DateTime?), + propertyInfo: typeof(WorkflowInstanceProjection).GetProperty("PurgeAfterUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowInstanceProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + purgeAfterUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + purgeAfterUtc.AddAnnotation("Relational:ColumnName", "PURGE_AFTER_UTC"); + purgeAfterUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var staleAfterUtc = runtimeEntityType.AddProperty( + "StaleAfterUtc", + typeof(DateTime?), + propertyInfo: typeof(WorkflowInstanceProjection).GetProperty("StaleAfterUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowInstanceProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + staleAfterUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + staleAfterUtc.AddAnnotation("Relational:ColumnName", "STALE_AFTER_UTC"); + staleAfterUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var stateJson = runtimeEntityType.AddProperty( + "StateJson", + typeof(string), + propertyInfo: typeof(WorkflowInstanceProjection).GetProperty("StateJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowInstanceProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + stateJson.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + stateJson.AddAnnotation("Relational:ColumnName", "STATE_JSON"); + stateJson.AddAnnotation("Relational:ColumnType", "CLOB"); + + var status = runtimeEntityType.AddProperty( + "Status", + typeof(string), + propertyInfo: typeof(WorkflowInstanceProjection).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowInstanceProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 32); + status.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + status.AddAnnotation("Relational:ColumnName", "STATUS"); + + var workflowInstanceId = runtimeEntityType.AddProperty( + "WorkflowInstanceId", + typeof(string), + propertyInfo: typeof(WorkflowInstanceProjection).GetProperty("WorkflowInstanceId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowInstanceProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + afterSaveBehavior: PropertySaveBehavior.Throw, + maxLength: 128); + workflowInstanceId.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + workflowInstanceId.AddAnnotation("Relational:ColumnName", "WF_INSTANCE_ID"); + + var workflowName = runtimeEntityType.AddProperty( + "WorkflowName", + typeof(string), + propertyInfo: typeof(WorkflowInstanceProjection).GetProperty("WorkflowName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowInstanceProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 128); + workflowName.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + workflowName.AddAnnotation("Relational:ColumnName", "WF_NAME"); + + var workflowVersion = runtimeEntityType.AddProperty( + "WorkflowVersion", + typeof(string), + propertyInfo: typeof(WorkflowInstanceProjection).GetProperty("WorkflowVersion", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowInstanceProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 64); + workflowVersion.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + workflowVersion.AddAnnotation("Relational:ColumnName", "WF_VERSION"); + + var key = runtimeEntityType.AddKey( + new[] { id }); + runtimeEntityType.SetPrimaryKey(key); + key.AddAnnotation("Relational:Name", "WF_INSTANCES_PK"); + + var key0 = runtimeEntityType.AddKey( + new[] { workflowInstanceId }); + key0.AddAnnotation("Relational:Name", "WF_INSTANCES_ID_UK"); + + var wF_INSTANCES_BID_IX = runtimeEntityType.AddIndex( + new[] { businessReferenceKey }, + name: "WF_INSTANCES_BID_IX"); + + var wF_INSTANCES_STATUS_IX = runtimeEntityType.AddIndex( + new[] { status }, + name: "WF_INSTANCES_STATUS_IX"); + + var wF_INSTANCES_WF_IX = runtimeEntityType.AddIndex( + new[] { workflowName, workflowVersion }, + name: "WF_INSTANCES_WF_IX"); + + return runtimeEntityType; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", "SRD_WFKLW"); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "WF_INSTANCES"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowRuntimeStateEntityEntityType.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowRuntimeStateEntityEntityType.cs new file mode 100644 index 000000000..bc6ab90b1 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowRuntimeStateEntityEntityType.cs @@ -0,0 +1,205 @@ +// +using System; +using System.Reflection; +using StellaOps.Workflow.DataStore.Oracle.Entities; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Oracle.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace StellaOps.Workflow.DataStore.Oracle +{ + [EntityFrameworkInternal] + public partial class WorkflowRuntimeStateEntityEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "StellaOps.Workflow.DataStore.Oracle.Entities.WorkflowRuntimeStateEntity", + typeof(WorkflowRuntimeStateEntity), + baseEntityType, + propertyCount: 15, + namedIndexCount: 3, + keyCount: 1); + + var workflowInstanceId = runtimeEntityType.AddProperty( + "WorkflowInstanceId", + typeof(string), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("WorkflowInstanceId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + afterSaveBehavior: PropertySaveBehavior.Throw, + maxLength: 128); + workflowInstanceId.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + workflowInstanceId.AddAnnotation("Relational:ColumnName", "WF_INSTANCE_ID"); + + var businessReferenceJson = runtimeEntityType.AddProperty( + "BusinessReferenceJson", + typeof(string), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("BusinessReferenceJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + businessReferenceJson.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + businessReferenceJson.AddAnnotation("Relational:ColumnName", "BUSINESS_REFERENCE_JSON"); + businessReferenceJson.AddAnnotation("Relational:ColumnType", "CLOB"); + + var businessReferenceKey = runtimeEntityType.AddProperty( + "BusinessReferenceKey", + typeof(string), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("BusinessReferenceKey", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true, + maxLength: 128); + businessReferenceKey.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + businessReferenceKey.AddAnnotation("Relational:ColumnName", "BUSINESS_ID"); + + var completedOnUtc = runtimeEntityType.AddProperty( + "CompletedOnUtc", + typeof(DateTime?), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("CompletedOnUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + completedOnUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + completedOnUtc.AddAnnotation("Relational:ColumnName", "COMPLETED_ON_UTC"); + completedOnUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var createdOnUtc = runtimeEntityType.AddProperty( + "CreatedOnUtc", + typeof(DateTime), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("CreatedOnUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + createdOnUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + createdOnUtc.AddAnnotation("Relational:ColumnName", "CREATED_ON_UTC"); + createdOnUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var lastUpdatedOnUtc = runtimeEntityType.AddProperty( + "LastUpdatedOnUtc", + typeof(DateTime), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("LastUpdatedOnUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + lastUpdatedOnUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + lastUpdatedOnUtc.AddAnnotation("Relational:ColumnName", "LAST_UPDATED_ON_UTC"); + lastUpdatedOnUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var purgeAfterUtc = runtimeEntityType.AddProperty( + "PurgeAfterUtc", + typeof(DateTime?), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("PurgeAfterUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + purgeAfterUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + purgeAfterUtc.AddAnnotation("Relational:ColumnName", "PURGE_AFTER_UTC"); + purgeAfterUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var runtimeInstanceId = runtimeEntityType.AddProperty( + "RuntimeInstanceId", + typeof(string), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("RuntimeInstanceId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 128); + runtimeInstanceId.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + runtimeInstanceId.AddAnnotation("Relational:ColumnName", "RUNTIME_INSTANCE_ID"); + + var runtimeProvider = runtimeEntityType.AddProperty( + "RuntimeProvider", + typeof(string), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("RuntimeProvider", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 64); + runtimeProvider.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + runtimeProvider.AddAnnotation("Relational:ColumnName", "RUNTIME_PROVIDER"); + + var runtimeStatus = runtimeEntityType.AddProperty( + "RuntimeStatus", + typeof(string), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("RuntimeStatus", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 32); + runtimeStatus.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + runtimeStatus.AddAnnotation("Relational:ColumnName", "RUNTIME_STATUS"); + + var version = runtimeEntityType.AddProperty( + "Version", + typeof(long), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("Version", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: 0L); + version.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + version.AddAnnotation("Relational:ColumnName", "VERSION_NO"); + version.AddAnnotation("Relational:ColumnType", "NUMBER(18)"); + + var staleAfterUtc = runtimeEntityType.AddProperty( + "StaleAfterUtc", + typeof(DateTime?), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("StaleAfterUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + staleAfterUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + staleAfterUtc.AddAnnotation("Relational:ColumnName", "STALE_AFTER_UTC"); + staleAfterUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var stateJson = runtimeEntityType.AddProperty( + "StateJson", + typeof(string), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("StateJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + stateJson.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + stateJson.AddAnnotation("Relational:ColumnName", "STATE_JSON"); + stateJson.AddAnnotation("Relational:ColumnType", "CLOB"); + + var workflowName = runtimeEntityType.AddProperty( + "WorkflowName", + typeof(string), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("WorkflowName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 128); + workflowName.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + workflowName.AddAnnotation("Relational:ColumnName", "WF_NAME"); + + var workflowVersion = runtimeEntityType.AddProperty( + "WorkflowVersion", + typeof(string), + propertyInfo: typeof(WorkflowRuntimeStateEntity).GetProperty("WorkflowVersion", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowRuntimeStateEntity).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 64); + workflowVersion.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + workflowVersion.AddAnnotation("Relational:ColumnName", "WF_VERSION"); + + var key = runtimeEntityType.AddKey( + new[] { workflowInstanceId }); + runtimeEntityType.SetPrimaryKey(key); + key.AddAnnotation("Relational:Name", "WF_RUNTIME_STATES_PK"); + + var wF_RUNTIME_STATES_BID_IX = runtimeEntityType.AddIndex( + new[] { businessReferenceKey }, + name: "WF_RUNTIME_STATES_BID_IX"); + + var wF_RUNTIME_STATES_STATUS_IX = runtimeEntityType.AddIndex( + new[] { runtimeStatus }, + name: "WF_RUNTIME_STATES_STATUS_IX"); + + var wF_RUNTIME_STATES_WF_IX = runtimeEntityType.AddIndex( + new[] { workflowName, workflowVersion }, + name: "WF_RUNTIME_STATES_WF_IX"); + + return runtimeEntityType; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", "SRD_WFKLW"); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "WF_RUNTIME_STATES"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowTaskEventEntityType.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowTaskEventEntityType.cs new file mode 100644 index 000000000..6ee434746 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowTaskEventEntityType.cs @@ -0,0 +1,143 @@ +// +using System; +using System.Collections.Generic; +using System.Reflection; +using StellaOps.Workflow.DataStore.Oracle.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Oracle.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace StellaOps.Workflow.DataStore.Oracle +{ + [EntityFrameworkInternal] + public partial class WorkflowTaskEventEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "StellaOps.Workflow.DataStore.Oracle.Entities.WorkflowTaskEvent", + typeof(WorkflowTaskEvent), + baseEntityType, + propertyCount: 6, + navigationCount: 1, + foreignKeyCount: 1, + namedIndexCount: 1, + keyCount: 1); + + var id = runtimeEntityType.AddProperty( + "Id", + typeof(decimal), + propertyInfo: typeof(WorkflowTaskEvent).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskEvent).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + afterSaveBehavior: PropertySaveBehavior.Throw, + precision: 18, + scale: 0, + sentinel: 0m); + id.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + id.AddAnnotation("Relational:ColumnName", "WF_TASK_EVENT_PK"); + id.AddAnnotation("Relational:DefaultValueSql", "\"SRD_WFKLW\".\"WF_TASK_EVENTS_SEQ\".\"NEXTVAL\" "); + + var actorId = runtimeEntityType.AddProperty( + "ActorId", + typeof(string), + propertyInfo: typeof(WorkflowTaskEvent).GetProperty("ActorId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskEvent).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true, + maxLength: 128); + actorId.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + actorId.AddAnnotation("Relational:ColumnName", "ACTOR_ID"); + + var createdOnUtc = runtimeEntityType.AddProperty( + "CreatedOnUtc", + typeof(DateTime), + propertyInfo: typeof(WorkflowTaskEvent).GetProperty("CreatedOnUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskEvent).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + createdOnUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + createdOnUtc.AddAnnotation("Relational:ColumnName", "CREATED_ON_UTC"); + createdOnUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var eventType = runtimeEntityType.AddProperty( + "EventType", + typeof(string), + propertyInfo: typeof(WorkflowTaskEvent).GetProperty("EventType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskEvent).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 64); + eventType.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + eventType.AddAnnotation("Relational:ColumnName", "EVENT_TYPE"); + + var payloadJson = runtimeEntityType.AddProperty( + "PayloadJson", + typeof(string), + propertyInfo: typeof(WorkflowTaskEvent).GetProperty("PayloadJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskEvent).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + payloadJson.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + payloadJson.AddAnnotation("Relational:ColumnName", "PAYLOAD_JSON"); + payloadJson.AddAnnotation("Relational:ColumnType", "CLOB"); + + var workflowTaskId = runtimeEntityType.AddProperty( + "WorkflowTaskId", + typeof(string), + propertyInfo: typeof(WorkflowTaskEvent).GetProperty("WorkflowTaskId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskEvent).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 128); + workflowTaskId.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + workflowTaskId.AddAnnotation("Relational:ColumnName", "WF_TASK_ID"); + + var key = runtimeEntityType.AddKey( + new[] { id }); + runtimeEntityType.SetPrimaryKey(key); + key.AddAnnotation("Relational:Name", "WF_TASK_EVENTS_PK"); + + var wF_TASK_EVENTS_TASK_TIME_IX = runtimeEntityType.AddIndex( + new[] { workflowTaskId, createdOnUtc }, + name: "WF_TASK_EVENTS_TASK_TIME_IX"); + + return runtimeEntityType; + } + + public static RuntimeForeignKey CreateForeignKey1(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType) + { + var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("WorkflowTaskId") }, + principalEntityType.FindKey(new[] { principalEntityType.FindProperty("WorkflowTaskId") }), + principalEntityType, + deleteBehavior: DeleteBehavior.Cascade, + required: true); + + var workflowTask = declaringEntityType.AddNavigation("WorkflowTask", + runtimeForeignKey, + onDependent: true, + typeof(WorkflowTaskProjection), + propertyInfo: typeof(WorkflowTaskEvent).GetProperty("WorkflowTask", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskEvent).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var events = principalEntityType.AddNavigation("Events", + runtimeForeignKey, + onDependent: false, + typeof(ICollection), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("Events", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + runtimeForeignKey.AddAnnotation("Relational:Name", "WF_TASK_EVENTS_TASK_FK"); + return runtimeForeignKey; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", "SRD_WFKLW"); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "WF_TASK_EVENTS"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowTaskProjectionEntityType.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowTaskProjectionEntityType.cs new file mode 100644 index 000000000..518c7de54 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/EntityTypes/WorkflowTaskProjectionEntityType.cs @@ -0,0 +1,300 @@ +// +using System; +using System.Collections.Generic; +using System.Reflection; +using StellaOps.Workflow.DataStore.Oracle.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Oracle.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace StellaOps.Workflow.DataStore.Oracle +{ + [EntityFrameworkInternal] + public partial class WorkflowTaskProjectionEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "StellaOps.Workflow.DataStore.Oracle.Entities.WorkflowTaskProjection", + typeof(WorkflowTaskProjection), + baseEntityType, + propertyCount: 21, + navigationCount: 2, + foreignKeyCount: 1, + namedIndexCount: 4, + keyCount: 2); + + var id = runtimeEntityType.AddProperty( + "Id", + typeof(decimal), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + afterSaveBehavior: PropertySaveBehavior.Throw, + precision: 18, + scale: 0, + sentinel: 0m); + id.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + id.AddAnnotation("Relational:ColumnName", "WF_TASK_PK"); + id.AddAnnotation("Relational:DefaultValueSql", "\"SRD_WFKLW\".\"WF_TASKS_SEQ\".\"NEXTVAL\" "); + + var assignee = runtimeEntityType.AddProperty( + "Assignee", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("Assignee", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true, + maxLength: 128); + assignee.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + assignee.AddAnnotation("Relational:ColumnName", "ASSIGNEE"); + + var businessReferenceJson = runtimeEntityType.AddProperty( + "BusinessReferenceJson", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("BusinessReferenceJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + businessReferenceJson.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + businessReferenceJson.AddAnnotation("Relational:ColumnName", "BUSINESS_REFERENCE_JSON"); + businessReferenceJson.AddAnnotation("Relational:ColumnType", "CLOB"); + + var businessReferenceKey = runtimeEntityType.AddProperty( + "BusinessReferenceKey", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("BusinessReferenceKey", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true, + maxLength: 128); + businessReferenceKey.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + businessReferenceKey.AddAnnotation("Relational:ColumnName", "BUSINESS_ID"); + + var completedOnUtc = runtimeEntityType.AddProperty( + "CompletedOnUtc", + typeof(DateTime?), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("CompletedOnUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + completedOnUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + completedOnUtc.AddAnnotation("Relational:ColumnName", "COMPLETED_ON_UTC"); + completedOnUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var createdOnUtc = runtimeEntityType.AddProperty( + "CreatedOnUtc", + typeof(DateTime), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("CreatedOnUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + createdOnUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + createdOnUtc.AddAnnotation("Relational:ColumnName", "CREATED_ON_UTC"); + createdOnUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var effectiveRolesJson = runtimeEntityType.AddProperty( + "EffectiveRolesJson", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("EffectiveRolesJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + effectiveRolesJson.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + effectiveRolesJson.AddAnnotation("Relational:ColumnName", "EFFECTIVE_ROLES_JSON"); + effectiveRolesJson.AddAnnotation("Relational:ColumnType", "CLOB"); + + var payloadJson = runtimeEntityType.AddProperty( + "PayloadJson", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("PayloadJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + payloadJson.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + payloadJson.AddAnnotation("Relational:ColumnName", "PAYLOAD_JSON"); + payloadJson.AddAnnotation("Relational:ColumnType", "CLOB"); + + var purgeAfterUtc = runtimeEntityType.AddProperty( + "PurgeAfterUtc", + typeof(DateTime?), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("PurgeAfterUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + purgeAfterUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + purgeAfterUtc.AddAnnotation("Relational:ColumnName", "PURGE_AFTER_UTC"); + purgeAfterUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var route = runtimeEntityType.AddProperty( + "Route", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("Route", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 256); + route.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + route.AddAnnotation("Relational:ColumnName", "ROUTE"); + + var runtimeRolesJson = runtimeEntityType.AddProperty( + "RuntimeRolesJson", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("RuntimeRolesJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + runtimeRolesJson.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + runtimeRolesJson.AddAnnotation("Relational:ColumnName", "RUNTIME_ROLES_JSON"); + runtimeRolesJson.AddAnnotation("Relational:ColumnType", "CLOB"); + + var staleAfterUtc = runtimeEntityType.AddProperty( + "StaleAfterUtc", + typeof(DateTime?), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("StaleAfterUtc", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + staleAfterUtc.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + staleAfterUtc.AddAnnotation("Relational:ColumnName", "STALE_AFTER_UTC"); + staleAfterUtc.AddAnnotation("Relational:ColumnType", "TIMESTAMP(6)"); + + var status = runtimeEntityType.AddProperty( + "Status", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 32); + status.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + status.AddAnnotation("Relational:ColumnName", "STATUS"); + + var taskName = runtimeEntityType.AddProperty( + "TaskName", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("TaskName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 256); + taskName.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + taskName.AddAnnotation("Relational:ColumnName", "TASK_NAME"); + + var taskRolesJson = runtimeEntityType.AddProperty( + "TaskRolesJson", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("TaskRolesJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + taskRolesJson.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + taskRolesJson.AddAnnotation("Relational:ColumnName", "TASK_ROLES_JSON"); + taskRolesJson.AddAnnotation("Relational:ColumnType", "CLOB"); + + var taskType = runtimeEntityType.AddProperty( + "TaskType", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("TaskType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 128); + taskType.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + taskType.AddAnnotation("Relational:ColumnName", "TASK_TYPE"); + + var workflowInstanceId = runtimeEntityType.AddProperty( + "WorkflowInstanceId", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("WorkflowInstanceId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 128); + workflowInstanceId.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + workflowInstanceId.AddAnnotation("Relational:ColumnName", "WF_INSTANCE_ID"); + + var workflowName = runtimeEntityType.AddProperty( + "WorkflowName", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("WorkflowName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 128); + workflowName.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + workflowName.AddAnnotation("Relational:ColumnName", "WF_NAME"); + + var workflowRolesJson = runtimeEntityType.AddProperty( + "WorkflowRolesJson", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("WorkflowRolesJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + workflowRolesJson.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + workflowRolesJson.AddAnnotation("Relational:ColumnName", "WORKFLOW_ROLES_JSON"); + workflowRolesJson.AddAnnotation("Relational:ColumnType", "CLOB"); + + var workflowTaskId = runtimeEntityType.AddProperty( + "WorkflowTaskId", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("WorkflowTaskId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + afterSaveBehavior: PropertySaveBehavior.Throw, + maxLength: 128); + workflowTaskId.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + workflowTaskId.AddAnnotation("Relational:ColumnName", "WF_TASK_ID"); + + var workflowVersion = runtimeEntityType.AddProperty( + "WorkflowVersion", + typeof(string), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("WorkflowVersion", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + maxLength: 64); + workflowVersion.AddAnnotation("Oracle:ValueGenerationStrategy", OracleValueGenerationStrategy.None); + workflowVersion.AddAnnotation("Relational:ColumnName", "WF_VERSION"); + + var key = runtimeEntityType.AddKey( + new[] { id }); + runtimeEntityType.SetPrimaryKey(key); + key.AddAnnotation("Relational:Name", "WF_TASKS_PK"); + + var key0 = runtimeEntityType.AddKey( + new[] { workflowTaskId }); + key0.AddAnnotation("Relational:Name", "WF_TASKS_ID_UK"); + + var wF_TASKS_ASSIGNEE_STATUS_IX = runtimeEntityType.AddIndex( + new[] { assignee, status }, + name: "WF_TASKS_ASSIGNEE_STATUS_IX"); + + var wF_TASKS_BID_IX = runtimeEntityType.AddIndex( + new[] { businessReferenceKey }, + name: "WF_TASKS_BID_IX"); + + var wF_TASKS_INSTANCE_STATUS_IX = runtimeEntityType.AddIndex( + new[] { workflowInstanceId, status }, + name: "WF_TASKS_INSTANCE_STATUS_IX"); + + var wF_TASKS_WF_IX = runtimeEntityType.AddIndex( + new[] { workflowName, workflowVersion }, + name: "WF_TASKS_WF_IX"); + + return runtimeEntityType; + } + + public static RuntimeForeignKey CreateForeignKey1(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType) + { + var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("WorkflowInstanceId") }, + principalEntityType.FindKey(new[] { principalEntityType.FindProperty("WorkflowInstanceId") }), + principalEntityType, + deleteBehavior: DeleteBehavior.Cascade, + required: true); + + var workflowInstance = declaringEntityType.AddNavigation("WorkflowInstance", + runtimeForeignKey, + onDependent: true, + typeof(WorkflowInstanceProjection), + propertyInfo: typeof(WorkflowTaskProjection).GetProperty("WorkflowInstance", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowTaskProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var tasks = principalEntityType.AddNavigation("Tasks", + runtimeForeignKey, + onDependent: false, + typeof(ICollection), + propertyInfo: typeof(WorkflowInstanceProjection).GetProperty("Tasks", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(WorkflowInstanceProjection).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + runtimeForeignKey.AddAnnotation("Relational:Name", "WF_TASKS_INSTANCE_FK"); + return runtimeForeignKey; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", "SRD_WFKLW"); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "WF_TASKS"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OptimizedWorkflowDbContext.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OptimizedWorkflowDbContext.cs new file mode 100644 index 000000000..e1c14fe14 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OptimizedWorkflowDbContext.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; + +namespace StellaOps.Workflow.DataStore.Oracle; + +public class OptimizedWorkflowDbContext : WorkflowDbContext +{ + public OptimizedWorkflowDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseOracle("DATA SOURCE=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=db.serdica.ablera.dev)(PORT=1521))(CONNECT_DATA=(SID=orcl1)));USER ID=srd_wfklw;PASSWORD=srd_wfklw"); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OptimizedWorkflowDbContextFactory.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OptimizedWorkflowDbContextFactory.cs new file mode 100644 index 000000000..6821bbae4 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OptimizedWorkflowDbContextFactory.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace StellaOps.Workflow.DataStore.Oracle; + +public class OptimizedWorkflowDbContextFactory : IDesignTimeDbContextFactory +{ + public OptimizedWorkflowDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseOracle("DATA SOURCE=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=db.serdica.ablera.dev)(PORT=1521))(CONNECT_DATA=(SID=orcl1)));USER ID=srd_wfklw;PASSWORD=srd_wfklw"); + return new OptimizedWorkflowDbContext(optionsBuilder.Options); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowDataStoreExtensions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowDataStoreExtensions.cs new file mode 100644 index 000000000..c39a268a7 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowDataStoreExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.EntityFrameworkCore; +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.DataStore.Oracle; + +public static class OracleWorkflowDataStoreExtensions +{ + public static IServiceCollection AddWorkflowOracleDataStore( + this IServiceCollection services, IConfiguration configuration) + { + // Register WorkflowDbContext with Oracle provider + // Register OracleWorkflowRuntimeStateStore + // Register OracleWorkflowHostedJobLockService + // Register EF-based projection/retention stores + return services; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowHostedJobLockService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowHostedJobLockService.cs new file mode 100644 index 000000000..8ba1d46d6 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowHostedJobLockService.cs @@ -0,0 +1,71 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.DataStore.Oracle.Entities; +using StellaOps.Workflow.Abstractions; + +using Microsoft.EntityFrameworkCore; + +namespace StellaOps.Workflow.DataStore.Oracle; + +public sealed class OracleWorkflowHostedJobLockService(WorkflowDbContext dbContext) : IWorkflowHostedJobLockService +{ + public async Task TryAcquireAsync( + string lockName, + string lockOwner, + DateTime acquiredOnUtc, + TimeSpan lease, + CancellationToken cancellationToken = default) + { + var expiresOnUtc = acquiredOnUtc.Add(lease); + var updated = await dbContext.Database.ExecuteSqlInterpolatedAsync( + $""" + UPDATE "SRD_WFKLW"."WF_HOST_LOCKS" + SET "LOCK_OWNER" = {lockOwner}, + "ACQUIRED_ON_UTC" = {acquiredOnUtc}, + "EXPIRES_ON_UTC" = {expiresOnUtc} + WHERE "LOCK_NAME" = {lockName} + AND ("EXPIRES_ON_UTC" <= {acquiredOnUtc} OR "LOCK_OWNER" = {lockOwner}) + """, + cancellationToken); + + if (updated > 0) + { + return true; + } + + dbContext.WorkflowHostedJobLocks.Add(new WorkflowHostedJobLockEntity + { + LockName = lockName, + LockOwner = lockOwner, + AcquiredOnUtc = acquiredOnUtc, + ExpiresOnUtc = expiresOnUtc, + }); + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + catch (DbUpdateException exception) when (exception.InnerException?.Message.Contains("ORA-00001", StringComparison.OrdinalIgnoreCase) == true) + { + dbContext.ChangeTracker.Clear(); + return false; + } + } + + public async Task ReleaseAsync( + string lockName, + string lockOwner, + CancellationToken cancellationToken = default) + { + await dbContext.Database.ExecuteSqlInterpolatedAsync( + $""" + DELETE FROM "SRD_WFKLW"."WF_HOST_LOCKS" + WHERE "LOCK_NAME" = {lockName} + AND "LOCK_OWNER" = {lockOwner} + """, + cancellationToken); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowRuntimeStateStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowRuntimeStateStore.cs new file mode 100644 index 000000000..b94ac309f --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowRuntimeStateStore.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.DataStore.Oracle.Entities; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +using Microsoft.EntityFrameworkCore; + +namespace StellaOps.Workflow.DataStore.Oracle; + +public sealed class OracleWorkflowRuntimeStateStore(WorkflowDbContext dbContext) : IWorkflowRuntimeStateStore +{ + private const string StaleRuntimeStatus = "Stale"; + + public async Task UpsertAsync( + WorkflowRuntimeStateRecord state, + CancellationToken cancellationToken = default) + { + var currentVersion = await dbContext.WorkflowRuntimeStates + .Where(x => x.WorkflowInstanceId == state.WorkflowInstanceId) + .Select(x => (long?)x.Version) + .SingleOrDefaultAsync(cancellationToken); + + if (currentVersion is null) + { + if (state.Version > 1) + { + throw new WorkflowRuntimeStateConcurrencyException(state.WorkflowInstanceId, state.Version, 0); + } + + dbContext.WorkflowRuntimeStates.Add(Map(state)); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + return; + } + catch (DbUpdateException) when (state.Version > 0) + { + throw new WorkflowRuntimeStateConcurrencyException( + state.WorkflowInstanceId, + state.Version, + await ResolveActualVersionAsync(state.WorkflowInstanceId, cancellationToken)); + } + } + + if (state.Version > 0 && currentVersion.Value + 1 != state.Version) + { + throw new WorkflowRuntimeStateConcurrencyException( + state.WorkflowInstanceId, + state.Version, + currentVersion.Value); + } + + var expectedCurrentVersion = state.Version > 0 + ? state.Version - 1 + : currentVersion.Value; + var businessReferenceKey = state.BusinessReference?.Key; + var businessReferenceJson = SerializeBusinessReference(state.BusinessReference); + var updatedRows = await dbContext.WorkflowRuntimeStates + .Where(x => x.WorkflowInstanceId == state.WorkflowInstanceId && x.Version == expectedCurrentVersion) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(x => x.WorkflowName, state.WorkflowName) + .SetProperty(x => x.WorkflowVersion, state.WorkflowVersion) + .SetProperty(x => x.Version, state.Version) + .SetProperty(x => x.BusinessReferenceKey, businessReferenceKey) + .SetProperty(x => x.BusinessReferenceJson, businessReferenceJson) + .SetProperty(x => x.RuntimeProvider, state.RuntimeProvider) + .SetProperty(x => x.RuntimeInstanceId, state.RuntimeInstanceId) + .SetProperty(x => x.RuntimeStatus, state.RuntimeStatus) + .SetProperty(x => x.StateJson, state.StateJson) + .SetProperty(x => x.CreatedOnUtc, state.CreatedOnUtc) + .SetProperty(x => x.CompletedOnUtc, state.CompletedOnUtc) + .SetProperty(x => x.StaleAfterUtc, state.StaleAfterUtc) + .SetProperty(x => x.PurgeAfterUtc, state.PurgeAfterUtc) + .SetProperty(x => x.LastUpdatedOnUtc, state.LastUpdatedOnUtc), + cancellationToken); + + if (updatedRows == 0) + { + throw new WorkflowRuntimeStateConcurrencyException( + state.WorkflowInstanceId, + state.Version, + await ResolveActualVersionAsync(state.WorkflowInstanceId, cancellationToken)); + } + } + + public async Task GetAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default) + { + var entity = await dbContext.WorkflowRuntimeStates + .AsNoTracking() + .SingleOrDefaultAsync(x => x.WorkflowInstanceId == workflowInstanceId, cancellationToken); + + return entity is null ? null : Map(entity); + } + + public async Task> GetManyAsync( + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken = default) + { + if (workflowInstanceIds.Count == 0) + { + return []; + } + + var entities = await dbContext.WorkflowRuntimeStates + .AsNoTracking() + .Where(x => workflowInstanceIds.Contains(x.WorkflowInstanceId)) + .ToArrayAsync(cancellationToken); + + return entities.Select(Map).ToArray(); + } + + public async Task MarkStaleAsync( + IReadOnlyCollection workflowInstanceIds, + DateTime updatedOnUtc, + CancellationToken cancellationToken = default) + { + if (workflowInstanceIds.Count == 0) + { + return 0; + } + + var states = await dbContext.WorkflowRuntimeStates + .Where(x => workflowInstanceIds.Contains(x.WorkflowInstanceId)) + .ToListAsync(cancellationToken); + + foreach (var state in states) + { + state.RuntimeStatus = StaleRuntimeStatus; + state.StaleAfterUtc = null; + state.LastUpdatedOnUtc = updatedOnUtc; + } + + await dbContext.SaveChangesAsync(cancellationToken); + return states.Count; + } + + public async Task DeleteAsync( + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken = default) + { + if (workflowInstanceIds.Count == 0) + { + return 0; + } + + var states = await dbContext.WorkflowRuntimeStates + .Where(x => workflowInstanceIds.Contains(x.WorkflowInstanceId)) + .ToListAsync(cancellationToken); + + if (states.Count == 0) + { + return 0; + } + + dbContext.WorkflowRuntimeStates.RemoveRange(states); + await dbContext.SaveChangesAsync(cancellationToken); + return states.Count; + } + + private static WorkflowRuntimeStateEntity Map(WorkflowRuntimeStateRecord state) + { + return new WorkflowRuntimeStateEntity + { + WorkflowInstanceId = state.WorkflowInstanceId, + WorkflowName = state.WorkflowName, + WorkflowVersion = state.WorkflowVersion, + Version = state.Version, + BusinessReferenceKey = state.BusinessReference?.Key, + BusinessReferenceJson = SerializeBusinessReference(state.BusinessReference), + RuntimeProvider = state.RuntimeProvider, + RuntimeInstanceId = state.RuntimeInstanceId, + RuntimeStatus = state.RuntimeStatus, + StateJson = state.StateJson, + CreatedOnUtc = state.CreatedOnUtc, + CompletedOnUtc = state.CompletedOnUtc, + StaleAfterUtc = state.StaleAfterUtc, + PurgeAfterUtc = state.PurgeAfterUtc, + LastUpdatedOnUtc = state.LastUpdatedOnUtc, + }; + } + + private static WorkflowRuntimeStateRecord Map(WorkflowRuntimeStateEntity entity) + { + return new WorkflowRuntimeStateRecord + { + WorkflowInstanceId = entity.WorkflowInstanceId, + WorkflowName = entity.WorkflowName, + WorkflowVersion = entity.WorkflowVersion, + Version = entity.Version, + BusinessReference = DeserializeBusinessReference(entity.BusinessReferenceKey, entity.BusinessReferenceJson), + RuntimeProvider = entity.RuntimeProvider, + RuntimeInstanceId = entity.RuntimeInstanceId, + RuntimeStatus = entity.RuntimeStatus, + StateJson = entity.StateJson, + CreatedOnUtc = entity.CreatedOnUtc, + CompletedOnUtc = entity.CompletedOnUtc, + StaleAfterUtc = entity.StaleAfterUtc, + PurgeAfterUtc = entity.PurgeAfterUtc, + LastUpdatedOnUtc = entity.LastUpdatedOnUtc, + }; + } + + private static string? SerializeBusinessReference(WorkflowBusinessReference? businessReference) + { + var normalizedReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + return normalizedReference is null ? null : global::System.Text.Json.JsonSerializer.Serialize(normalizedReference); + } + + private static WorkflowBusinessReference? DeserializeBusinessReference(string? key, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference + { + Key = key, + }); + } + + var businessReference = global::System.Text.Json.JsonSerializer.Deserialize(value); + if (businessReference is null) + { + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference + { + Key = key, + }); + } + + if (string.IsNullOrWhiteSpace(businessReference.Key) && !string.IsNullOrWhiteSpace(key)) + { + businessReference = businessReference with { Key = key }; + } + + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + } + + private async Task ResolveActualVersionAsync( + string workflowInstanceId, + CancellationToken cancellationToken) + { + return await dbContext.WorkflowRuntimeStates + .Where(x => x.WorkflowInstanceId == workflowInstanceId) + .Select(x => (long?)x.Version) + .SingleOrDefaultAsync(cancellationToken) + ?? 0; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/StellaOps.Workflow.DataStore.Oracle.csproj b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/StellaOps.Workflow.DataStore.Oracle.csproj new file mode 100644 index 000000000..ba6c0b505 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/StellaOps.Workflow.DataStore.Oracle.csproj @@ -0,0 +1,23 @@ + + + net10.0 + false + enable + enable + + + + + + + + + + + + + + + + + diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/WorkflowDbContext.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/WorkflowDbContext.cs new file mode 100644 index 000000000..40cac109c --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/WorkflowDbContext.cs @@ -0,0 +1,146 @@ +using StellaOps.Workflow.DataStore.Oracle.Entities; + +using Microsoft.EntityFrameworkCore; + +namespace StellaOps.Workflow.DataStore.Oracle; + +public class WorkflowDbContext(DbContextOptions options) : DbContext(options) +{ + public virtual DbSet WorkflowInstances { get; set; } = null!; + public virtual DbSet WorkflowTasks { get; set; } = null!; + public virtual DbSet WorkflowTaskEvents { get; set; } = null!; + public virtual DbSet WorkflowRuntimeStates { get; set; } = null!; + public virtual DbSet WorkflowHostedJobLocks { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasSequence("WF_INSTANCES_SEQ", "SRD_WFKLW"); + modelBuilder.HasSequence("WF_TASKS_SEQ", "SRD_WFKLW"); + modelBuilder.HasSequence("WF_TASK_EVENTS_SEQ", "SRD_WFKLW"); + + modelBuilder.Entity(entity => + { + entity.ToTable("WF_INSTANCES", "SRD_WFKLW"); + entity.HasKey(x => x.Id).HasName("WF_INSTANCES_PK"); + entity.HasAlternateKey(x => x.WorkflowInstanceId).HasName("WF_INSTANCES_ID_UK"); + entity.HasIndex(x => new { x.WorkflowName, x.WorkflowVersion }, "WF_INSTANCES_WF_IX"); + entity.HasIndex(x => x.BusinessReferenceKey, "WF_INSTANCES_BID_IX"); + entity.HasIndex(x => x.Status, "WF_INSTANCES_STATUS_IX"); + entity.Property(x => x.Id) + .HasColumnName("WF_INSTANCE_PK") + .HasPrecision(18, 0) + .ValueGeneratedNever() + .HasDefaultValueSql("\"SRD_WFKLW\".\"WF_INSTANCES_SEQ\".\"NEXTVAL\" "); + entity.Property(x => x.WorkflowInstanceId).HasColumnName("WF_INSTANCE_ID").HasMaxLength(128).IsRequired(); + entity.Property(x => x.WorkflowName).HasColumnName("WF_NAME").HasMaxLength(128).IsRequired(); + entity.Property(x => x.WorkflowVersion).HasColumnName("WF_VERSION").HasMaxLength(64).IsRequired(); + entity.Property(x => x.BusinessReferenceKey).HasColumnName("BUSINESS_ID").HasMaxLength(128); + entity.Property(x => x.BusinessReferenceJson).HasColumnName("BUSINESS_REFERENCE_JSON").HasColumnType("CLOB"); + entity.Property(x => x.Status).HasColumnName("STATUS").HasMaxLength(32).IsRequired(); + entity.Property(x => x.StateJson).HasColumnName("STATE_JSON").HasColumnType("CLOB").IsRequired(); + entity.Property(x => x.CreatedOnUtc).HasColumnName("CREATED_ON_UTC").HasColumnType("TIMESTAMP(6)").IsRequired(); + entity.Property(x => x.CompletedOnUtc).HasColumnName("COMPLETED_ON_UTC").HasColumnType("TIMESTAMP(6)"); + entity.Property(x => x.StaleAfterUtc).HasColumnName("STALE_AFTER_UTC").HasColumnType("TIMESTAMP(6)"); + entity.Property(x => x.PurgeAfterUtc).HasColumnName("PURGE_AFTER_UTC").HasColumnType("TIMESTAMP(6)"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("WF_TASKS", "SRD_WFKLW"); + entity.HasKey(x => x.Id).HasName("WF_TASKS_PK"); + entity.HasAlternateKey(x => x.WorkflowTaskId).HasName("WF_TASKS_ID_UK"); + entity.HasIndex(x => new { x.WorkflowInstanceId, x.Status }, "WF_TASKS_INSTANCE_STATUS_IX"); + entity.HasIndex(x => new { x.WorkflowName, x.WorkflowVersion }, "WF_TASKS_WF_IX"); + entity.HasIndex(x => x.BusinessReferenceKey, "WF_TASKS_BID_IX"); + entity.HasIndex(x => new { x.Assignee, x.Status }, "WF_TASKS_ASSIGNEE_STATUS_IX"); + entity.Property(x => x.Id) + .HasColumnName("WF_TASK_PK") + .HasPrecision(18, 0) + .ValueGeneratedNever() + .HasDefaultValueSql("\"SRD_WFKLW\".\"WF_TASKS_SEQ\".\"NEXTVAL\" "); + entity.Property(x => x.WorkflowTaskId).HasColumnName("WF_TASK_ID").HasMaxLength(128).IsRequired(); + entity.Property(x => x.WorkflowInstanceId).HasColumnName("WF_INSTANCE_ID").HasMaxLength(128).IsRequired(); + entity.Property(x => x.WorkflowName).HasColumnName("WF_NAME").HasMaxLength(128).IsRequired(); + entity.Property(x => x.WorkflowVersion).HasColumnName("WF_VERSION").HasMaxLength(64).IsRequired(); + entity.Property(x => x.TaskName).HasColumnName("TASK_NAME").HasMaxLength(256).IsRequired(); + entity.Property(x => x.TaskType).HasColumnName("TASK_TYPE").HasMaxLength(128).IsRequired(); + entity.Property(x => x.Route).HasColumnName("ROUTE").HasMaxLength(256).IsRequired(); + entity.Property(x => x.BusinessReferenceKey).HasColumnName("BUSINESS_ID").HasMaxLength(128); + entity.Property(x => x.BusinessReferenceJson).HasColumnName("BUSINESS_REFERENCE_JSON").HasColumnType("CLOB"); + entity.Property(x => x.Assignee).HasColumnName("ASSIGNEE").HasMaxLength(128); + entity.Property(x => x.Status).HasColumnName("STATUS").HasMaxLength(32).IsRequired(); + entity.Property(x => x.WorkflowRolesJson).HasColumnName("WORKFLOW_ROLES_JSON").HasColumnType("CLOB").IsRequired(); + entity.Property(x => x.TaskRolesJson).HasColumnName("TASK_ROLES_JSON").HasColumnType("CLOB").IsRequired(); + entity.Property(x => x.RuntimeRolesJson).HasColumnName("RUNTIME_ROLES_JSON").HasColumnType("CLOB").IsRequired(); + entity.Property(x => x.EffectiveRolesJson).HasColumnName("EFFECTIVE_ROLES_JSON").HasColumnType("CLOB").IsRequired(); + entity.Property(x => x.PayloadJson).HasColumnName("PAYLOAD_JSON").HasColumnType("CLOB").IsRequired(); + entity.Property(x => x.CreatedOnUtc).HasColumnName("CREATED_ON_UTC").HasColumnType("TIMESTAMP(6)").IsRequired(); + entity.Property(x => x.CompletedOnUtc).HasColumnName("COMPLETED_ON_UTC").HasColumnType("TIMESTAMP(6)"); + entity.Property(x => x.StaleAfterUtc).HasColumnName("STALE_AFTER_UTC").HasColumnType("TIMESTAMP(6)"); + entity.Property(x => x.PurgeAfterUtc).HasColumnName("PURGE_AFTER_UTC").HasColumnType("TIMESTAMP(6)"); + entity.HasOne(x => x.WorkflowInstance) + .WithMany(x => x.Tasks) + .HasForeignKey(x => x.WorkflowInstanceId) + .HasPrincipalKey(x => x.WorkflowInstanceId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("WF_TASKS_INSTANCE_FK"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("WF_TASK_EVENTS", "SRD_WFKLW"); + entity.HasKey(x => x.Id).HasName("WF_TASK_EVENTS_PK"); + entity.HasIndex(x => new { x.WorkflowTaskId, x.CreatedOnUtc }, "WF_TASK_EVENTS_TASK_TIME_IX"); + entity.Property(x => x.Id) + .HasColumnName("WF_TASK_EVENT_PK") + .HasPrecision(18, 0) + .ValueGeneratedNever() + .HasDefaultValueSql("\"SRD_WFKLW\".\"WF_TASK_EVENTS_SEQ\".\"NEXTVAL\" "); + entity.Property(x => x.WorkflowTaskId).HasColumnName("WF_TASK_ID").HasMaxLength(128).IsRequired(); + entity.Property(x => x.EventType).HasColumnName("EVENT_TYPE").HasMaxLength(64).IsRequired(); + entity.Property(x => x.ActorId).HasColumnName("ACTOR_ID").HasMaxLength(128); + entity.Property(x => x.PayloadJson).HasColumnName("PAYLOAD_JSON").HasColumnType("CLOB").IsRequired(); + entity.Property(x => x.CreatedOnUtc).HasColumnName("CREATED_ON_UTC").HasColumnType("TIMESTAMP(6)").IsRequired(); + entity.HasOne(x => x.WorkflowTask) + .WithMany(x => x.Events) + .HasForeignKey(x => x.WorkflowTaskId) + .HasPrincipalKey(x => x.WorkflowTaskId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("WF_TASK_EVENTS_TASK_FK"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("WF_RUNTIME_STATES", "SRD_WFKLW"); + entity.HasKey(x => x.WorkflowInstanceId).HasName("WF_RUNTIME_STATES_PK"); + entity.HasIndex(x => new { x.WorkflowName, x.WorkflowVersion }, "WF_RUNTIME_STATES_WF_IX"); + entity.HasIndex(x => x.BusinessReferenceKey, "WF_RUNTIME_STATES_BID_IX"); + entity.HasIndex(x => x.RuntimeStatus, "WF_RUNTIME_STATES_STATUS_IX"); + entity.Property(x => x.WorkflowInstanceId).HasColumnName("WF_INSTANCE_ID").HasMaxLength(128).IsRequired(); + entity.Property(x => x.WorkflowName).HasColumnName("WF_NAME").HasMaxLength(128).IsRequired(); + entity.Property(x => x.WorkflowVersion).HasColumnName("WF_VERSION").HasMaxLength(64).IsRequired(); + entity.Property(x => x.Version).HasColumnName("VERSION_NO").HasPrecision(18, 0).IsRequired(); + entity.Property(x => x.BusinessReferenceKey).HasColumnName("BUSINESS_ID").HasMaxLength(128); + entity.Property(x => x.BusinessReferenceJson).HasColumnName("BUSINESS_REFERENCE_JSON").HasColumnType("CLOB"); + entity.Property(x => x.RuntimeProvider).HasColumnName("RUNTIME_PROVIDER").HasMaxLength(64).IsRequired(); + entity.Property(x => x.RuntimeInstanceId).HasColumnName("RUNTIME_INSTANCE_ID").HasMaxLength(128).IsRequired(); + entity.Property(x => x.RuntimeStatus).HasColumnName("RUNTIME_STATUS").HasMaxLength(32).IsRequired(); + entity.Property(x => x.StateJson).HasColumnName("STATE_JSON").HasColumnType("CLOB").IsRequired(); + entity.Property(x => x.CreatedOnUtc).HasColumnName("CREATED_ON_UTC").HasColumnType("TIMESTAMP(6)").IsRequired(); + entity.Property(x => x.CompletedOnUtc).HasColumnName("COMPLETED_ON_UTC").HasColumnType("TIMESTAMP(6)"); + entity.Property(x => x.StaleAfterUtc).HasColumnName("STALE_AFTER_UTC").HasColumnType("TIMESTAMP(6)"); + entity.Property(x => x.PurgeAfterUtc).HasColumnName("PURGE_AFTER_UTC").HasColumnType("TIMESTAMP(6)"); + entity.Property(x => x.LastUpdatedOnUtc).HasColumnName("LAST_UPDATED_ON_UTC").HasColumnType("TIMESTAMP(6)").IsRequired(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("WF_HOST_LOCKS", "SRD_WFKLW"); + entity.HasKey(x => x.LockName).HasName("WF_HOST_LOCKS_PK"); + entity.Property(x => x.LockName).HasColumnName("LOCK_NAME").HasMaxLength(128).IsRequired(); + entity.Property(x => x.LockOwner).HasColumnName("LOCK_OWNER").HasMaxLength(256).IsRequired(); + entity.Property(x => x.AcquiredOnUtc).HasColumnName("ACQUIRED_ON_UTC").HasColumnType("TIMESTAMP(6)").IsRequired(); + entity.Property(x => x.ExpiresOnUtc).HasColumnName("EXPIRES_ON_UTC").HasColumnType("TIMESTAMP(6)").IsRequired(); + }); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/WorkflowDbContextFactory.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/WorkflowDbContextFactory.cs new file mode 100644 index 000000000..79c1e557a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/WorkflowDbContextFactory.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace StellaOps.Workflow.DataStore.Oracle; + +public class WorkflowDbContextFactory : IDesignTimeDbContextFactory +{ + public WorkflowDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseOracle("DATA SOURCE=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=db.serdica.ablera.dev)(PORT=1521))(CONNECT_DATA=(SID=orcl1)));USER ID=srd_wfklw;PASSWORD=srd_wfklw"); + return new WorkflowDbContext(optionsBuilder.Options); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-10-workflow-pilot-initial-schema.sql b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-10-workflow-pilot-initial-schema.sql new file mode 100644 index 000000000..0b10ec933 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-10-workflow-pilot-initial-schema.sql @@ -0,0 +1,142 @@ +CREATE SEQUENCE "SRD_WFKLW"."WF_INSTANCES_SEQ" + START WITH 1 + INCREMENT BY 1 + NOCACHE + NOCYCLE; + +CREATE SEQUENCE "SRD_WFKLW"."WF_TASKS_SEQ" + START WITH 1 + INCREMENT BY 1 + NOCACHE + NOCYCLE; + +CREATE SEQUENCE "SRD_WFKLW"."WF_TASK_EVENTS_SEQ" + START WITH 1 + INCREMENT BY 1 + NOCACHE + NOCYCLE; + +CREATE TABLE "SRD_WFKLW"."WF_INSTANCES" +( + "WF_INSTANCE_PK" NUMBER(18, 0) DEFAULT "SRD_WFKLW"."WF_INSTANCES_SEQ"."NEXTVAL" 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), + "BUSINESS_REFERENCE_JSON" CLOB, + "STATUS" VARCHAR2(32 CHAR) NOT NULL, + "STATE_JSON" CLOB NOT NULL, + "CREATED_ON_UTC" TIMESTAMP(6) NOT NULL, + "COMPLETED_ON_UTC" TIMESTAMP(6), + "STALE_AFTER_UTC" TIMESTAMP(6), + "PURGE_AFTER_UTC" TIMESTAMP(6), + CONSTRAINT "WF_INSTANCES_PK" PRIMARY KEY ("WF_INSTANCE_PK"), + CONSTRAINT "WF_INSTANCES_ID_UK" UNIQUE ("WF_INSTANCE_ID") +); + +CREATE TABLE "SRD_WFKLW"."WF_TASKS" +( + "WF_TASK_PK" NUMBER(18, 0) DEFAULT "SRD_WFKLW"."WF_TASKS_SEQ"."NEXTVAL" 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), + "BUSINESS_REFERENCE_JSON" CLOB, + "ASSIGNEE" VARCHAR2(128 CHAR), + "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), + "STALE_AFTER_UTC" TIMESTAMP(6), + "PURGE_AFTER_UTC" TIMESTAMP(6), + 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 "SRD_WFKLW"."WF_INSTANCES" ("WF_INSTANCE_ID") + ON DELETE CASCADE +); + +CREATE TABLE "SRD_WFKLW"."WF_TASK_EVENTS" +( + "WF_TASK_EVENT_PK" NUMBER(18, 0) DEFAULT "SRD_WFKLW"."WF_TASK_EVENTS_SEQ"."NEXTVAL" NOT NULL, + "WF_TASK_ID" VARCHAR2(128 CHAR) NOT NULL, + "EVENT_TYPE" VARCHAR2(64 CHAR) NOT NULL, + "ACTOR_ID" VARCHAR2(128 CHAR), + "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 "SRD_WFKLW"."WF_TASKS" ("WF_TASK_ID") + ON DELETE CASCADE +); + +CREATE TABLE "SRD_WFKLW"."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, + "BUSINESS_ID" VARCHAR2(128 CHAR), + "BUSINESS_REFERENCE_JSON" CLOB, + "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), + "STALE_AFTER_UTC" TIMESTAMP(6), + "PURGE_AFTER_UTC" TIMESTAMP(6), + "LAST_UPDATED_ON_UTC" TIMESTAMP(6) NOT NULL, + CONSTRAINT "WF_RUNTIME_STATES_PK" PRIMARY KEY ("WF_INSTANCE_ID") +); + +CREATE TABLE "SRD_WFKLW"."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") +); + +CREATE INDEX "SRD_WFKLW"."WF_INSTANCES_WF_IX" + ON "SRD_WFKLW"."WF_INSTANCES" ("WF_NAME", "WF_VERSION"); + +CREATE INDEX "SRD_WFKLW"."WF_INSTANCES_BID_IX" + ON "SRD_WFKLW"."WF_INSTANCES" ("BUSINESS_ID"); + +CREATE INDEX "SRD_WFKLW"."WF_INSTANCES_STATUS_IX" + ON "SRD_WFKLW"."WF_INSTANCES" ("STATUS"); + +CREATE INDEX "SRD_WFKLW"."WF_TASKS_INSTANCE_STATUS_IX" + ON "SRD_WFKLW"."WF_TASKS" ("WF_INSTANCE_ID", "STATUS"); + +CREATE INDEX "SRD_WFKLW"."WF_TASKS_WF_IX" + ON "SRD_WFKLW"."WF_TASKS" ("WF_NAME", "WF_VERSION"); + +CREATE INDEX "SRD_WFKLW"."WF_TASKS_BID_IX" + ON "SRD_WFKLW"."WF_TASKS" ("BUSINESS_ID"); + +CREATE INDEX "SRD_WFKLW"."WF_TASKS_ASSIGNEE_STATUS_IX" + ON "SRD_WFKLW"."WF_TASKS" ("ASSIGNEE", "STATUS"); + +CREATE INDEX "SRD_WFKLW"."WF_TASK_EVENTS_TASK_TIME_IX" + ON "SRD_WFKLW"."WF_TASK_EVENTS" ("WF_TASK_ID", "CREATED_ON_UTC"); + +CREATE INDEX "SRD_WFKLW"."WF_RUNTIME_STATES_WF_IX" + ON "SRD_WFKLW"."WF_RUNTIME_STATES" ("WF_NAME", "WF_VERSION"); + +CREATE INDEX "SRD_WFKLW"."WF_RUNTIME_STATES_BID_IX" + ON "SRD_WFKLW"."WF_RUNTIME_STATES" ("BUSINESS_ID"); + +CREATE INDEX "SRD_WFKLW"."WF_RUNTIME_STATES_STATUS_IX" + ON "SRD_WFKLW"."WF_RUNTIME_STATES" ("RUNTIME_STATUS"); diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-11-workflow-pilot-runtime-columns.sql b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-11-workflow-pilot-runtime-columns.sql new file mode 100644 index 000000000..bc8f2fd83 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-11-workflow-pilot-runtime-columns.sql @@ -0,0 +1,20 @@ +ALTER TABLE "SRD_WFKLW"."WF_INSTANCES" + ADD ("STATE_JSON" CLOB); + +ALTER TABLE "SRD_WFKLW"."WF_TASKS" + ADD ("RUNTIME_ROLES_JSON" CLOB); + +ALTER TABLE "SRD_WFKLW"."WF_TASKS" + ADD ("PAYLOAD_JSON" CLOB); + +UPDATE "SRD_WFKLW"."WF_INSTANCES" +SET "STATE_JSON" = '{}' +WHERE "STATE_JSON" IS NULL; + +UPDATE "SRD_WFKLW"."WF_TASKS" +SET "RUNTIME_ROLES_JSON" = '[]' +WHERE "RUNTIME_ROLES_JSON" IS NULL; + +UPDATE "SRD_WFKLW"."WF_TASKS" +SET "PAYLOAD_JSON" = '{}' +WHERE "PAYLOAD_JSON" IS NULL; diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-12-workflow-runtime-state-locks-and-semver.sql b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-12-workflow-runtime-state-locks-and-semver.sql new file mode 100644 index 000000000..802d2e51d --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-12-workflow-runtime-state-locks-and-semver.sql @@ -0,0 +1,42 @@ +ALTER TABLE "SRD_WFKLW"."WF_INSTANCES" + MODIFY ("WF_VERSION" VARCHAR2(64 CHAR)); + +ALTER TABLE "SRD_WFKLW"."WF_TASKS" + MODIFY ("WF_VERSION" VARCHAR2(64 CHAR)); + +CREATE TABLE "SRD_WFKLW"."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, + "BUSINESS_ID" VARCHAR2(128 CHAR), + "BUSINESS_REFERENCE_JSON" CLOB, + "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), + "STALE_AFTER_UTC" TIMESTAMP(6), + "PURGE_AFTER_UTC" TIMESTAMP(6), + "LAST_UPDATED_ON_UTC" TIMESTAMP(6) NOT NULL, + CONSTRAINT "WF_RUNTIME_STATES_PK" PRIMARY KEY ("WF_INSTANCE_ID") +); + +CREATE TABLE "SRD_WFKLW"."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") +); + +CREATE INDEX "SRD_WFKLW"."WF_RUNTIME_STATES_WF_IX" + ON "SRD_WFKLW"."WF_RUNTIME_STATES" ("WF_NAME", "WF_VERSION"); + +CREATE INDEX "SRD_WFKLW"."WF_RUNTIME_STATES_BID_IX" + ON "SRD_WFKLW"."WF_RUNTIME_STATES" ("BUSINESS_ID"); + +CREATE INDEX "SRD_WFKLW"."WF_RUNTIME_STATES_STATUS_IX" + ON "SRD_WFKLW"."WF_RUNTIME_STATES" ("RUNTIME_STATUS"); diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-13-workflow-business-reference-columns.sql b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-13-workflow-business-reference-columns.sql new file mode 100644 index 000000000..b0876a0c1 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/ddl/2026-03-13-workflow-business-reference-columns.sql @@ -0,0 +1,29 @@ +ALTER TABLE "SRD_WFKLW"."WF_INSTANCES" + ADD ("BUSINESS_REFERENCE_JSON" CLOB); + +ALTER TABLE "SRD_WFKLW"."WF_TASKS" + ADD ("BUSINESS_REFERENCE_JSON" CLOB); + +ALTER TABLE "SRD_WFKLW"."WF_RUNTIME_STATES" + ADD ("BUSINESS_REFERENCE_JSON" CLOB); + +UPDATE "SRD_WFKLW"."WF_INSTANCES" +SET "BUSINESS_REFERENCE_JSON" = CASE + WHEN "BUSINESS_ID" IS NULL THEN NULL + ELSE '{"key":"' || REPLACE("BUSINESS_ID", '"', '\"') || '","parts":{}}' +END +WHERE "BUSINESS_REFERENCE_JSON" IS NULL; + +UPDATE "SRD_WFKLW"."WF_TASKS" +SET "BUSINESS_REFERENCE_JSON" = CASE + WHEN "BUSINESS_ID" IS NULL THEN NULL + ELSE '{"key":"' || REPLACE("BUSINESS_ID", '"', '\"') || '","parts":{}}' +END +WHERE "BUSINESS_REFERENCE_JSON" IS NULL; + +UPDATE "SRD_WFKLW"."WF_RUNTIME_STATES" +SET "BUSINESS_REFERENCE_JSON" = CASE + WHEN "BUSINESS_ID" IS NULL THEN NULL + ELSE '{"key":"' || REPLACE("BUSINESS_ID", '"', '\"') || '","parts":{}}' +END +WHERE "BUSINESS_REFERENCE_JSON" IS NULL; diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowBackendOptions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowBackendOptions.cs new file mode 100644 index 000000000..284af2297 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowBackendOptions.cs @@ -0,0 +1,24 @@ +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +public sealed class PostgresWorkflowBackendOptions +{ + public const string SectionName = $"{WorkflowBackendOptions.SectionName}:Postgres"; + + public string ConnectionStringName { get; set; } = "WorkflowPostgres"; + public string SchemaName { get; set; } = "srd_wfklw"; + public string RuntimeStatesTableName { get; set; } = "wf_runtime_states"; + public string HostedJobLocksTableName { get; set; } = "wf_host_locks"; + public string InstancesTableName { get; set; } = "wf_instances"; + public string TasksTableName { get; set; } = "wf_tasks"; + public string TaskEventsTableName { get; set; } = "wf_task_events"; + public string SignalQueueTableName { get; set; } = "wf_signal_queue"; + public string DeadLetterTableName { get; set; } = "wf_signal_dead_letters"; + public string WakeOutboxTableName { get; set; } = "wf_signal_wake_outbox"; + public string NotifyChannel { get; set; } = "workflow_signal"; + public string WakeOutboxNotifyChannel { get; set; } = "workflow_signal_wake_outbox"; + public int ClaimBatchSize { get; set; } = 32; + public int ClaimTimeoutSeconds { get; set; } = 60; + public int BlockingWaitSeconds { get; set; } = 30; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDataStoreExtensions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDataStoreExtensions.cs new file mode 100644 index 000000000..5475d41a1 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDataStoreExtensions.cs @@ -0,0 +1,61 @@ +using System; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +public static class PostgresWorkflowDataStoreExtensions +{ + public static IServiceCollection AddWorkflowPostgresDataStore( + this IServiceCollection services, IConfiguration configuration) + { + services.AddWorkflowModule("workflow-store.postgres", "1.0.0"); + services.AddSingleton( + new WorkflowBackendRegistrationMarker(WorkflowBackendNames.Postgres)); + + if (!string.Equals(configuration.GetWorkflowBackendProvider(), WorkflowBackendNames.Postgres, StringComparison.OrdinalIgnoreCase)) + { + return services; + } + + var useNativeSignalDriver = string.Equals( + configuration.GetWorkflowSignalDriverProvider(), + WorkflowSignalDriverNames.Native, + StringComparison.OrdinalIgnoreCase); + + services.Configure(configuration.GetSection(PostgresWorkflowBackendOptions.SectionName)); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + if (useNativeSignalDriver) + { + services.AddScoped(); + } + + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Scoped()); + + if (useNativeSignalDriver) + { + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + } + + services.AddSingleton( + new WorkflowSignalDriverRegistrationMarker(WorkflowSignalDriverNames.Native)); + + return services; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDatabase.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDatabase.cs new file mode 100644 index 000000000..8cefd0992 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDatabase.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +using Npgsql; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +public sealed class PostgresWorkflowDatabase( + IConfiguration configuration, + IOptions options, + PostgresWorkflowMutationSessionAccessor sessionAccessor, + IWorkflowMutationScopeAccessor scopeAccessor) +{ + private readonly PostgresWorkflowBackendOptions postgres = options.Value; + + internal PostgresWorkflowBackendOptions Options => postgres; + + internal string Qualify(string tableName) + { + return $"{QuoteIdentifier(postgres.SchemaName)}.{QuoteIdentifier(tableName)}"; + } + + internal string QuoteIdentifier(string identifier) + { + return $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; + } + + internal NpgsqlCommand CreateCommand( + NpgsqlConnection connection, + string sql, + NpgsqlTransaction? transaction = null) + { + return new NpgsqlCommand(sql, connection, transaction); + } + + internal async Task OpenScopeAsync( + bool requireTransaction, + CancellationToken cancellationToken = default) + { + if (sessionAccessor.Current is not null) + { + return new PostgresWorkflowOperationScope( + sessionAccessor.Current.Connection, + sessionAccessor.Current.Transaction, + ownsConnection: false, + ownsTransaction: false); + } + + var connection = await OpenConnectionAsync(cancellationToken); + if (!requireTransaction) + { + return new PostgresWorkflowOperationScope(connection, transaction: null, ownsConnection: true, ownsTransaction: false); + } + + var transaction = await connection.BeginTransactionAsync(cancellationToken); + return new PostgresWorkflowOperationScope(connection, transaction, ownsConnection: true, ownsTransaction: true); + } + + internal async Task BeginMutationAsync(CancellationToken cancellationToken = default) + { + if (scopeAccessor.Current is not null) + { + return new DelegatingMutationScope(scopeAccessor.Current); + } + + if (sessionAccessor.Current is not null) + { + var currentScope = new PostgresWorkflowMutationScope(sessionAccessor, scopeAccessor, connection: null, transaction: null, ownsSession: true); + scopeAccessor.Current = currentScope; + return currentScope; + } + + var connection = await OpenConnectionAsync(cancellationToken); + var transaction = await connection.BeginTransactionAsync(cancellationToken); + sessionAccessor.Current = new PostgresWorkflowMutationSession(connection, transaction); + var scope = new PostgresWorkflowMutationScope(sessionAccessor, scopeAccessor, connection, transaction, ownsSession: true); + scopeAccessor.Current = scope; + return scope; + } + + internal async Task OpenConnectionAsync(CancellationToken cancellationToken = default) + { + var connectionString = configuration.GetConnectionString(postgres.ConnectionStringName) + ?? throw new InvalidOperationException( + $"PostgreSQL workflow backend requires connection string '{postgres.ConnectionStringName}'."); + + var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + return connection; + } +} + +internal sealed class PostgresWorkflowOperationScope( + NpgsqlConnection connection, + NpgsqlTransaction? transaction, + bool ownsConnection, + bool ownsTransaction) : IAsyncDisposable +{ + private bool isCommitted; + + public NpgsqlConnection Connection { get; } = connection; + public NpgsqlTransaction? Transaction { get; } = transaction; + + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + if (!ownsTransaction || isCommitted || Transaction is null) + { + return; + } + + await Transaction.CommitAsync(cancellationToken); + isCommitted = true; + } + + public async ValueTask DisposeAsync() + { + if (ownsTransaction && Transaction is not null) + { + if (!isCommitted) + { + try + { + await Transaction.RollbackAsync(CancellationToken.None); + } + catch + { + } + } + + await Transaction.DisposeAsync(); + } + + if (ownsConnection) + { + await Connection.DisposeAsync(); + } + } +} + +public sealed class PostgresWorkflowMutationSessionAccessor +{ + internal PostgresWorkflowMutationSession? Current { get; set; } +} + +internal sealed record PostgresWorkflowMutationSession( + NpgsqlConnection Connection, + NpgsqlTransaction Transaction); + +internal sealed class PostgresWorkflowMutationScope( + PostgresWorkflowMutationSessionAccessor sessionAccessor, + IWorkflowMutationScopeAccessor scopeAccessor, + NpgsqlConnection? connection, + NpgsqlTransaction? transaction, + bool ownsSession) : IWorkflowMutationScope +{ + private readonly List> postCommitActions = []; + private bool isCommitted; + + public void RegisterPostCommitAction(Func action) + { + ArgumentNullException.ThrowIfNull(action); + postCommitActions.Add(action); + } + + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + if (!ownsSession || isCommitted) + { + return; + } + + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + + foreach (var action in postCommitActions) + { + await action(cancellationToken); + } + + isCommitted = true; + } + + public async ValueTask DisposeAsync() + { + try + { + if (ownsSession && transaction is not null) + { + if (!isCommitted) + { + try + { + await transaction.RollbackAsync(CancellationToken.None); + } + catch + { + } + } + + await transaction.DisposeAsync(); + } + } + finally + { + if (ownsSession) + { + sessionAccessor.Current = null; + if (scopeAccessor.Current == this) + { + scopeAccessor.Current = null; + } + } + + if (ownsSession && connection is not null) + { + await connection.DisposeAsync(); + } + } + } +} + +internal sealed class DelegatingMutationScope(IWorkflowMutationScope inner) : IWorkflowMutationScope +{ + public void RegisterPostCommitAction(Func action) + { + inner.RegisterPostCommitAction(action); + } + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowHostedJobLockService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowHostedJobLockService.cs new file mode 100644 index 000000000..a2018ac25 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowHostedJobLockService.cs @@ -0,0 +1,108 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using Npgsql; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +public sealed class PostgresWorkflowHostedJobLockService( + PostgresWorkflowDatabase database) : IWorkflowHostedJobLockService +{ + private PostgresWorkflowBackendOptions postgres => database.Options; + + public async Task TryAcquireAsync( + string lockName, + string lockOwner, + DateTime acquiredOnUtc, + TimeSpan lease, + CancellationToken cancellationToken = default) + { + var expiresOnUtc = acquiredOnUtc.Add(lease); + + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + + await using (var updateCommand = database.CreateCommand( + scope.Connection, + $""" + update {Qualify(postgres.HostedJobLocksTableName)} + set lock_owner = @lock_owner, + acquired_on_utc = @acquired_on_utc, + expires_on_utc = @expires_on_utc + where lock_name = @lock_name + and (expires_on_utc <= @acquired_on_utc or lock_owner = @lock_owner) + """, + scope.Transaction)) + { + updateCommand.Parameters.AddWithValue("lock_name", lockName); + updateCommand.Parameters.AddWithValue("lock_owner", lockOwner); + updateCommand.Parameters.AddWithValue("acquired_on_utc", acquiredOnUtc); + updateCommand.Parameters.AddWithValue("expires_on_utc", expiresOnUtc); + + if (await updateCommand.ExecuteNonQueryAsync(cancellationToken) > 0) + { + await scope.CommitAsync(cancellationToken); + return true; + } + } + + try + { + await using var insertCommand = database.CreateCommand( + scope.Connection, + $""" + insert into {Qualify(postgres.HostedJobLocksTableName)} ( + lock_name, + lock_owner, + acquired_on_utc, + expires_on_utc + ) + values ( + @lock_name, + @lock_owner, + @acquired_on_utc, + @expires_on_utc + ) + """, + scope.Transaction); + insertCommand.Parameters.AddWithValue("lock_name", lockName); + insertCommand.Parameters.AddWithValue("lock_owner", lockOwner); + insertCommand.Parameters.AddWithValue("acquired_on_utc", acquiredOnUtc); + insertCommand.Parameters.AddWithValue("expires_on_utc", expiresOnUtc); + await insertCommand.ExecuteNonQueryAsync(cancellationToken); + await scope.CommitAsync(cancellationToken); + return true; + } + catch (PostgresException exception) when (exception.SqlState == PostgresErrorCodes.UniqueViolation) + { + return false; + } + } + + public async Task ReleaseAsync( + string lockName, + string lockOwner, + CancellationToken cancellationToken = default) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + $""" + delete from {Qualify(postgres.HostedJobLocksTableName)} + where lock_name = @lock_name + and lock_owner = @lock_owner + """, + scope.Transaction); + command.Parameters.AddWithValue("lock_name", lockName); + command.Parameters.AddWithValue("lock_owner", lockOwner); + await command.ExecuteNonQueryAsync(cancellationToken); + await scope.CommitAsync(cancellationToken); + } + + private string Qualify(string tableName) + { + return database.Qualify(tableName); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowJson.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowJson.cs new file mode 100644 index 000000000..b8c5fd1cc --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowJson.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +using Microsoft.Extensions.Configuration; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +internal static class PostgresWorkflowJson +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + public static string Serialize(object value) + { + return JsonSerializer.Serialize(value, SerializerOptions); + } + + public static string? SerializeBusinessReference(WorkflowBusinessReference? businessReference) + { + var normalizedReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + return normalizedReference is null ? null : Serialize(normalizedReference); + } + + public static WorkflowBusinessReference? DeserializeBusinessReference(string? key, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference + { + Key = key, + }); + } + + var businessReference = JsonSerializer.Deserialize(value, SerializerOptions); + if (businessReference is null) + { + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference + { + Key = key, + }); + } + + if (string.IsNullOrWhiteSpace(businessReference.Key) && !string.IsNullOrWhiteSpace(key)) + { + businessReference = businessReference with { Key = key }; + } + + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + } + + public static IReadOnlyCollection DeserializeStringArray(string value) + { + return JsonSerializer.Deserialize(value, SerializerOptions) ?? []; + } + + public static IReadOnlyDictionary DeserializeJsonDictionary(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return JsonSerializer.Deserialize>(value, SerializerOptions) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public static IDictionary DeserializeObjectDictionary(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return JsonSerializer.Deserialize>(value, SerializerOptions) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public static IDictionary DeserializePublicTaskPayload(string value) + { + var payload = DeserializeObjectDictionary(value); + payload.Remove(WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey); + return payload; + } + + public static IReadOnlyDictionary ToPublicTaskPayload( + IReadOnlyDictionary payload) + { + return payload + .Where(x => !string.Equals( + x.Key, + WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey, + StringComparison.OrdinalIgnoreCase)) + .ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase); + } + + public static string? TryReadProjectionWorkflowInstanceId(IReadOnlyDictionary payload) + { + if (!payload.TryGetValue(WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey, out var value) + || value.ValueKind != JsonValueKind.String) + { + return null; + } + + return value.GetString(); + } +} + +internal static class PostgresWorkflowRoleResolver +{ + public static IReadOnlyCollection NormalizeRoles(IReadOnlyCollection roles) + { + return roles + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public static IReadOnlyCollection ResolveEffectiveRoles( + IReadOnlyCollection workflowRoles, + IReadOnlyCollection taskRoles, + IReadOnlyCollection? runtimeRoles = null) + { + if (runtimeRoles is { Count: > 0 }) + { + return NormalizeRoles(runtimeRoles); + } + + if (taskRoles.Count > 0) + { + return NormalizeRoles(taskRoles); + } + + return NormalizeRoles(workflowRoles); + } +} + +internal sealed class PostgresWorkflowRetentionSettings +{ + public int OpenStaleAfterDays { get; init; } = 30; + public int CompletedPurgeAfterDays { get; init; } = 180; + + public static PostgresWorkflowRetentionSettings FromConfiguration(IConfiguration configuration) + { + var section = configuration.GetSection("WorkflowRetention"); + return new PostgresWorkflowRetentionSettings + { + OpenStaleAfterDays = ReadInt(section["OpenStaleAfterDays"], 30), + CompletedPurgeAfterDays = ReadInt(section["CompletedPurgeAfterDays"], 180), + }; + } + + private static int ReadInt(string? value, int fallback) + { + return int.TryParse(value, out var parsed) ? parsed : fallback; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowMutationCoordinator.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowMutationCoordinator.cs new file mode 100644 index 000000000..6a18b690a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowMutationCoordinator.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +public sealed class PostgresWorkflowMutationCoordinator(PostgresWorkflowDatabase database) : IWorkflowMutationCoordinator +{ + public Task BeginAsync(CancellationToken cancellationToken = default) + { + return database.BeginMutationAsync(cancellationToken); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowProjectionRetentionStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowProjectionRetentionStore.cs new file mode 100644 index 000000000..a5983acbe --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowProjectionRetentionStore.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using Npgsql; +using NpgsqlTypes; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +public sealed class PostgresWorkflowProjectionRetentionStore( + PostgresWorkflowDatabase database) : IWorkflowProjectionRetentionStore +{ + private const string OpenInstanceStatus = "Open"; + private const string StaleInstanceStatus = "Stale"; + private const string OpenTaskStatus = "Open"; + private const string AssignedTaskStatus = "Assigned"; + private const string StaleTaskStatus = "Stale"; + + public async Task RunAsync( + DateTime nowUtc, + CancellationToken cancellationToken = default) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + + var staleInstanceIds = await MarkStaleInstancesAsync(scope, nowUtc, cancellationToken); + var staleTasksMarked = await MarkStaleTasksAsync(scope, nowUtc, cancellationToken); + var expiredInstanceIds = await GetExpiredInstanceIdsAsync(scope, nowUtc, cancellationToken); + var taskIdsToPurge = await GetTaskIdsToPurgeAsync(scope, expiredInstanceIds, nowUtc, cancellationToken); + var purgedTaskEvents = await PurgeTaskEventsAsync(scope, taskIdsToPurge, cancellationToken); + var purgedTasks = await PurgeTasksAsync(scope, taskIdsToPurge, cancellationToken); + var purgedInstances = await PurgeInstancesAsync(scope, expiredInstanceIds, cancellationToken); + + await scope.CommitAsync(cancellationToken); + + return new WorkflowProjectionRetentionBatch + { + StaleWorkflowInstanceIds = staleInstanceIds, + StaleInstancesMarked = staleInstanceIds.Count, + StaleTasksMarked = staleTasksMarked, + PurgedWorkflowInstanceIds = expiredInstanceIds, + PurgedInstances = purgedInstances, + PurgedTasks = purgedTasks, + PurgedTaskEvents = purgedTaskEvents, + }; + } + + private async Task> MarkStaleInstancesAsync( + PostgresWorkflowOperationScope scope, + DateTime nowUtc, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + update {database.Qualify(database.Options.InstancesTableName)} + set status = @stale_status + where status = @open_status + and stale_after_utc is not null + and stale_after_utc <= @now_utc + returning workflow_instance_id + """, + scope.Transaction); + command.Parameters.Add("stale_status", NpgsqlDbType.Text).Value = StaleInstanceStatus; + command.Parameters.Add("open_status", NpgsqlDbType.Text).Value = OpenInstanceStatus; + command.Parameters.Add("now_utc", NpgsqlDbType.TimestampTz).Value = nowUtc; + return await ReadStringValuesAsync(command, cancellationToken); + } + + private async Task MarkStaleTasksAsync( + PostgresWorkflowOperationScope scope, + DateTime nowUtc, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + update {database.Qualify(database.Options.TasksTableName)} + set status = @stale_status + where status = any(@source_statuses) + and stale_after_utc is not null + and stale_after_utc <= @now_utc + """, + scope.Transaction); + command.Parameters.Add("stale_status", NpgsqlDbType.Text).Value = StaleTaskStatus; + command.Parameters.Add("source_statuses", NpgsqlDbType.Array | NpgsqlDbType.Text).Value = new[] { OpenTaskStatus, AssignedTaskStatus }; + command.Parameters.Add("now_utc", NpgsqlDbType.TimestampTz).Value = nowUtc; + return await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task> GetExpiredInstanceIdsAsync( + PostgresWorkflowOperationScope scope, + DateTime nowUtc, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + select workflow_instance_id + from {database.Qualify(database.Options.InstancesTableName)} + where purge_after_utc is not null + and purge_after_utc <= @now_utc + """, + scope.Transaction); + command.Parameters.Add("now_utc", NpgsqlDbType.TimestampTz).Value = nowUtc; + return await ReadStringValuesAsync(command, cancellationToken); + } + + private async Task> GetTaskIdsToPurgeAsync( + PostgresWorkflowOperationScope scope, + IReadOnlyCollection expiredInstanceIds, + DateTime nowUtc, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + select workflow_task_id + from {database.Qualify(database.Options.TasksTableName)} + where (cardinality(@expired_instance_ids) > 0 and workflow_instance_id = any(@expired_instance_ids)) + or (purge_after_utc is not null and purge_after_utc <= @now_utc) + """, + scope.Transaction); + command.Parameters.Add("expired_instance_ids", NpgsqlDbType.Array | NpgsqlDbType.Text).Value = + expiredInstanceIds.Count == 0 ? Array.Empty() : expiredInstanceIds; + command.Parameters.Add("now_utc", NpgsqlDbType.TimestampTz).Value = nowUtc; + return await ReadStringValuesAsync(command, cancellationToken); + } + + private async Task PurgeTaskEventsAsync( + PostgresWorkflowOperationScope scope, + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) + { + if (taskIds.Count == 0) + { + return 0; + } + + await using var command = database.CreateCommand( + scope.Connection, + $""" + delete from {database.Qualify(database.Options.TaskEventsTableName)} + where workflow_task_id = any(@task_ids) + """, + scope.Transaction); + command.Parameters.Add("task_ids", NpgsqlDbType.Array | NpgsqlDbType.Text).Value = taskIds; + return await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task PurgeTasksAsync( + PostgresWorkflowOperationScope scope, + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) + { + if (taskIds.Count == 0) + { + return 0; + } + + await using var command = database.CreateCommand( + scope.Connection, + $""" + delete from {database.Qualify(database.Options.TasksTableName)} + where workflow_task_id = any(@task_ids) + """, + scope.Transaction); + command.Parameters.Add("task_ids", NpgsqlDbType.Array | NpgsqlDbType.Text).Value = taskIds; + return await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task PurgeInstancesAsync( + PostgresWorkflowOperationScope scope, + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken) + { + if (workflowInstanceIds.Count == 0) + { + return 0; + } + + await using var command = database.CreateCommand( + scope.Connection, + $""" + delete from {database.Qualify(database.Options.InstancesTableName)} + where workflow_instance_id = any(@workflow_instance_ids) + """, + scope.Transaction); + command.Parameters.Add("workflow_instance_ids", NpgsqlDbType.Array | NpgsqlDbType.Text).Value = workflowInstanceIds; + return await command.ExecuteNonQueryAsync(cancellationToken); + } + + private static async Task> ReadStringValuesAsync( + NpgsqlCommand command, + CancellationToken cancellationToken) + { + var values = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + values.Add(reader.GetString(0)); + } + + return values; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowProjectionStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowProjectionStore.cs new file mode 100644 index 000000000..d89d33479 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowProjectionStore.cs @@ -0,0 +1,1397 @@ +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 Microsoft.Extensions.Configuration; + +using Npgsql; +using NpgsqlTypes; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +public sealed class PostgresWorkflowProjectionStore( + PostgresWorkflowDatabase database, + IConfiguration configuration) : IWorkflowProjectionStore +{ + private readonly PostgresWorkflowRetentionSettings retention = PostgresWorkflowRetentionSettings.FromConfiguration(configuration); + + public Task CreateWorkflowAsync( + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + WorkflowStartExecutionPlan executionPlan, + CancellationToken cancellationToken = default) + { + return CreateWorkflowCoreAsync(definition, businessReference, executionPlan, cancellationToken); + } + + public Task> GetTasksAsync( + WorkflowTasksGetRequest request, + CancellationToken cancellationToken = default) + { + return GetTasksCoreAsync(request, cancellationToken); + } + + public Task GetTaskAsync( + string workflowTaskId, + CancellationToken cancellationToken = default) + { + return GetTaskCoreAsync(workflowTaskId, cancellationToken); + } + + public Task GetExecutionSnapshotAsync( + string workflowTaskId, + CancellationToken cancellationToken = default) + { + return GetExecutionSnapshotCoreAsync(workflowTaskId, cancellationToken); + } + + public Task AssignTaskAsync( + string workflowTaskId, + string actorId, + string assignee, + CancellationToken cancellationToken = default) + { + return AssignTaskCoreAsync(workflowTaskId, actorId, assignee, cancellationToken); + } + + public Task AssignTaskRolesAsync( + string workflowTaskId, + string actorId, + IReadOnlyCollection targetRoles, + CancellationToken cancellationToken = default) + { + return AssignTaskRolesCoreAsync(workflowTaskId, actorId, targetRoles, cancellationToken); + } + + public Task ReleaseTaskAsync( + string workflowTaskId, + string actorId, + CancellationToken cancellationToken = default) + { + return ReleaseTaskCoreAsync(workflowTaskId, actorId, cancellationToken); + } + + public Task ApplyTaskCompletionAsync( + string workflowTaskId, + string actorId, + IDictionary payload, + WorkflowTaskCompletionPlan completionPlan, + WorkflowBusinessReference? businessReference, + CancellationToken cancellationToken = default) + { + return ApplyTaskCompletionCoreAsync( + workflowTaskId, + actorId, + payload, + completionPlan, + businessReference, + cancellationToken); + } + + public Task ApplyRuntimeProgressAsync( + string workflowInstanceId, + WorkflowTaskCompletionPlan progressPlan, + WorkflowBusinessReference? businessReference, + CancellationToken cancellationToken = default) + { + return ApplyRuntimeProgressCoreAsync( + workflowInstanceId, + progressPlan, + businessReference, + cancellationToken); + } + + public Task> GetInstancesAsync( + WorkflowInstancesGetRequest request, + CancellationToken cancellationToken = default) + { + return GetInstancesCoreAsync(request, cancellationToken); + } + + public Task GetInstanceAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default) + { + return GetInstanceCoreAsync(workflowInstanceId, cancellationToken); + } + + public Task GetInstanceDetailsAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default) + { + return GetInstanceDetailsCoreAsync(workflowInstanceId, cancellationToken); + } + + private async Task CreateWorkflowCoreAsync( + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + WorkflowStartExecutionPlan executionPlan, + CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var workflowRoles = PostgresWorkflowRoleResolver.NormalizeRoles(definition.WorkflowRoles); + var workflowInstanceId = $"wf-{Guid.NewGuid():N}"; + var normalizedBusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await InsertInstanceAsync(scope, new WorkflowInstanceRow + { + WorkflowInstanceId = workflowInstanceId, + WorkflowName = definition.WorkflowName, + WorkflowVersion = definition.WorkflowVersion, + BusinessReference = normalizedBusinessReference, + Status = executionPlan.InstanceStatus, + StateJson = PostgresWorkflowJson.Serialize(executionPlan.WorkflowState), + CreatedOnUtc = now, + CompletedOnUtc = ResolveCompletedOnUtc(executionPlan.InstanceStatus, now), + StaleAfterUtc = ResolveOpenStaleAfterUtc(executionPlan.InstanceStatus, now), + PurgeAfterUtc = ResolvePurgeAfterUtc(executionPlan.InstanceStatus, now), + }, cancellationToken); + + await UpsertProjectionWorkflowInstancesAsync( + scope, + definition.WorkflowName, + definition.WorkflowVersion, + normalizedBusinessReference, + executionPlan.WorkflowState, + executionPlan.Tasks, + now, + cancellationToken); + + foreach (var taskPlan in executionPlan.Tasks) + { + await InsertTaskPlanAsync( + scope, + definition.WorkflowName, + definition.WorkflowVersion, + workflowInstanceId, + normalizedBusinessReference, + workflowRoles, + taskPlan, + now, + cancellationToken); + } + + await scope.CommitAsync(cancellationToken); + return new StartWorkflowResponse + { + WorkflowInstanceId = workflowInstanceId, + WorkflowName = definition.WorkflowName, + WorkflowVersion = definition.WorkflowVersion, + BusinessReference = normalizedBusinessReference, + }; + } + + private async Task> GetTasksCoreAsync( + WorkflowTasksGetRequest request, + CancellationToken cancellationToken) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: false, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + $""" + select workflow_task_id, + workflow_instance_id, + workflow_name, + workflow_version, + task_name, + task_type, + route, + business_reference_key, + business_reference_json::text, + assignee, + status, + workflow_roles_json::text, + task_roles_json::text, + runtime_roles_json::text, + effective_roles_json::text, + payload_json::text, + created_on_utc, + completed_on_utc, + stale_after_utc, + purge_after_utc + from {database.Qualify(database.Options.TasksTableName)} + where (@workflow_name is null or workflow_name = @workflow_name) + and (@workflow_version is null or workflow_version = @workflow_version) + and (@workflow_instance_id is null or workflow_instance_id = @workflow_instance_id) + and (@business_reference_key is null or business_reference_key = @business_reference_key) + and (@assignee is null or assignee = @assignee) + and (@status is null or status = @status) + order by created_on_utc + """); + command.Parameters.Add("workflow_name", NpgsqlDbType.Text).Value = (object?)request.WorkflowName ?? DBNull.Value; + command.Parameters.Add("workflow_version", NpgsqlDbType.Text).Value = (object?)request.WorkflowVersion ?? DBNull.Value; + command.Parameters.Add("workflow_instance_id", NpgsqlDbType.Text).Value = (object?)request.WorkflowInstanceId ?? DBNull.Value; + command.Parameters.Add("business_reference_key", NpgsqlDbType.Text).Value = (object?)request.BusinessReferenceKey ?? DBNull.Value; + command.Parameters.Add("assignee", NpgsqlDbType.Text).Value = (object?)request.Assignee ?? DBNull.Value; + command.Parameters.Add("status", NpgsqlDbType.Text).Value = (object?)request.Status ?? DBNull.Value; + + var tasks = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + tasks.Add(MapTaskSummary(ReadTaskRow(reader))); + } + + var filtered = tasks + .Where(x => x.BusinessReference.MatchesBusinessReferenceFilter(request.BusinessReferenceKey, request.BusinessReferenceParts)) + .ToArray(); + if (request.CandidateRoles.Count == 0) + { + return filtered; + } + + return filtered + .Where(x => x.EffectiveRoles.Intersect(request.CandidateRoles, StringComparer.OrdinalIgnoreCase).Any()) + .ToArray(); + } + + private async Task GetTaskCoreAsync( + string workflowTaskId, + CancellationToken cancellationToken) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: false, cancellationToken); + var task = await GetTaskRowAsync(scope, workflowTaskId, forUpdate: false, cancellationToken); + return task is null ? null : MapTaskSummary(task); + } + + private async Task GetExecutionSnapshotCoreAsync( + string workflowTaskId, + CancellationToken cancellationToken) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: false, cancellationToken); + var task = await GetTaskRowAsync(scope, workflowTaskId, forUpdate: false, cancellationToken); + if (task is null) + { + return null; + } + + var instance = await GetInstanceRowAsync(scope, task.WorkflowInstanceId, forUpdate: false, cancellationToken) + ?? throw new InvalidOperationException($"Workflow instance '{task.WorkflowInstanceId}' was not found."); + + return new WorkflowExecutionSnapshot + { + Task = MapTaskSummary(task), + WorkflowState = PostgresWorkflowJson.DeserializeJsonDictionary(instance.StateJson), + }; + } + + private async Task AssignTaskCoreAsync( + string workflowTaskId, + string actorId, + string assignee, + CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + var task = await GetTaskRowAsync(scope, workflowTaskId, forUpdate: true, cancellationToken) + ?? throw new InvalidOperationException($"Workflow task '{workflowTaskId}' was not found."); + + task.Assignee = assignee; + task.Status = ProjectionTaskStatuses.Assigned; + task.StaleAfterUtc = now.AddDays(retention.OpenStaleAfterDays); + await UpdateTaskAsync(scope, task, cancellationToken); + await InsertTaskEventAsync(scope, new WorkflowTaskEventRow + { + WorkflowTaskId = workflowTaskId, + EventType = ProjectionTaskEventTypes.Assigned, + ActorId = actorId, + PayloadJson = PostgresWorkflowJson.Serialize(new { Assignee = assignee }), + CreatedOnUtc = now, + }, cancellationToken); + await scope.CommitAsync(cancellationToken); + return MapTaskSummary(task); + } + + private async Task AssignTaskRolesCoreAsync( + string workflowTaskId, + string actorId, + IReadOnlyCollection targetRoles, + CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + var task = await GetTaskRowAsync(scope, workflowTaskId, forUpdate: true, cancellationToken) + ?? throw new InvalidOperationException($"Workflow task '{workflowTaskId}' was not found."); + + var workflowRoles = PostgresWorkflowJson.DeserializeStringArray(task.WorkflowRolesJson); + var taskRoles = PostgresWorkflowJson.DeserializeStringArray(task.TaskRolesJson); + var runtimeRoles = PostgresWorkflowRoleResolver.NormalizeRoles(targetRoles); + var effectiveRoles = PostgresWorkflowRoleResolver.ResolveEffectiveRoles(workflowRoles, taskRoles, runtimeRoles); + + task.Assignee = null; + task.Status = ProjectionTaskStatuses.Open; + task.RuntimeRolesJson = PostgresWorkflowJson.Serialize(runtimeRoles); + task.EffectiveRolesJson = PostgresWorkflowJson.Serialize(effectiveRoles); + task.StaleAfterUtc = now.AddDays(retention.OpenStaleAfterDays); + await UpdateTaskAsync(scope, task, cancellationToken); + await InsertTaskEventAsync(scope, new WorkflowTaskEventRow + { + WorkflowTaskId = workflowTaskId, + EventType = ProjectionTaskEventTypes.Reassigned, + ActorId = actorId, + PayloadJson = PostgresWorkflowJson.Serialize(new + { + RuntimeRoles = runtimeRoles, + EffectiveRoles = effectiveRoles, + }), + CreatedOnUtc = now, + }, cancellationToken); + await scope.CommitAsync(cancellationToken); + return MapTaskSummary(task); + } + + private async Task ReleaseTaskCoreAsync( + string workflowTaskId, + string actorId, + CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + var task = await GetTaskRowAsync(scope, workflowTaskId, forUpdate: true, cancellationToken) + ?? throw new InvalidOperationException($"Workflow task '{workflowTaskId}' was not found."); + + task.Assignee = null; + task.Status = ProjectionTaskStatuses.Open; + task.StaleAfterUtc = now.AddDays(retention.OpenStaleAfterDays); + await UpdateTaskAsync(scope, task, cancellationToken); + await InsertTaskEventAsync(scope, new WorkflowTaskEventRow + { + WorkflowTaskId = workflowTaskId, + EventType = ProjectionTaskEventTypes.Released, + ActorId = actorId, + PayloadJson = PostgresWorkflowJson.Serialize(new { Released = true }), + CreatedOnUtc = now, + }, cancellationToken); + await scope.CommitAsync(cancellationToken); + return MapTaskSummary(task); + } + + private async Task ApplyTaskCompletionCoreAsync( + string workflowTaskId, + string actorId, + IDictionary payload, + WorkflowTaskCompletionPlan completionPlan, + WorkflowBusinessReference? businessReference, + CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + var task = await GetTaskRowAsync(scope, workflowTaskId, forUpdate: true, cancellationToken) + ?? throw new InvalidOperationException($"Workflow task '{workflowTaskId}' was not found."); + var instance = await GetInstanceRowAsync(scope, task.WorkflowInstanceId, forUpdate: true, cancellationToken) + ?? throw new InvalidOperationException($"Workflow instance '{task.WorkflowInstanceId}' was not found."); + var updatedBusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference( + businessReference ?? PostgresWorkflowJson.DeserializeBusinessReference( + task.BusinessReference?.Key, + PostgresWorkflowJson.SerializeBusinessReference(task.BusinessReference))); + + task.Assignee ??= actorId; + task.Status = ProjectionTaskStatuses.Completed; + task.CompletedOnUtc = now; + task.StaleAfterUtc = null; + task.PurgeAfterUtc = now.AddDays(retention.CompletedPurgeAfterDays); + task.BusinessReference = updatedBusinessReference; + await UpdateTaskAsync(scope, task, cancellationToken); + + instance.StateJson = PostgresWorkflowJson.Serialize(completionPlan.WorkflowState); + instance.BusinessReference = updatedBusinessReference; + instance.Status = completionPlan.InstanceStatus; + instance.CompletedOnUtc = ResolveCompletedOnUtc(completionPlan.InstanceStatus, now); + instance.StaleAfterUtc = ResolveOpenStaleAfterUtc(completionPlan.InstanceStatus, now); + instance.PurgeAfterUtc = ResolvePurgeAfterUtc(completionPlan.InstanceStatus, now); + await UpdateInstanceAsync(scope, instance, cancellationToken); + + await UpdateTasksBusinessReferenceAsync(scope, task.WorkflowInstanceId, updatedBusinessReference, cancellationToken); + await InsertTaskEventAsync(scope, new WorkflowTaskEventRow + { + WorkflowTaskId = workflowTaskId, + EventType = ProjectionTaskEventTypes.Completed, + ActorId = actorId, + PayloadJson = PostgresWorkflowJson.Serialize(payload), + CreatedOnUtc = now, + }, cancellationToken); + + var completedProjectionWorkflowInstanceId = PostgresWorkflowJson.TryReadProjectionWorkflowInstanceId( + PostgresWorkflowJson.DeserializeJsonDictionary(task.PayloadJson)); + + if (completionPlan.NextTasks.Count > 0) + { + var workflowRoles = PostgresWorkflowJson.DeserializeStringArray(task.WorkflowRolesJson); + foreach (var nextTask in completionPlan.NextTasks) + { + await InsertTaskPlanAsync( + scope, + task.WorkflowName, + task.WorkflowVersion, + task.WorkflowInstanceId, + updatedBusinessReference, + workflowRoles, + nextTask, + now, + cancellationToken); + } + } + + await UpsertProjectionWorkflowInstancesAsync( + scope, + instance.WorkflowName, + instance.WorkflowVersion, + updatedBusinessReference, + completionPlan.WorkflowState, + completionPlan.NextTasks, + now, + cancellationToken); + + if (!string.IsNullOrWhiteSpace(completedProjectionWorkflowInstanceId) + && !completionPlan.NextTasks.Any(x => + string.Equals( + PostgresWorkflowJson.TryReadProjectionWorkflowInstanceId(x.Payload), + completedProjectionWorkflowInstanceId, + StringComparison.OrdinalIgnoreCase))) + { + await CompleteProjectionWorkflowInstanceAsync( + scope, + completedProjectionWorkflowInstanceId, + task.WorkflowName, + task.WorkflowVersion, + updatedBusinessReference, + completionPlan.WorkflowState, + now, + cancellationToken); + } + + await scope.CommitAsync(cancellationToken); + return MapTaskSummary(task); + } + + private async Task ApplyRuntimeProgressCoreAsync( + string workflowInstanceId, + WorkflowTaskCompletionPlan progressPlan, + WorkflowBusinessReference? businessReference, + CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + var instance = await GetInstanceRowAsync(scope, workflowInstanceId, forUpdate: true, cancellationToken) + ?? throw new InvalidOperationException($"Workflow instance '{workflowInstanceId}' was not found."); + var updatedBusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference( + businessReference ?? instance.BusinessReference); + + instance.StateJson = PostgresWorkflowJson.Serialize(progressPlan.WorkflowState); + instance.BusinessReference = updatedBusinessReference; + instance.Status = progressPlan.InstanceStatus; + instance.CompletedOnUtc = ResolveCompletedOnUtc(progressPlan.InstanceStatus, now); + instance.StaleAfterUtc = ResolveOpenStaleAfterUtc(progressPlan.InstanceStatus, now); + instance.PurgeAfterUtc = ResolvePurgeAfterUtc(progressPlan.InstanceStatus, now); + await UpdateInstanceAsync(scope, instance, cancellationToken); + + await UpdateTasksBusinessReferenceAsync(scope, workflowInstanceId, updatedBusinessReference, cancellationToken); + await UpsertProjectionWorkflowInstancesAsync( + scope, + instance.WorkflowName, + instance.WorkflowVersion, + updatedBusinessReference, + progressPlan.WorkflowState, + progressPlan.NextTasks, + now, + cancellationToken); + + if (progressPlan.NextTasks.Count > 0) + { + var existingTasks = await GetTaskRowsByInstanceIdAsync(scope, workflowInstanceId, cancellationToken); + var workflowRoles = existingTasks.Count > 0 + ? PostgresWorkflowJson.DeserializeStringArray(existingTasks[0].WorkflowRolesJson) + : []; + foreach (var nextTask in progressPlan.NextTasks) + { + await InsertTaskPlanAsync( + scope, + instance.WorkflowName, + instance.WorkflowVersion, + workflowInstanceId, + updatedBusinessReference, + workflowRoles, + nextTask, + now, + cancellationToken); + } + } + + await scope.CommitAsync(cancellationToken); + } + + private async Task> GetInstancesCoreAsync( + WorkflowInstancesGetRequest request, + CancellationToken cancellationToken) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: false, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + $""" + select workflow_instance_id, + workflow_name, + workflow_version, + business_reference_key, + business_reference_json::text, + status, + created_on_utc, + completed_on_utc + from {database.Qualify(database.Options.InstancesTableName)} + where (@workflow_name is null or workflow_name = @workflow_name) + and (@workflow_version is null or workflow_version = @workflow_version) + and (@business_reference_key is null or business_reference_key = @business_reference_key) + and (@status is null or status = @status) + order by created_on_utc desc + """); + command.Parameters.Add("workflow_name", NpgsqlDbType.Text).Value = (object?)request.WorkflowName ?? DBNull.Value; + command.Parameters.Add("workflow_version", NpgsqlDbType.Text).Value = (object?)request.WorkflowVersion ?? DBNull.Value; + command.Parameters.Add("business_reference_key", NpgsqlDbType.Text).Value = (object?)request.BusinessReferenceKey ?? DBNull.Value; + command.Parameters.Add("status", NpgsqlDbType.Text).Value = (object?)request.Status ?? DBNull.Value; + + var instances = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + instances.Add(new WorkflowInstanceSummary + { + WorkflowInstanceId = reader.GetString(0), + WorkflowName = reader.GetString(1), + WorkflowVersion = reader.GetString(2), + BusinessReference = PostgresWorkflowJson.DeserializeBusinessReference( + reader.IsDBNull(3) ? null : reader.GetString(3), + reader.IsDBNull(4) ? null : reader.GetString(4)), + Status = reader.GetString(5), + CreatedOnUtc = reader.GetDateTime(6), + CompletedOnUtc = reader.IsDBNull(7) ? null : reader.GetDateTime(7), + }); + } + + return instances + .Where(x => x.BusinessReference.MatchesBusinessReferenceFilter(request.BusinessReferenceKey, request.BusinessReferenceParts)) + .ToArray(); + } + + private async Task GetInstanceCoreAsync( + string workflowInstanceId, + CancellationToken cancellationToken) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: false, cancellationToken); + var instance = await GetInstanceRowAsync(scope, workflowInstanceId, forUpdate: false, cancellationToken); + return instance is null ? null : MapInstanceSummary(instance); + } + + private async Task GetInstanceDetailsCoreAsync( + string workflowInstanceId, + CancellationToken cancellationToken) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: false, cancellationToken); + var instance = await GetInstanceRowAsync(scope, workflowInstanceId, forUpdate: false, cancellationToken); + if (instance is null) + { + return null; + } + + var tasks = await GetTaskRowsByInstanceIdAsync(scope, workflowInstanceId, cancellationToken); + if (tasks.Count == 0) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + select workflow_task_id, + workflow_instance_id, + workflow_name, + workflow_version, + task_name, + task_type, + route, + business_reference_key, + business_reference_json::text, + assignee, + status, + workflow_roles_json::text, + task_roles_json::text, + runtime_roles_json::text, + effective_roles_json::text, + payload_json::text, + created_on_utc, + completed_on_utc, + stale_after_utc, + purge_after_utc + from {database.Qualify(database.Options.TasksTableName)} + where payload_json ->> @projection_key = @workflow_instance_id + order by created_on_utc + """); + command.Parameters.AddWithValue("projection_key", WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey); + command.Parameters.AddWithValue("workflow_instance_id", workflowInstanceId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + tasks.Add(ReadTaskRow(reader)); + } + } + + var taskLookup = tasks.ToDictionary(x => x.WorkflowTaskId, StringComparer.OrdinalIgnoreCase); + var taskEvents = tasks.Count == 0 + ? [] + : await GetTaskEventsAsync(scope, tasks.Select(x => x.WorkflowTaskId).ToArray(), taskLookup, cancellationToken); + + return new WorkflowInstanceProjectionDetails + { + Instance = MapInstanceSummary(instance), + WorkflowState = PostgresWorkflowJson.DeserializeObjectDictionary(instance.StateJson), + Tasks = tasks.Select(MapTaskSummary).ToArray(), + TaskEvents = taskEvents, + }; + } + + private async Task InsertTaskPlanAsync( + PostgresWorkflowOperationScope scope, + string workflowName, + string workflowVersion, + string workflowInstanceId, + WorkflowBusinessReference? businessReference, + IReadOnlyCollection workflowRoles, + WorkflowExecutionTaskPlan taskPlan, + DateTime createdOnUtc, + CancellationToken cancellationToken) + { + var normalizedWorkflowRoles = PostgresWorkflowRoleResolver.NormalizeRoles( + taskPlan.WorkflowRoles.Count > 0 ? taskPlan.WorkflowRoles : workflowRoles); + var taskRoles = PostgresWorkflowRoleResolver.NormalizeRoles(taskPlan.TaskRoles); + var runtimeRoles = PostgresWorkflowRoleResolver.NormalizeRoles(taskPlan.RuntimeRoles); + var effectiveRoles = PostgresWorkflowRoleResolver.ResolveEffectiveRoles(normalizedWorkflowRoles, taskRoles, runtimeRoles); + var workflowTaskId = $"wft-{Guid.NewGuid():N}"; + + await InsertTaskAsync(scope, new WorkflowTaskRow + { + WorkflowTaskId = workflowTaskId, + WorkflowInstanceId = workflowInstanceId, + WorkflowName = taskPlan.WorkflowName ?? workflowName, + WorkflowVersion = taskPlan.WorkflowVersion ?? workflowVersion, + TaskName = taskPlan.TaskName, + TaskType = taskPlan.TaskType, + Route = taskPlan.Route, + BusinessReference = businessReference, + Status = ProjectionTaskStatuses.Open, + WorkflowRolesJson = PostgresWorkflowJson.Serialize(normalizedWorkflowRoles), + TaskRolesJson = PostgresWorkflowJson.Serialize(taskRoles), + RuntimeRolesJson = PostgresWorkflowJson.Serialize(runtimeRoles), + EffectiveRolesJson = PostgresWorkflowJson.Serialize(effectiveRoles), + PayloadJson = PostgresWorkflowJson.Serialize(taskPlan.Payload), + CreatedOnUtc = createdOnUtc, + StaleAfterUtc = createdOnUtc.AddDays(retention.OpenStaleAfterDays), + }, cancellationToken); + + await InsertTaskEventAsync(scope, new WorkflowTaskEventRow + { + WorkflowTaskId = workflowTaskId, + EventType = ProjectionTaskEventTypes.Created, + PayloadJson = PostgresWorkflowJson.Serialize(new + { + taskPlan.TaskName, + taskPlan.TaskType, + taskPlan.Route, + Payload = PostgresWorkflowJson.ToPublicTaskPayload(taskPlan.Payload), + }), + CreatedOnUtc = createdOnUtc, + }, cancellationToken); + } + + private async Task InsertInstanceAsync( + PostgresWorkflowOperationScope scope, + WorkflowInstanceRow instance, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + insert into {database.Qualify(database.Options.InstancesTableName)} ( + workflow_instance_id, + workflow_name, + workflow_version, + business_reference_key, + business_reference_json, + status, + state_json, + created_on_utc, + completed_on_utc, + stale_after_utc, + purge_after_utc + ) + values ( + @workflow_instance_id, + @workflow_name, + @workflow_version, + @business_reference_key, + @business_reference_json, + @status, + @state_json, + @created_on_utc, + @completed_on_utc, + @stale_after_utc, + @purge_after_utc + ) + """, + scope.Transaction); + BindInstance(command, instance); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task UpdateInstanceAsync( + PostgresWorkflowOperationScope scope, + WorkflowInstanceRow instance, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + update {database.Qualify(database.Options.InstancesTableName)} + set workflow_name = @workflow_name, + workflow_version = @workflow_version, + business_reference_key = @business_reference_key, + business_reference_json = @business_reference_json, + status = @status, + state_json = @state_json, + created_on_utc = @created_on_utc, + completed_on_utc = @completed_on_utc, + stale_after_utc = @stale_after_utc, + purge_after_utc = @purge_after_utc + where workflow_instance_id = @workflow_instance_id + """, + scope.Transaction); + BindInstance(command, instance); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task UpsertInstanceAsync( + PostgresWorkflowOperationScope scope, + WorkflowInstanceRow instance, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + insert into {database.Qualify(database.Options.InstancesTableName)} ( + workflow_instance_id, + workflow_name, + workflow_version, + business_reference_key, + business_reference_json, + status, + state_json, + created_on_utc, + completed_on_utc, + stale_after_utc, + purge_after_utc + ) + values ( + @workflow_instance_id, + @workflow_name, + @workflow_version, + @business_reference_key, + @business_reference_json, + @status, + @state_json, + @created_on_utc, + @completed_on_utc, + @stale_after_utc, + @purge_after_utc + ) + on conflict (workflow_instance_id) do update + set workflow_name = excluded.workflow_name, + workflow_version = excluded.workflow_version, + business_reference_key = excluded.business_reference_key, + business_reference_json = excluded.business_reference_json, + status = excluded.status, + state_json = excluded.state_json, + completed_on_utc = excluded.completed_on_utc, + stale_after_utc = excluded.stale_after_utc, + purge_after_utc = excluded.purge_after_utc + """, + scope.Transaction); + BindInstance(command, instance); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private void BindInstance(NpgsqlCommand command, WorkflowInstanceRow instance) + { + command.Parameters.AddWithValue("workflow_instance_id", instance.WorkflowInstanceId); + command.Parameters.AddWithValue("workflow_name", instance.WorkflowName); + command.Parameters.AddWithValue("workflow_version", instance.WorkflowVersion); + command.Parameters.AddWithValue("business_reference_key", (object?)instance.BusinessReference?.Key ?? DBNull.Value); + command.Parameters.Add("business_reference_json", NpgsqlDbType.Jsonb).Value = + (object?)PostgresWorkflowJson.SerializeBusinessReference(instance.BusinessReference) ?? DBNull.Value; + command.Parameters.AddWithValue("status", instance.Status); + command.Parameters.Add("state_json", NpgsqlDbType.Jsonb).Value = instance.StateJson; + command.Parameters.AddWithValue("created_on_utc", instance.CreatedOnUtc); + command.Parameters.AddWithValue("completed_on_utc", (object?)instance.CompletedOnUtc ?? DBNull.Value); + command.Parameters.AddWithValue("stale_after_utc", (object?)instance.StaleAfterUtc ?? DBNull.Value); + command.Parameters.AddWithValue("purge_after_utc", (object?)instance.PurgeAfterUtc ?? DBNull.Value); + } + + private async Task InsertTaskAsync( + PostgresWorkflowOperationScope scope, + WorkflowTaskRow task, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + insert into {database.Qualify(database.Options.TasksTableName)} ( + workflow_task_id, + workflow_instance_id, + workflow_name, + workflow_version, + task_name, + task_type, + route, + business_reference_key, + business_reference_json, + assignee, + status, + workflow_roles_json, + task_roles_json, + runtime_roles_json, + effective_roles_json, + payload_json, + created_on_utc, + completed_on_utc, + stale_after_utc, + purge_after_utc + ) + values ( + @workflow_task_id, + @workflow_instance_id, + @workflow_name, + @workflow_version, + @task_name, + @task_type, + @route, + @business_reference_key, + @business_reference_json, + @assignee, + @status, + @workflow_roles_json, + @task_roles_json, + @runtime_roles_json, + @effective_roles_json, + @payload_json, + @created_on_utc, + @completed_on_utc, + @stale_after_utc, + @purge_after_utc + ) + """, + scope.Transaction); + BindTask(command, task); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task UpdateTaskAsync( + PostgresWorkflowOperationScope scope, + WorkflowTaskRow task, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + update {database.Qualify(database.Options.TasksTableName)} + set workflow_instance_id = @workflow_instance_id, + workflow_name = @workflow_name, + workflow_version = @workflow_version, + task_name = @task_name, + task_type = @task_type, + route = @route, + business_reference_key = @business_reference_key, + business_reference_json = @business_reference_json, + assignee = @assignee, + status = @status, + workflow_roles_json = @workflow_roles_json, + task_roles_json = @task_roles_json, + runtime_roles_json = @runtime_roles_json, + effective_roles_json = @effective_roles_json, + payload_json = @payload_json, + created_on_utc = @created_on_utc, + completed_on_utc = @completed_on_utc, + stale_after_utc = @stale_after_utc, + purge_after_utc = @purge_after_utc + where workflow_task_id = @workflow_task_id + """, + scope.Transaction); + BindTask(command, task); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private void BindTask(NpgsqlCommand command, WorkflowTaskRow task) + { + command.Parameters.AddWithValue("workflow_task_id", task.WorkflowTaskId); + command.Parameters.AddWithValue("workflow_instance_id", task.WorkflowInstanceId); + command.Parameters.AddWithValue("workflow_name", task.WorkflowName); + command.Parameters.AddWithValue("workflow_version", task.WorkflowVersion); + command.Parameters.AddWithValue("task_name", task.TaskName); + command.Parameters.AddWithValue("task_type", task.TaskType); + command.Parameters.AddWithValue("route", task.Route); + command.Parameters.AddWithValue("business_reference_key", (object?)task.BusinessReference?.Key ?? DBNull.Value); + command.Parameters.Add("business_reference_json", NpgsqlDbType.Jsonb).Value = + (object?)PostgresWorkflowJson.SerializeBusinessReference(task.BusinessReference) ?? DBNull.Value; + command.Parameters.AddWithValue("assignee", (object?)task.Assignee ?? DBNull.Value); + command.Parameters.AddWithValue("status", task.Status); + command.Parameters.Add("workflow_roles_json", NpgsqlDbType.Jsonb).Value = task.WorkflowRolesJson; + command.Parameters.Add("task_roles_json", NpgsqlDbType.Jsonb).Value = task.TaskRolesJson; + command.Parameters.Add("runtime_roles_json", NpgsqlDbType.Jsonb).Value = task.RuntimeRolesJson; + command.Parameters.Add("effective_roles_json", NpgsqlDbType.Jsonb).Value = task.EffectiveRolesJson; + command.Parameters.Add("payload_json", NpgsqlDbType.Jsonb).Value = task.PayloadJson; + command.Parameters.AddWithValue("created_on_utc", task.CreatedOnUtc); + command.Parameters.AddWithValue("completed_on_utc", (object?)task.CompletedOnUtc ?? DBNull.Value); + command.Parameters.AddWithValue("stale_after_utc", (object?)task.StaleAfterUtc ?? DBNull.Value); + command.Parameters.AddWithValue("purge_after_utc", (object?)task.PurgeAfterUtc ?? DBNull.Value); + } + + private async Task InsertTaskEventAsync( + PostgresWorkflowOperationScope scope, + WorkflowTaskEventRow taskEvent, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + insert into {database.Qualify(database.Options.TaskEventsTableName)} ( + workflow_task_id, + event_type, + actor_id, + payload_json, + created_on_utc + ) + values ( + @workflow_task_id, + @event_type, + @actor_id, + @payload_json, + @created_on_utc + ) + """, + scope.Transaction); + command.Parameters.AddWithValue("workflow_task_id", taskEvent.WorkflowTaskId); + command.Parameters.AddWithValue("event_type", taskEvent.EventType); + command.Parameters.AddWithValue("actor_id", (object?)taskEvent.ActorId ?? DBNull.Value); + command.Parameters.Add("payload_json", NpgsqlDbType.Jsonb).Value = taskEvent.PayloadJson; + command.Parameters.AddWithValue("created_on_utc", taskEvent.CreatedOnUtc); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task GetTaskRowAsync( + PostgresWorkflowOperationScope scope, + string workflowTaskId, + bool forUpdate, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + select workflow_task_id, + workflow_instance_id, + workflow_name, + workflow_version, + task_name, + task_type, + route, + business_reference_key, + business_reference_json::text, + assignee, + status, + workflow_roles_json::text, + task_roles_json::text, + runtime_roles_json::text, + effective_roles_json::text, + payload_json::text, + created_on_utc, + completed_on_utc, + stale_after_utc, + purge_after_utc + from {database.Qualify(database.Options.TasksTableName)} + where workflow_task_id = @workflow_task_id + {(forUpdate ? "for update" : string.Empty)} + """, + scope.Transaction); + command.Parameters.AddWithValue("workflow_task_id", workflowTaskId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await reader.ReadAsync(cancellationToken) ? ReadTaskRow(reader) : null; + } + + private async Task> GetTaskRowsByInstanceIdAsync( + PostgresWorkflowOperationScope scope, + string workflowInstanceId, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + select workflow_task_id, + workflow_instance_id, + workflow_name, + workflow_version, + task_name, + task_type, + route, + business_reference_key, + business_reference_json::text, + assignee, + status, + workflow_roles_json::text, + task_roles_json::text, + runtime_roles_json::text, + effective_roles_json::text, + payload_json::text, + created_on_utc, + completed_on_utc, + stale_after_utc, + purge_after_utc + from {database.Qualify(database.Options.TasksTableName)} + where workflow_instance_id = @workflow_instance_id + order by created_on_utc + """, + scope.Transaction); + command.Parameters.AddWithValue("workflow_instance_id", workflowInstanceId); + var tasks = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + tasks.Add(ReadTaskRow(reader)); + } + + return tasks; + } + + private async Task> GetTaskEventsAsync( + PostgresWorkflowOperationScope scope, + IReadOnlyCollection taskIds, + IReadOnlyDictionary taskLookup, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + select workflow_task_id, + event_type, + actor_id, + payload_json::text, + created_on_utc + from {database.Qualify(database.Options.TaskEventsTableName)} + where workflow_task_id = any(@workflow_task_ids) + order by created_on_utc + """, + scope.Transaction); + command.Parameters.Add("workflow_task_ids", NpgsqlDbType.Array | NpgsqlDbType.Text).Value = taskIds.ToArray(); + + var results = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var workflowTaskId = reader.GetString(0); + results.Add(new WorkflowTaskEventSummary + { + WorkflowTaskId = workflowTaskId, + TaskName = taskLookup.TryGetValue(workflowTaskId, out var task) ? task.TaskName : null, + EventType = reader.GetString(1), + ActorId = reader.IsDBNull(2) ? null : reader.GetString(2), + Payload = PostgresWorkflowJson.DeserializeObjectDictionary(reader.GetString(3)), + CreatedOnUtc = reader.GetDateTime(4), + }); + } + + return results; + } + + private async Task GetInstanceRowAsync( + PostgresWorkflowOperationScope scope, + string workflowInstanceId, + bool forUpdate, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + select workflow_instance_id, + workflow_name, + workflow_version, + business_reference_key, + business_reference_json::text, + status, + state_json::text, + created_on_utc, + completed_on_utc, + stale_after_utc, + purge_after_utc + from {database.Qualify(database.Options.InstancesTableName)} + where workflow_instance_id = @workflow_instance_id + {(forUpdate ? "for update" : string.Empty)} + """, + scope.Transaction); + command.Parameters.AddWithValue("workflow_instance_id", workflowInstanceId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await reader.ReadAsync(cancellationToken) ? ReadInstanceRow(reader) : null; + } + + private async Task UpdateTasksBusinessReferenceAsync( + PostgresWorkflowOperationScope scope, + string workflowInstanceId, + WorkflowBusinessReference? businessReference, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + update {database.Qualify(database.Options.TasksTableName)} + set business_reference_key = @business_reference_key, + business_reference_json = @business_reference_json + where workflow_instance_id = @workflow_instance_id + """, + scope.Transaction); + command.Parameters.AddWithValue("business_reference_key", (object?)businessReference?.Key ?? DBNull.Value); + command.Parameters.Add("business_reference_json", NpgsqlDbType.Jsonb).Value = + (object?)PostgresWorkflowJson.SerializeBusinessReference(businessReference) ?? DBNull.Value; + command.Parameters.AddWithValue("workflow_instance_id", workflowInstanceId); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task UpsertProjectionWorkflowInstancesAsync( + PostgresWorkflowOperationScope scope, + string rootWorkflowName, + string rootWorkflowVersion, + WorkflowBusinessReference? businessReference, + IReadOnlyDictionary workflowState, + IReadOnlyCollection taskPlans, + DateTime now, + CancellationToken cancellationToken) + { + foreach (var projectionTaskGroup in taskPlans + .Select(taskPlan => new + { + TaskPlan = taskPlan, + ProjectionWorkflowInstanceId = PostgresWorkflowJson.TryReadProjectionWorkflowInstanceId(taskPlan.Payload), + }) + .Where(x => !string.IsNullOrWhiteSpace(x.ProjectionWorkflowInstanceId)) + .GroupBy(x => x.ProjectionWorkflowInstanceId!, StringComparer.OrdinalIgnoreCase)) + { + var firstTaskPlan = projectionTaskGroup.First().TaskPlan; + await UpsertInstanceAsync(scope, new WorkflowInstanceRow + { + WorkflowInstanceId = projectionTaskGroup.Key, + WorkflowName = firstTaskPlan.WorkflowName ?? rootWorkflowName, + WorkflowVersion = firstTaskPlan.WorkflowVersion ?? rootWorkflowVersion, + BusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference), + Status = ProjectionInstanceStatuses.Open, + StateJson = PostgresWorkflowJson.Serialize(workflowState), + CreatedOnUtc = now, + StaleAfterUtc = ResolveOpenStaleAfterUtc(ProjectionInstanceStatuses.Open, now), + }, cancellationToken); + } + } + + private async Task CompleteProjectionWorkflowInstanceAsync( + PostgresWorkflowOperationScope scope, + string projectionWorkflowInstanceId, + string workflowName, + string workflowVersion, + WorkflowBusinessReference? businessReference, + IReadOnlyDictionary workflowState, + DateTime now, + CancellationToken cancellationToken) + { + await UpsertInstanceAsync(scope, new WorkflowInstanceRow + { + WorkflowInstanceId = projectionWorkflowInstanceId, + WorkflowName = workflowName, + WorkflowVersion = workflowVersion, + BusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference), + Status = ProjectionInstanceStatuses.Completed, + StateJson = PostgresWorkflowJson.Serialize(workflowState), + CreatedOnUtc = now, + CompletedOnUtc = now, + PurgeAfterUtc = ResolvePurgeAfterUtc(ProjectionInstanceStatuses.Completed, now), + }, cancellationToken); + } + + private WorkflowTaskRow ReadTaskRow(NpgsqlDataReader reader) + { + return new WorkflowTaskRow + { + WorkflowTaskId = reader.GetString(0), + WorkflowInstanceId = reader.GetString(1), + WorkflowName = reader.GetString(2), + WorkflowVersion = reader.GetString(3), + TaskName = reader.GetString(4), + TaskType = reader.GetString(5), + Route = reader.GetString(6), + BusinessReference = PostgresWorkflowJson.DeserializeBusinessReference( + reader.IsDBNull(7) ? null : reader.GetString(7), + reader.IsDBNull(8) ? null : reader.GetString(8)), + Assignee = reader.IsDBNull(9) ? null : reader.GetString(9), + Status = reader.GetString(10), + WorkflowRolesJson = reader.GetString(11), + TaskRolesJson = reader.GetString(12), + RuntimeRolesJson = reader.GetString(13), + EffectiveRolesJson = reader.GetString(14), + PayloadJson = reader.GetString(15), + CreatedOnUtc = reader.GetDateTime(16), + CompletedOnUtc = reader.IsDBNull(17) ? null : reader.GetDateTime(17), + StaleAfterUtc = reader.IsDBNull(18) ? null : reader.GetDateTime(18), + PurgeAfterUtc = reader.IsDBNull(19) ? null : reader.GetDateTime(19), + }; + } + + private WorkflowInstanceRow ReadInstanceRow(NpgsqlDataReader reader) + { + return new WorkflowInstanceRow + { + WorkflowInstanceId = reader.GetString(0), + WorkflowName = reader.GetString(1), + WorkflowVersion = reader.GetString(2), + BusinessReference = PostgresWorkflowJson.DeserializeBusinessReference( + reader.IsDBNull(3) ? null : reader.GetString(3), + reader.IsDBNull(4) ? null : reader.GetString(4)), + Status = reader.GetString(5), + StateJson = reader.GetString(6), + CreatedOnUtc = reader.GetDateTime(7), + CompletedOnUtc = reader.IsDBNull(8) ? null : reader.GetDateTime(8), + StaleAfterUtc = reader.IsDBNull(9) ? null : reader.GetDateTime(9), + PurgeAfterUtc = reader.IsDBNull(10) ? null : reader.GetDateTime(10), + }; + } + + private WorkflowTaskSummary MapTaskSummary(WorkflowTaskRow task) + { + return new WorkflowTaskSummary + { + WorkflowTaskId = task.WorkflowTaskId, + WorkflowInstanceId = task.WorkflowInstanceId, + WorkflowName = task.WorkflowName, + WorkflowVersion = task.WorkflowVersion, + TaskName = task.TaskName, + TaskType = task.TaskType, + Route = task.Route, + BusinessReference = task.BusinessReference, + Assignee = task.Assignee, + Status = task.Status, + WorkflowRoles = PostgresWorkflowJson.DeserializeStringArray(task.WorkflowRolesJson), + TaskRoles = PostgresWorkflowJson.DeserializeStringArray(task.TaskRolesJson), + RuntimeRoles = PostgresWorkflowJson.DeserializeStringArray(task.RuntimeRolesJson), + EffectiveRoles = PostgresWorkflowJson.DeserializeStringArray(task.EffectiveRolesJson), + Payload = PostgresWorkflowJson.DeserializePublicTaskPayload(task.PayloadJson), + CreatedOnUtc = task.CreatedOnUtc, + CompletedOnUtc = task.CompletedOnUtc, + StaleAfterUtc = task.StaleAfterUtc, + PurgeAfterUtc = task.PurgeAfterUtc, + }; + } + + private static WorkflowInstanceSummary MapInstanceSummary(WorkflowInstanceRow instance) + { + return new WorkflowInstanceSummary + { + WorkflowInstanceId = instance.WorkflowInstanceId, + WorkflowName = instance.WorkflowName, + WorkflowVersion = instance.WorkflowVersion, + BusinessReference = instance.BusinessReference, + Status = instance.Status, + CreatedOnUtc = instance.CreatedOnUtc, + CompletedOnUtc = instance.CompletedOnUtc, + }; + } + + private static DateTime? ResolveCompletedOnUtc(string instanceStatus, DateTime now) + { + return string.Equals(instanceStatus, ProjectionInstanceStatuses.Completed, StringComparison.OrdinalIgnoreCase) + ? now + : null; + } + + private DateTime? ResolveOpenStaleAfterUtc(string instanceStatus, DateTime now) + { + return string.Equals(instanceStatus, ProjectionInstanceStatuses.Completed, StringComparison.OrdinalIgnoreCase) + ? null + : now.AddDays(retention.OpenStaleAfterDays); + } + + private DateTime? ResolvePurgeAfterUtc(string instanceStatus, DateTime now) + { + return string.Equals(instanceStatus, ProjectionInstanceStatuses.Completed, StringComparison.OrdinalIgnoreCase) + ? now.AddDays(retention.CompletedPurgeAfterDays) + : null; + } + + private static class ProjectionInstanceStatuses + { + public const string Open = "Open"; + public const string Completed = "Completed"; + } + + private static class ProjectionTaskStatuses + { + public const string Open = "Open"; + public const string Assigned = "Assigned"; + public const string Completed = "Completed"; + } + + private static class ProjectionTaskEventTypes + { + public const string Created = "Created"; + public const string Assigned = "Assigned"; + public const string Reassigned = "Reassigned"; + public const string Released = "Released"; + public const string Completed = "Completed"; + } + + private sealed class WorkflowInstanceRow + { + public string WorkflowInstanceId { get; set; } = string.Empty; + public string WorkflowName { get; set; } = string.Empty; + public string WorkflowVersion { get; set; } = "1.0.0"; + public WorkflowBusinessReference? BusinessReference { get; set; } + public string Status { get; set; } = ProjectionInstanceStatuses.Open; + public string StateJson { get; set; } = "{}"; + public DateTime CreatedOnUtc { get; set; } = DateTime.UtcNow; + public DateTime? CompletedOnUtc { get; set; } + public DateTime? StaleAfterUtc { get; set; } + public DateTime? PurgeAfterUtc { get; set; } + } + + private sealed class WorkflowTaskRow + { + public string WorkflowTaskId { get; set; } = string.Empty; + public string WorkflowInstanceId { get; set; } = string.Empty; + public string WorkflowName { get; set; } = string.Empty; + public string WorkflowVersion { get; set; } = "1.0.0"; + public string TaskName { get; set; } = string.Empty; + public string TaskType { get; set; } = string.Empty; + public string Route { get; set; } = string.Empty; + public WorkflowBusinessReference? BusinessReference { get; set; } + public string? Assignee { get; set; } + public string Status { get; set; } = ProjectionTaskStatuses.Open; + public string WorkflowRolesJson { get; set; } = "[]"; + public string TaskRolesJson { get; set; } = "[]"; + public string RuntimeRolesJson { get; set; } = "[]"; + public string EffectiveRolesJson { get; set; } = "[]"; + public string PayloadJson { get; set; } = "{}"; + public DateTime CreatedOnUtc { get; set; } = DateTime.UtcNow; + public DateTime? CompletedOnUtc { get; set; } + public DateTime? StaleAfterUtc { get; set; } + public DateTime? PurgeAfterUtc { get; set; } + } + + private sealed class WorkflowTaskEventRow + { + public string WorkflowTaskId { get; set; } = string.Empty; + public string EventType { get; set; } = string.Empty; + public string? ActorId { get; set; } + public string PayloadJson { get; set; } = "{}"; + public DateTime CreatedOnUtc { get; set; } = DateTime.UtcNow; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowRuntimeStateStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowRuntimeStateStore.cs new file mode 100644 index 000000000..b46009e03 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowRuntimeStateStore.cs @@ -0,0 +1,397 @@ +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 Npgsql; +using Npgsql.PostgresTypes; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +public sealed class PostgresWorkflowRuntimeStateStore( + PostgresWorkflowDatabase database) : IWorkflowRuntimeStateStore +{ + private const string StaleRuntimeStatus = "Stale"; + private PostgresWorkflowBackendOptions postgres => database.Options; + + public async Task UpsertAsync( + WorkflowRuntimeStateRecord state, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(state); + + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + + var currentVersion = await GetCurrentVersionAsync(scope.Connection, scope.Transaction!, state.WorkflowInstanceId, cancellationToken); + if (currentVersion is null) + { + if (state.Version > 1) + { + throw new WorkflowRuntimeStateConcurrencyException(state.WorkflowInstanceId, state.Version, 0); + } + + try + { + await InsertAsync(scope.Connection, scope.Transaction!, state, cancellationToken); + await scope.CommitAsync(cancellationToken); + return; + } + catch (PostgresException exception) when (exception.SqlState == PostgresErrorCodes.UniqueViolation && state.Version > 0) + { + throw new WorkflowRuntimeStateConcurrencyException( + state.WorkflowInstanceId, + state.Version, + await ResolveActualVersionAsync(scope.Connection, scope.Transaction!, state.WorkflowInstanceId, cancellationToken)); + } + } + + if (state.Version > 0 && currentVersion.Value + 1 != state.Version) + { + throw new WorkflowRuntimeStateConcurrencyException( + state.WorkflowInstanceId, + state.Version, + currentVersion.Value); + } + + var expectedCurrentVersion = state.Version > 0 + ? state.Version - 1 + : currentVersion.Value; + var updatedRows = await UpdateAsync(scope.Connection, scope.Transaction!, state, expectedCurrentVersion, cancellationToken); + if (updatedRows == 0) + { + throw new WorkflowRuntimeStateConcurrencyException( + state.WorkflowInstanceId, + state.Version, + await ResolveActualVersionAsync(scope.Connection, scope.Transaction!, state.WorkflowInstanceId, cancellationToken)); + } + + await scope.CommitAsync(cancellationToken); + } + + public async Task GetAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: false, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + $""" + select workflow_instance_id, + workflow_name, + workflow_version, + version_no, + business_reference_key, + business_reference_json, + runtime_provider, + runtime_instance_id, + runtime_status, + state_json, + created_on_utc, + completed_on_utc, + stale_after_utc, + purge_after_utc, + last_updated_on_utc + from {Qualify(postgres.RuntimeStatesTableName)} + where workflow_instance_id = @workflow_instance_id + """); + command.Parameters.AddWithValue("workflow_instance_id", workflowInstanceId); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await reader.ReadAsync(cancellationToken) + ? ReadState(reader) + : null; + } + + public async Task> GetManyAsync( + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken = default) + { + if (workflowInstanceIds.Count == 0) + { + return []; + } + + await using var scope = await database.OpenScopeAsync(requireTransaction: false, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + $""" + select workflow_instance_id, + workflow_name, + workflow_version, + version_no, + business_reference_key, + business_reference_json, + runtime_provider, + runtime_instance_id, + runtime_status, + state_json, + created_on_utc, + completed_on_utc, + stale_after_utc, + purge_after_utc, + last_updated_on_utc + from {Qualify(postgres.RuntimeStatesTableName)} + where workflow_instance_id = any(@workflow_instance_ids) + """); + command.Parameters.AddWithValue("workflow_instance_ids", workflowInstanceIds.ToArray()); + + var results = new List(workflowInstanceIds.Count); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + results.Add(ReadState(reader)); + } + + return results; + } + + public async Task MarkStaleAsync( + IReadOnlyCollection workflowInstanceIds, + DateTime updatedOnUtc, + CancellationToken cancellationToken = default) + { + if (workflowInstanceIds.Count == 0) + { + return 0; + } + + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + $""" + update {Qualify(postgres.RuntimeStatesTableName)} + set runtime_status = @runtime_status, + stale_after_utc = null, + last_updated_on_utc = @last_updated_on_utc + where workflow_instance_id = any(@workflow_instance_ids) + """); + command.Parameters.AddWithValue("runtime_status", StaleRuntimeStatus); + command.Parameters.AddWithValue("last_updated_on_utc", updatedOnUtc); + command.Parameters.AddWithValue("workflow_instance_ids", workflowInstanceIds.ToArray()); + + var affected = await command.ExecuteNonQueryAsync(cancellationToken); + await scope.CommitAsync(cancellationToken); + return affected; + } + + public async Task DeleteAsync( + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken = default) + { + if (workflowInstanceIds.Count == 0) + { + return 0; + } + + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + $""" + delete from {Qualify(postgres.RuntimeStatesTableName)} + where workflow_instance_id = any(@workflow_instance_ids) + """); + command.Parameters.AddWithValue("workflow_instance_ids", workflowInstanceIds.ToArray()); + var affected = await command.ExecuteNonQueryAsync(cancellationToken); + await scope.CommitAsync(cancellationToken); + return affected; + } + + private async Task InsertAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + WorkflowRuntimeStateRecord state, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + connection, + $""" + insert into {Qualify(postgres.RuntimeStatesTableName)} ( + workflow_instance_id, + workflow_name, + workflow_version, + version_no, + business_reference_key, + business_reference_json, + runtime_provider, + runtime_instance_id, + runtime_status, + state_json, + created_on_utc, + completed_on_utc, + stale_after_utc, + purge_after_utc, + last_updated_on_utc + ) + values ( + @workflow_instance_id, + @workflow_name, + @workflow_version, + @version_no, + @business_reference_key, + @business_reference_json, + @runtime_provider, + @runtime_instance_id, + @runtime_status, + @state_json, + @created_on_utc, + @completed_on_utc, + @stale_after_utc, + @purge_after_utc, + @last_updated_on_utc + ) + """, + transaction); + BindState(command, state); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task UpdateAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + WorkflowRuntimeStateRecord state, + long expectedCurrentVersion, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + connection, + $""" + update {Qualify(postgres.RuntimeStatesTableName)} + set workflow_name = @workflow_name, + workflow_version = @workflow_version, + version_no = @version_no, + business_reference_key = @business_reference_key, + business_reference_json = @business_reference_json, + runtime_provider = @runtime_provider, + runtime_instance_id = @runtime_instance_id, + runtime_status = @runtime_status, + state_json = @state_json, + created_on_utc = @created_on_utc, + completed_on_utc = @completed_on_utc, + stale_after_utc = @stale_after_utc, + purge_after_utc = @purge_after_utc, + last_updated_on_utc = @last_updated_on_utc + where workflow_instance_id = @workflow_instance_id + and version_no = @expected_current_version + """, + transaction); + BindState(command, state); + command.Parameters.AddWithValue("expected_current_version", expectedCurrentVersion); + return await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task GetCurrentVersionAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + string workflowInstanceId, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + connection, + $""" + select version_no + from {Qualify(postgres.RuntimeStatesTableName)} + where workflow_instance_id = @workflow_instance_id + """, + transaction); + command.Parameters.AddWithValue("workflow_instance_id", workflowInstanceId); + + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is null or DBNull ? null : Convert.ToInt64(result); + } + + private async Task ResolveActualVersionAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + string workflowInstanceId, + CancellationToken cancellationToken) + { + return await GetCurrentVersionAsync(connection, transaction, workflowInstanceId, cancellationToken) ?? 0; + } + + private void BindState( + NpgsqlCommand command, + WorkflowRuntimeStateRecord state) + { + command.Parameters.AddWithValue("workflow_instance_id", state.WorkflowInstanceId); + command.Parameters.AddWithValue("workflow_name", state.WorkflowName); + command.Parameters.AddWithValue("workflow_version", state.WorkflowVersion); + command.Parameters.AddWithValue("version_no", state.Version); + command.Parameters.AddWithValue("business_reference_key", (object?)state.BusinessReference?.Key ?? DBNull.Value); + command.Parameters.AddWithValue("business_reference_json", (object?)SerializeBusinessReference(state.BusinessReference) ?? DBNull.Value); + command.Parameters.AddWithValue("runtime_provider", state.RuntimeProvider); + command.Parameters.AddWithValue("runtime_instance_id", state.RuntimeInstanceId); + command.Parameters.AddWithValue("runtime_status", state.RuntimeStatus); + command.Parameters.AddWithValue("state_json", state.StateJson); + command.Parameters.AddWithValue("created_on_utc", state.CreatedOnUtc); + command.Parameters.AddWithValue("completed_on_utc", (object?)state.CompletedOnUtc ?? DBNull.Value); + command.Parameters.AddWithValue("stale_after_utc", (object?)state.StaleAfterUtc ?? DBNull.Value); + command.Parameters.AddWithValue("purge_after_utc", (object?)state.PurgeAfterUtc ?? DBNull.Value); + command.Parameters.AddWithValue("last_updated_on_utc", state.LastUpdatedOnUtc); + } + + private static WorkflowRuntimeStateRecord ReadState(NpgsqlDataReader reader) + { + return new WorkflowRuntimeStateRecord + { + WorkflowInstanceId = reader.GetString(0), + WorkflowName = reader.GetString(1), + WorkflowVersion = reader.GetString(2), + Version = reader.GetInt64(3), + BusinessReference = DeserializeBusinessReference( + reader.IsDBNull(4) ? null : reader.GetString(4), + reader.IsDBNull(5) ? null : reader.GetString(5)), + RuntimeProvider = reader.GetString(6), + RuntimeInstanceId = reader.GetString(7), + RuntimeStatus = reader.GetString(8), + StateJson = reader.GetString(9), + CreatedOnUtc = reader.GetDateTime(10), + CompletedOnUtc = reader.IsDBNull(11) ? null : reader.GetDateTime(11), + StaleAfterUtc = reader.IsDBNull(12) ? null : reader.GetDateTime(12), + PurgeAfterUtc = reader.IsDBNull(13) ? null : reader.GetDateTime(13), + LastUpdatedOnUtc = reader.GetDateTime(14), + }; + } + + private string Qualify(string tableName) + { + return database.Qualify(tableName); + } + + private static string? SerializeBusinessReference(WorkflowBusinessReference? businessReference) + { + var normalizedReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + return normalizedReference is null ? null : JsonSerializer.Serialize(normalizedReference); + } + + private static WorkflowBusinessReference? DeserializeBusinessReference(string? key, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference + { + Key = key, + }); + } + + var businessReference = JsonSerializer.Deserialize(value); + if (businessReference is null) + { + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference + { + Key = key, + }); + } + + if (string.IsNullOrWhiteSpace(businessReference.Key) && !string.IsNullOrWhiteSpace(key)) + { + businessReference = businessReference with { Key = key }; + } + + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowScheduleBus.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowScheduleBus.cs new file mode 100644 index 000000000..c3e78465a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowScheduleBus.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +public sealed class PostgresWorkflowScheduleBus(PostgresWorkflowSignalStore signalStore) : IWorkflowSignalScheduler +{ + public Task ScheduleAsync( + WorkflowSignalEnvelope envelope, + DateTime dueAtUtc, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope); + return signalStore.EnqueueLiveAsync( + envelope with { DueAtUtc = dueAtUtc }, + dueAtUtc, + cancellationToken); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSignalBus.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSignalBus.cs new file mode 100644 index 000000000..ed06f84d9 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSignalBus.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +public sealed class PostgresWorkflowSignalBus(PostgresWorkflowSignalStore signalStore) : IWorkflowSignalDriver +{ + public string DriverName => "Postgres.Notify"; + + public WorkflowSignalDriverDispatchMode DispatchMode => WorkflowSignalDriverDispatchMode.NativeTransactional; + + public Task NotifySignalAvailableAsync( + WorkflowSignalWakeNotification notification, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(notification); + return signalStore.NotifySignalAvailableAsync(notification.SignalId, cancellationToken); + } + + public async Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + var deadline = DateTime.UtcNow.AddSeconds(Math.Max(1, signalStore.Options.BlockingWaitSeconds)); + while (true) + { + var lease = await signalStore.TryClaimAsync(consumerName, cancellationToken); + if (lease is not null) + { + return lease; + } + + var remaining = deadline - DateTime.UtcNow; + if (remaining <= TimeSpan.Zero) + { + return null; + } + + await signalStore.WaitForWakeAsync(remaining, cancellationToken); + } + } + +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSignalDeadLetterStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSignalDeadLetterStore.cs new file mode 100644 index 000000000..e8c76daa2 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSignalDeadLetterStore.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +public sealed class PostgresWorkflowSignalDeadLetterStore(PostgresWorkflowSignalStore signalStore) : IWorkflowSignalDeadLetterStore +{ + public Task GetMessagesAsync( + WorkflowSignalDeadLettersGetRequest request, + CancellationToken cancellationToken = default) + { + return signalStore.GetDeadLettersAsync(request, cancellationToken); + } + + public Task ReplayAsync( + WorkflowSignalDeadLetterReplayRequest request, + CancellationToken cancellationToken = default) + { + return signalStore.ReplayAsync(request, cancellationToken); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSignalStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSignalStore.cs new file mode 100644 index 000000000..02b208b2f --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSignalStore.cs @@ -0,0 +1,653 @@ +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 Npgsql; +using NpgsqlTypes; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +public sealed class PostgresWorkflowSignalStore( + PostgresWorkflowDatabase database, + PostgresWorkflowSqlBuilder sqlBuilder) : IWorkflowSignalStore, IWorkflowSignalClaimStore +{ + private PostgresWorkflowBackendOptions Postgres => database.Options; + internal PostgresWorkflowBackendOptions Options => Postgres; + + public Task PublishAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope); + return EnqueueLiveAsync(envelope, envelope.DueAtUtc, cancellationToken); + } + + public Task PublishDeadLetterAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope); + return EnqueueDeadLetterAsync(envelope, cancellationToken); + } + + public async Task TryClaimAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + var claimed = await TryClaimInternalAsync(consumerName, cancellationToken); + return claimed is null + ? null + : new PostgresWorkflowSignalLease(claimed, this); + } + + internal async Task EnqueueLiveAsync( + WorkflowSignalEnvelope envelope, + DateTime? dueAtUtc, + CancellationToken cancellationToken = default) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await InsertAsync( + scope, + Postgres.SignalQueueTableName, + envelope, + dueAtUtc, + deliveryCount: 0, + enqueuedOnUtc: DateTime.UtcNow, + lastError: null, + deadLetteredOnUtc: null, + cancellationToken); + await scope.CommitAsync(cancellationToken); + } + + internal async Task EnqueueDeadLetterAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await InsertAsync( + scope, + Postgres.DeadLetterTableName, + envelope, + envelope.DueAtUtc, + deliveryCount: 0, + enqueuedOnUtc: DateTime.UtcNow, + lastError: null, + deadLetteredOnUtc: DateTime.UtcNow, + cancellationToken); + await scope.CommitAsync(cancellationToken); + } + + internal async Task TryClaimInternalAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + sqlBuilder.BuildReadySignalClaimSql(), + scope.Transaction); + command.Parameters.AddWithValue("consumer_name", consumerName); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + var claimed = new PostgresClaimedSignal + { + SignalId = reader.GetString(0), + Envelope = new WorkflowSignalEnvelope + { + SignalId = reader.GetString(0), + WorkflowInstanceId = reader.GetString(1), + RuntimeProvider = reader.GetString(2), + SignalType = reader.GetString(3), + ExpectedVersion = reader.GetInt64(4), + WaitingToken = reader.IsDBNull(5) ? null : reader.GetString(5), + OccurredAtUtc = reader.GetDateTime(6), + DueAtUtc = reader.IsDBNull(7) ? null : reader.GetDateTime(7), + Payload = PostgresWorkflowJson.DeserializeJsonDictionary(reader.GetString(8)), + }, + DeliveryCount = reader.GetInt32(9), + ClaimedBy = consumerName, + }; + await reader.DisposeAsync(); + await scope.CommitAsync(cancellationToken); + return claimed; + } + + internal async Task WaitForWakeAsync( + TimeSpan? maxWait, + CancellationToken cancellationToken = default) + { + var timeout = TimeSpan.FromSeconds(Math.Max(1, Postgres.BlockingWaitSeconds)); + if (maxWait.HasValue && maxWait.Value < timeout) + { + timeout = maxWait.Value; + } + + var nextDueAtUtc = await GetNextDueAtUtcAsync(cancellationToken); + if (nextDueAtUtc.HasValue) + { + var dueDelay = nextDueAtUtc.Value - DateTime.UtcNow; + if (dueDelay <= TimeSpan.Zero) + { + return; + } + + if (dueDelay < timeout) + { + timeout = dueDelay; + } + } + + if (timeout <= TimeSpan.Zero) + { + return; + } + + await using var connection = await database.OpenConnectionAsync(cancellationToken); + var channel = database.QuoteIdentifier(Postgres.NotifyChannel); + await using (var listenCommand = database.CreateCommand(connection, $"listen {channel};")) + { + await listenCommand.ExecuteNonQueryAsync(cancellationToken); + } + + try + { + await connection.WaitAsync(timeout, cancellationToken); + } + finally + { + await using var unlistenCommand = database.CreateCommand(connection, $"unlisten {channel};"); + await unlistenCommand.ExecuteNonQueryAsync(CancellationToken.None); + } + } + + internal async Task CompleteAsync( + string signalId, + string claimedBy, + CancellationToken cancellationToken = default) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + $""" + delete from {database.Qualify(Postgres.SignalQueueTableName)} + where signal_id = @signal_id + and claimed_by = @claimed_by + """, + scope.Transaction); + command.Parameters.AddWithValue("signal_id", signalId); + command.Parameters.AddWithValue("claimed_by", claimedBy); + await command.ExecuteNonQueryAsync(cancellationToken); + await scope.CommitAsync(cancellationToken); + } + + internal async Task AbandonAsync( + string signalId, + string claimedBy, + CancellationToken cancellationToken = default) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + $""" + update {database.Qualify(Postgres.SignalQueueTableName)} + set claimed_by = null, + claimed_until_utc = null + where signal_id = @signal_id + and claimed_by = @claimed_by + """, + scope.Transaction); + command.Parameters.AddWithValue("signal_id", signalId); + command.Parameters.AddWithValue("claimed_by", claimedBy); + await command.ExecuteNonQueryAsync(cancellationToken); + await scope.CommitAsync(cancellationToken); + } + + internal async Task NotifySignalAvailableAsync( + string signalId, + CancellationToken cancellationToken = default) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await NotifyAsync(scope, signalId, cancellationToken); + await scope.CommitAsync(cancellationToken); + } + + internal async Task DeadLetterAsync( + string signalId, + string claimedBy, + CancellationToken cancellationToken = default) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await using (var insertCommand = database.CreateCommand( + scope.Connection, + $""" + insert into {database.Qualify(Postgres.DeadLetterTableName)} ( + signal_id, + workflow_instance_id, + runtime_provider, + signal_type, + expected_version, + waiting_token, + occurred_at_utc, + due_at_utc, + payload_json, + delivery_count, + enqueued_on_utc, + last_error, + dead_lettered_on_utc + ) + select signal_id, + workflow_instance_id, + runtime_provider, + signal_type, + expected_version, + waiting_token, + occurred_at_utc, + due_at_utc, + payload_json, + delivery_count, + enqueued_on_utc, + last_error, + now() + from {database.Qualify(Postgres.SignalQueueTableName)} + where signal_id = @signal_id + and claimed_by = @claimed_by + on conflict (signal_id) do update + set workflow_instance_id = excluded.workflow_instance_id, + runtime_provider = excluded.runtime_provider, + signal_type = excluded.signal_type, + expected_version = excluded.expected_version, + waiting_token = excluded.waiting_token, + occurred_at_utc = excluded.occurred_at_utc, + due_at_utc = excluded.due_at_utc, + payload_json = excluded.payload_json, + delivery_count = excluded.delivery_count, + enqueued_on_utc = excluded.enqueued_on_utc, + last_error = excluded.last_error, + dead_lettered_on_utc = excluded.dead_lettered_on_utc + """, + scope.Transaction)) + { + insertCommand.Parameters.AddWithValue("signal_id", signalId); + insertCommand.Parameters.AddWithValue("claimed_by", claimedBy); + await insertCommand.ExecuteNonQueryAsync(cancellationToken); + } + + await using (var deleteCommand = database.CreateCommand( + scope.Connection, + $""" + delete from {database.Qualify(Postgres.SignalQueueTableName)} + where signal_id = @signal_id + and claimed_by = @claimed_by + """, + scope.Transaction)) + { + deleteCommand.Parameters.AddWithValue("signal_id", signalId); + deleteCommand.Parameters.AddWithValue("claimed_by", claimedBy); + await deleteCommand.ExecuteNonQueryAsync(cancellationToken); + } + + await scope.CommitAsync(cancellationToken); + } + + internal async Task GetDeadLettersAsync( + WorkflowSignalDeadLettersGetRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + await using var scope = await database.OpenScopeAsync(requireTransaction: false, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + $""" + select signal_id, + workflow_instance_id, + runtime_provider, + signal_type, + expected_version, + waiting_token, + occurred_at_utc, + due_at_utc, + payload_json::text, + delivery_count, + enqueued_on_utc, + dead_lettered_on_utc + from {database.Qualify(Postgres.DeadLetterTableName)} + where (@signal_id is null or signal_id = @signal_id) + and (@workflow_instance_id is null or workflow_instance_id = @workflow_instance_id) + and (@signal_type is null or signal_type = @signal_type) + order by dead_lettered_on_utc desc, signal_id + limit @max_messages + """); + command.Parameters.Add("signal_id", NpgsqlDbType.Text).Value = (object?)request.SignalId ?? DBNull.Value; + command.Parameters.Add("workflow_instance_id", NpgsqlDbType.Text).Value = (object?)request.WorkflowInstanceId ?? DBNull.Value; + command.Parameters.Add("signal_type", NpgsqlDbType.Text).Value = (object?)request.SignalType ?? DBNull.Value; + command.Parameters.AddWithValue("max_messages", Math.Clamp(request.MaxMessages, 1, 200)); + + var results = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var payloadJson = reader.GetString(8); + try + { + results.Add(new WorkflowSignalDeadLetterMessage + { + SignalId = reader.GetString(0), + Correlation = reader.GetString(0), + WorkflowInstanceId = reader.GetString(1), + RuntimeProvider = reader.GetString(2), + SignalType = reader.GetString(3), + ExpectedVersion = reader.GetInt64(4), + WaitingToken = reader.IsDBNull(5) ? null : reader.GetString(5), + OccurredAtUtc = reader.GetDateTime(6), + DueAtUtc = reader.IsDBNull(7) ? null : reader.GetDateTime(7), + Payload = PostgresWorkflowJson.DeserializeJsonDictionary(payloadJson), + DeliveryCount = reader.GetInt32(9), + EnqueuedOnUtc = reader.GetDateTime(10), + IsEnvelopeReadable = true, + RawPayloadBase64 = request.IncludeRawPayload + ? Convert.ToBase64String(global::System.Text.Encoding.UTF8.GetBytes(payloadJson)) + : null, + }); + } + catch (Exception exception) + { + results.Add(new WorkflowSignalDeadLetterMessage + { + SignalId = reader.GetString(0), + Correlation = reader.GetString(0), + WorkflowInstanceId = reader.GetString(1), + RuntimeProvider = reader.GetString(2), + SignalType = reader.GetString(3), + ExpectedVersion = reader.GetInt64(4), + WaitingToken = reader.IsDBNull(5) ? null : reader.GetString(5), + OccurredAtUtc = reader.GetDateTime(6), + DueAtUtc = reader.IsDBNull(7) ? null : reader.GetDateTime(7), + DeliveryCount = reader.GetInt32(9), + EnqueuedOnUtc = reader.GetDateTime(10), + IsEnvelopeReadable = false, + ReadError = exception.Message, + RawPayloadBase64 = request.IncludeRawPayload + ? Convert.ToBase64String(global::System.Text.Encoding.UTF8.GetBytes(payloadJson)) + : null, + }); + } + } + + return new WorkflowSignalDeadLettersGetResponse + { + Messages = results, + }; + } + + internal async Task ReplayAsync( + WorkflowSignalDeadLetterReplayRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + WorkflowSignalEnvelope? envelope = null; + int deliveryCount = 0; + await using (var selectCommand = database.CreateCommand( + scope.Connection, + $""" + select workflow_instance_id, + runtime_provider, + signal_type, + expected_version, + waiting_token, + occurred_at_utc, + due_at_utc, + payload_json::text, + delivery_count + from {database.Qualify(Postgres.DeadLetterTableName)} + where signal_id = @signal_id + for update + """, + scope.Transaction)) + { + selectCommand.Parameters.AddWithValue("signal_id", request.SignalId); + await using var reader = await selectCommand.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + envelope = new WorkflowSignalEnvelope + { + SignalId = request.SignalId, + WorkflowInstanceId = reader.GetString(0), + RuntimeProvider = reader.GetString(1), + SignalType = reader.GetString(2), + ExpectedVersion = reader.GetInt64(3), + WaitingToken = reader.IsDBNull(4) ? null : reader.GetString(4), + OccurredAtUtc = reader.GetDateTime(5), + DueAtUtc = reader.IsDBNull(6) ? null : reader.GetDateTime(6), + Payload = PostgresWorkflowJson.DeserializeJsonDictionary(reader.GetString(7)), + }; + deliveryCount = reader.GetInt32(8); + } + } + + if (envelope is null) + { + return new WorkflowSignalDeadLetterReplayResponse + { + SignalId = request.SignalId, + Replayed = false, + }; + } + + await InsertAsync( + scope, + Postgres.SignalQueueTableName, + envelope, + envelope.DueAtUtc, + deliveryCount: 0, + enqueuedOnUtc: DateTime.UtcNow, + lastError: null, + deadLetteredOnUtc: null, + cancellationToken); + await using (var deleteCommand = database.CreateCommand( + scope.Connection, + $""" + delete from {database.Qualify(Postgres.DeadLetterTableName)} + where signal_id = @signal_id + """, + scope.Transaction)) + { + deleteCommand.Parameters.AddWithValue("signal_id", request.SignalId); + await deleteCommand.ExecuteNonQueryAsync(cancellationToken); + } + + await NotifyAsync(scope, request.SignalId, cancellationToken); + await scope.CommitAsync(cancellationToken); + + return new WorkflowSignalDeadLetterReplayResponse + { + SignalId = request.SignalId, + Replayed = true, + WorkflowInstanceId = envelope.WorkflowInstanceId, + SignalType = envelope.SignalType, + WasEnvelopeReadable = true, + }; + } + + private async Task GetNextDueAtUtcAsync(CancellationToken cancellationToken) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: false, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + $""" + select min(due_at_utc) + from {database.Qualify(Postgres.SignalQueueTableName)} + where due_at_utc is not null + and (claimed_until_utc is null or claimed_until_utc <= now()) + """); + + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is null or DBNull + ? null + : Convert.ToDateTime(result); + } + + private async Task InsertAsync( + PostgresWorkflowOperationScope scope, + string tableName, + WorkflowSignalEnvelope envelope, + DateTime? dueAtUtc, + int deliveryCount, + DateTime enqueuedOnUtc, + string? lastError, + DateTime? deadLetteredOnUtc, + CancellationToken cancellationToken) + { + var hasDeadLetteredOnUtc = deadLetteredOnUtc.HasValue; + var sql = hasDeadLetteredOnUtc + ? $""" + insert into {database.Qualify(tableName)} ( + signal_id, + workflow_instance_id, + runtime_provider, + signal_type, + expected_version, + waiting_token, + occurred_at_utc, + due_at_utc, + payload_json, + delivery_count, + enqueued_on_utc, + last_error, + dead_lettered_on_utc + ) + values ( + @signal_id, + @workflow_instance_id, + @runtime_provider, + @signal_type, + @expected_version, + @waiting_token, + @occurred_at_utc, + @due_at_utc, + @payload_json, + @delivery_count, + @enqueued_on_utc, + @last_error, + @dead_lettered_on_utc + ) + """ + : $""" + insert into {database.Qualify(tableName)} ( + signal_id, + workflow_instance_id, + runtime_provider, + signal_type, + expected_version, + waiting_token, + occurred_at_utc, + due_at_utc, + payload_json, + delivery_count, + enqueued_on_utc, + last_error + ) + values ( + @signal_id, + @workflow_instance_id, + @runtime_provider, + @signal_type, + @expected_version, + @waiting_token, + @occurred_at_utc, + @due_at_utc, + @payload_json, + @delivery_count, + @enqueued_on_utc, + @last_error + ) + """; + + await using var command = database.CreateCommand(scope.Connection, sql, scope.Transaction); + command.Parameters.AddWithValue("signal_id", envelope.SignalId); + command.Parameters.AddWithValue("workflow_instance_id", envelope.WorkflowInstanceId); + command.Parameters.AddWithValue("runtime_provider", envelope.RuntimeProvider); + command.Parameters.AddWithValue("signal_type", envelope.SignalType); + command.Parameters.AddWithValue("expected_version", envelope.ExpectedVersion); + command.Parameters.AddWithValue("waiting_token", (object?)envelope.WaitingToken ?? DBNull.Value); + command.Parameters.AddWithValue("occurred_at_utc", envelope.OccurredAtUtc); + command.Parameters.AddWithValue("due_at_utc", (object?)dueAtUtc ?? DBNull.Value); + command.Parameters.Add("payload_json", NpgsqlDbType.Jsonb).Value = PostgresWorkflowJson.Serialize(envelope.Payload); + command.Parameters.AddWithValue("delivery_count", deliveryCount); + command.Parameters.AddWithValue("enqueued_on_utc", enqueuedOnUtc); + command.Parameters.AddWithValue("last_error", (object?)lastError ?? DBNull.Value); + if (hasDeadLetteredOnUtc) + { + command.Parameters.AddWithValue("dead_lettered_on_utc", deadLetteredOnUtc!.Value); + } + + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task NotifyAsync( + PostgresWorkflowOperationScope scope, + string signalId, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + sqlBuilder.BuildNotifySql(), + scope.Transaction); + command.Parameters.AddWithValue("signal_id", signalId); + await command.ExecuteNonQueryAsync(cancellationToken); + } +} + +internal sealed record PostgresClaimedSignal +{ + public required string SignalId { get; init; } + public required WorkflowSignalEnvelope Envelope { get; init; } + public required string ClaimedBy { get; init; } + public int DeliveryCount { get; init; } +} + +internal sealed class PostgresWorkflowSignalLease( + PostgresClaimedSignal claimedSignal, + PostgresWorkflowSignalStore signalStore) : IWorkflowSignalLease +{ + public WorkflowSignalEnvelope Envelope { get; } = claimedSignal.Envelope; + public int DeliveryCount => claimedSignal.DeliveryCount; + + public Task CompleteAsync(CancellationToken cancellationToken = default) + { + return signalStore.CompleteAsync(claimedSignal.SignalId, claimedSignal.ClaimedBy, cancellationToken); + } + + public Task AbandonAsync(CancellationToken cancellationToken = default) + { + return AbandonAndNotifyAsync(cancellationToken); + } + + public Task DeadLetterAsync(CancellationToken cancellationToken = default) + { + return signalStore.DeadLetterAsync(claimedSignal.SignalId, claimedSignal.ClaimedBy, cancellationToken); + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + private async Task AbandonAndNotifyAsync(CancellationToken cancellationToken) + { + await signalStore.AbandonAsync(claimedSignal.SignalId, claimedSignal.ClaimedBy, cancellationToken); + await signalStore.NotifySignalAvailableAsync(claimedSignal.SignalId, cancellationToken); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSqlBuilder.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSqlBuilder.cs new file mode 100644 index 000000000..b94776da1 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowSqlBuilder.cs @@ -0,0 +1,242 @@ +using Microsoft.Extensions.Options; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +public sealed class PostgresWorkflowSqlBuilder(IOptions options) +{ + private readonly PostgresWorkflowBackendOptions postgres = options.Value; + + public string BuildStorageBootstrapSql() + { + return +$""" +create schema if not exists "{postgres.SchemaName}"; + +create table if not exists "{postgres.SchemaName}"."{postgres.InstancesTableName}" ( + workflow_instance_id text primary key, + workflow_name text not null, + workflow_version text not null, + business_reference_key text null, + business_reference_json jsonb null, + status text not null, + state_json jsonb not null, + created_on_utc timestamp with time zone not null, + completed_on_utc timestamp with time zone null, + stale_after_utc timestamp with time zone null, + purge_after_utc timestamp with time zone null +); + +create index if not exists "{postgres.InstancesTableName}_wf_ix" + on "{postgres.SchemaName}"."{postgres.InstancesTableName}" (workflow_name, workflow_version); +create index if not exists "{postgres.InstancesTableName}_business_ix" + on "{postgres.SchemaName}"."{postgres.InstancesTableName}" (business_reference_key); +create index if not exists "{postgres.InstancesTableName}_status_ix" + on "{postgres.SchemaName}"."{postgres.InstancesTableName}" (status); +create index if not exists "{postgres.InstancesTableName}_purge_ix" + on "{postgres.SchemaName}"."{postgres.InstancesTableName}" (purge_after_utc); +create index if not exists "{postgres.InstancesTableName}_stale_ix" + on "{postgres.SchemaName}"."{postgres.InstancesTableName}" (stale_after_utc); + +create table if not exists "{postgres.SchemaName}"."{postgres.TasksTableName}" ( + workflow_task_id text primary key, + workflow_instance_id text not null references "{postgres.SchemaName}"."{postgres.InstancesTableName}" (workflow_instance_id) on delete cascade, + workflow_name text not null, + workflow_version text not null, + task_name text not null, + task_type text not null, + route text not null, + business_reference_key text null, + business_reference_json jsonb null, + assignee text null, + status text not null, + workflow_roles_json jsonb not null, + task_roles_json jsonb not null, + runtime_roles_json jsonb not null, + effective_roles_json jsonb not null, + payload_json jsonb not null, + created_on_utc timestamp with time zone not null, + completed_on_utc timestamp with time zone null, + stale_after_utc timestamp with time zone null, + purge_after_utc timestamp with time zone null +); + +create index if not exists "{postgres.TasksTableName}_instance_status_ix" + on "{postgres.SchemaName}"."{postgres.TasksTableName}" (workflow_instance_id, status); +create index if not exists "{postgres.TasksTableName}_wf_ix" + on "{postgres.SchemaName}"."{postgres.TasksTableName}" (workflow_name, workflow_version); +create index if not exists "{postgres.TasksTableName}_business_ix" + on "{postgres.SchemaName}"."{postgres.TasksTableName}" (business_reference_key); +create index if not exists "{postgres.TasksTableName}_assignee_status_ix" + on "{postgres.SchemaName}"."{postgres.TasksTableName}" (assignee, status); +create index if not exists "{postgres.TasksTableName}_purge_ix" + on "{postgres.SchemaName}"."{postgres.TasksTableName}" (purge_after_utc); +create index if not exists "{postgres.TasksTableName}_stale_ix" + on "{postgres.SchemaName}"."{postgres.TasksTableName}" (stale_after_utc); + +create table if not exists "{postgres.SchemaName}"."{postgres.TaskEventsTableName}" ( + task_event_id bigint generated always as identity primary key, + workflow_task_id text not null references "{postgres.SchemaName}"."{postgres.TasksTableName}" (workflow_task_id) on delete cascade, + event_type text not null, + actor_id text null, + payload_json jsonb not null, + created_on_utc timestamp with time zone not null +); + +create index if not exists "{postgres.TaskEventsTableName}_task_time_ix" + on "{postgres.SchemaName}"."{postgres.TaskEventsTableName}" (workflow_task_id, created_on_utc); + +create table if not exists "{postgres.SchemaName}"."{postgres.RuntimeStatesTableName}" ( + workflow_instance_id text primary key, + workflow_name text not null, + workflow_version text not null, + version_no bigint not null, + business_reference_key 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 timestamp with time zone not null, + completed_on_utc timestamp with time zone null, + stale_after_utc timestamp with time zone null, + purge_after_utc timestamp with time zone null, + last_updated_on_utc timestamp with time zone not null +); + +create table if not exists "{postgres.SchemaName}"."{postgres.HostedJobLocksTableName}" ( + lock_name text primary key, + lock_owner text not null, + acquired_on_utc timestamp with time zone not null, + expires_on_utc timestamp with time zone not null +); + +create table if not exists "{postgres.SchemaName}"."{postgres.SignalQueueTableName}" ( + signal_id text primary key, + workflow_instance_id text not null, + runtime_provider text not null, + signal_type text not null, + expected_version bigint not null, + waiting_token text null, + occurred_at_utc timestamp with time zone not null, + due_at_utc timestamp with time zone null, + payload_json jsonb not null, + delivery_count integer not null default 0, + enqueued_on_utc timestamp with time zone not null default now(), + claimed_by text null, + claimed_until_utc timestamp with time zone null, + last_error text null +); + +create index if not exists "{postgres.SignalQueueTableName}_ready_ix" + on "{postgres.SchemaName}"."{postgres.SignalQueueTableName}" (due_at_utc, claimed_until_utc); +create index if not exists "{postgres.SignalQueueTableName}_instance_ix" + on "{postgres.SchemaName}"."{postgres.SignalQueueTableName}" (workflow_instance_id); + +create table if not exists "{postgres.SchemaName}"."{postgres.DeadLetterTableName}" ( + signal_id text primary key, + workflow_instance_id text not null, + runtime_provider text not null, + signal_type text not null, + expected_version bigint not null, + waiting_token text null, + occurred_at_utc timestamp with time zone not null, + due_at_utc timestamp with time zone null, + payload_json jsonb not null, + delivery_count integer not null default 0, + enqueued_on_utc timestamp with time zone not null, + last_error text null, + dead_lettered_on_utc timestamp with time zone not null default now() +); + +create index if not exists "{postgres.DeadLetterTableName}_instance_ix" + on "{postgres.SchemaName}"."{postgres.DeadLetterTableName}" (workflow_instance_id); +create index if not exists "{postgres.DeadLetterTableName}_type_ix" + on "{postgres.SchemaName}"."{postgres.DeadLetterTableName}" (signal_type); + +create table if not exists "{postgres.SchemaName}"."{postgres.WakeOutboxTableName}" ( + outbox_id text primary key, + signal_id text not null, + workflow_instance_id text not null, + runtime_provider text not null, + signal_type text not null, + due_at_utc timestamp with time zone null, + created_on_utc timestamp with time zone not null default now(), + claimed_by text null, + claimed_until_utc timestamp with time zone null +); + +create index if not exists "{postgres.WakeOutboxTableName}_claim_ix" + on "{postgres.SchemaName}"."{postgres.WakeOutboxTableName}" (claimed_until_utc, created_on_utc); +"""; + } + + public string BuildReadySignalClaimSql() + { + return +$""" +with ready as ( + select signal_id + from "{postgres.SchemaName}"."{postgres.SignalQueueTableName}" + where (due_at_utc is null or due_at_utc <= now()) + and (claimed_until_utc is null or claimed_until_utc <= now()) + order by coalesce(due_at_utc, enqueued_on_utc), signal_id + for update skip locked + limit 1 +) +update "{postgres.SchemaName}"."{postgres.SignalQueueTableName}" queue +set claimed_by = @consumer_name, + claimed_until_utc = now() + make_interval(secs => {postgres.ClaimTimeoutSeconds}), + delivery_count = queue.delivery_count + 1 +from ready +where queue.signal_id = ready.signal_id +returning queue.signal_id, + queue.workflow_instance_id, + queue.runtime_provider, + queue.signal_type, + queue.expected_version, + queue.waiting_token, + queue.occurred_at_utc, + queue.due_at_utc, + queue.payload_json::text, + queue.delivery_count; +"""; + } + + public string BuildNotifySql() + { + return $"select pg_notify('{postgres.NotifyChannel.Replace("'", "''")}', @signal_id);"; + } + + public string BuildWakeOutboxClaimSql() + { + return +$""" +with ready as ( + select outbox_id + from "{postgres.SchemaName}"."{postgres.WakeOutboxTableName}" + where claimed_until_utc is null or claimed_until_utc <= now() + order by created_on_utc, outbox_id + for update skip locked + limit 1 +) +update "{postgres.SchemaName}"."{postgres.WakeOutboxTableName}" outbox +set claimed_by = @consumer_name, + claimed_until_utc = now() + make_interval(secs => {postgres.ClaimTimeoutSeconds}) +from ready +where outbox.outbox_id = ready.outbox_id +returning outbox.outbox_id, + outbox.signal_id, + outbox.workflow_instance_id, + outbox.runtime_provider, + outbox.signal_type, + outbox.due_at_utc, + outbox.claimed_by; +"""; + } + + public string BuildWakeOutboxNotifySql() + { + var channel = postgres.WakeOutboxNotifyChannel.Replace("'", "''"); + return $"select pg_notify('{channel}', @signal_id);"; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowWakeOutbox.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowWakeOutbox.cs new file mode 100644 index 000000000..cf4c79b10 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowWakeOutbox.cs @@ -0,0 +1,237 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using Npgsql; + +namespace StellaOps.Workflow.DataStore.PostgreSQL; + +public sealed class PostgresWorkflowWakeOutbox( + PostgresWorkflowDatabase database, + PostgresWorkflowSqlBuilder sqlBuilder) : IWorkflowWakeOutbox, IWorkflowWakeOutboxReceiver +{ + private PostgresWorkflowBackendOptions Postgres => database.Options; + + public async Task EnqueueAsync( + WorkflowSignalWakeNotification notification, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(notification); + + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await InsertAsync(scope, notification, cancellationToken); + await NotifyAsync(scope, notification.SignalId, cancellationToken); + await scope.CommitAsync(cancellationToken); + } + + public async Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + var deadline = DateTime.UtcNow.AddSeconds(Math.Max(1, Postgres.BlockingWaitSeconds)); + while (true) + { + var claimed = await TryClaimInternalAsync(consumerName, cancellationToken); + if (claimed is not null) + { + return new Lease(claimed, this); + } + + var remaining = deadline - DateTime.UtcNow; + if (remaining <= TimeSpan.Zero) + { + return null; + } + + await WaitForWakeAsync(remaining, cancellationToken); + } + } + + internal async Task CompleteAsync( + string outboxId, + string claimedBy, + CancellationToken cancellationToken = default) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + $""" + delete from {database.Qualify(Postgres.WakeOutboxTableName)} + where outbox_id = @outbox_id + and claimed_by = @claimed_by + """, + scope.Transaction); + command.Parameters.AddWithValue("outbox_id", outboxId); + command.Parameters.AddWithValue("claimed_by", claimedBy); + await command.ExecuteNonQueryAsync(cancellationToken); + await scope.CommitAsync(cancellationToken); + } + + internal async Task AbandonAsync( + string outboxId, + string claimedBy, + CancellationToken cancellationToken = default) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + $""" + update {database.Qualify(Postgres.WakeOutboxTableName)} + set claimed_by = null, + claimed_until_utc = null + where outbox_id = @outbox_id + and claimed_by = @claimed_by + """, + scope.Transaction); + command.Parameters.AddWithValue("outbox_id", outboxId); + command.Parameters.AddWithValue("claimed_by", claimedBy); + await command.ExecuteNonQueryAsync(cancellationToken); + await NotifyAsync(scope, outboxId, cancellationToken); + await scope.CommitAsync(cancellationToken); + } + + private async Task TryClaimInternalAsync( + string consumerName, + CancellationToken cancellationToken) + { + await using var scope = await database.OpenScopeAsync(requireTransaction: true, cancellationToken); + await using var command = database.CreateCommand( + scope.Connection, + sqlBuilder.BuildWakeOutboxClaimSql(), + scope.Transaction); + command.Parameters.AddWithValue("consumer_name", consumerName); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + var claimed = new ClaimedWakeNotification + { + OutboxId = reader.GetString(0), + ConsumerName = reader.GetString(6), + Notification = new WorkflowSignalWakeNotification + { + SignalId = reader.GetString(1), + WorkflowInstanceId = reader.GetString(2), + RuntimeProvider = reader.GetString(3), + SignalType = reader.GetString(4), + DueAtUtc = reader.IsDBNull(5) ? null : reader.GetDateTime(5), + }, + }; + + await reader.DisposeAsync(); + await scope.CommitAsync(cancellationToken); + return claimed; + } + + private async Task WaitForWakeAsync( + TimeSpan maxWait, + CancellationToken cancellationToken) + { + if (maxWait <= TimeSpan.Zero) + { + return; + } + + await using var connection = await database.OpenConnectionAsync(cancellationToken); + var channel = database.QuoteIdentifier(Postgres.WakeOutboxNotifyChannel); + await using (var listenCommand = database.CreateCommand(connection, $"listen {channel};")) + { + await listenCommand.ExecuteNonQueryAsync(cancellationToken); + } + + try + { + await connection.WaitAsync(maxWait, cancellationToken); + } + finally + { + await using var unlistenCommand = database.CreateCommand(connection, $"unlisten {channel};"); + await unlistenCommand.ExecuteNonQueryAsync(CancellationToken.None); + } + } + + private async Task InsertAsync( + PostgresWorkflowOperationScope scope, + WorkflowSignalWakeNotification notification, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + $""" + insert into {database.Qualify(Postgres.WakeOutboxTableName)} ( + outbox_id, + signal_id, + workflow_instance_id, + runtime_provider, + signal_type, + due_at_utc, + created_on_utc + ) + values ( + @outbox_id, + @signal_id, + @workflow_instance_id, + @runtime_provider, + @signal_type, + @due_at_utc, + now() + ) + """, + scope.Transaction); + command.Parameters.AddWithValue("outbox_id", Guid.NewGuid().ToString("N")); + command.Parameters.AddWithValue("signal_id", notification.SignalId); + command.Parameters.AddWithValue("workflow_instance_id", notification.WorkflowInstanceId); + command.Parameters.AddWithValue("runtime_provider", notification.RuntimeProvider); + command.Parameters.AddWithValue("signal_type", notification.SignalType); + command.Parameters.AddWithValue("due_at_utc", (object?)notification.DueAtUtc ?? DBNull.Value); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task NotifyAsync( + PostgresWorkflowOperationScope scope, + string signalId, + CancellationToken cancellationToken) + { + await using var command = database.CreateCommand( + scope.Connection, + sqlBuilder.BuildWakeOutboxNotifySql(), + scope.Transaction); + command.Parameters.AddWithValue("signal_id", signalId); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private sealed class Lease( + ClaimedWakeNotification claimed, + PostgresWorkflowWakeOutbox outbox) : IWorkflowWakeOutboxLease + { + public WorkflowSignalWakeNotification Notification => claimed.Notification; + public string ConsumerName => claimed.ConsumerName; + + public Task CompleteAsync(CancellationToken cancellationToken = default) + { + return outbox.CompleteAsync(claimed.OutboxId, claimed.ConsumerName, cancellationToken); + } + + public Task AbandonAsync(CancellationToken cancellationToken = default) + { + return outbox.AbandonAsync(claimed.OutboxId, claimed.ConsumerName, cancellationToken); + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + } + + private sealed class ClaimedWakeNotification + { + public required string OutboxId { get; init; } + public required string ConsumerName { get; init; } + public required WorkflowSignalWakeNotification Notification { get; init; } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/StellaOps.Workflow.DataStore.PostgreSQL.csproj b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/StellaOps.Workflow.DataStore.PostgreSQL.csproj new file mode 100644 index 000000000..40b745677 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/StellaOps.Workflow.DataStore.PostgreSQL.csproj @@ -0,0 +1,16 @@ + + + net10.0 + false + enable + enable + + + + + + + + + + diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Authorization/WorkflowTaskAuthorizationService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Authorization/WorkflowTaskAuthorizationService.cs new file mode 100644 index 000000000..ed5e85d11 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Authorization/WorkflowTaskAuthorizationService.cs @@ -0,0 +1,9 @@ +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Authorization; + +public sealed class WorkflowTaskAuthorizationService(IWorkflowAssignmentPermissionEvaluator permissionEvaluator) +{ + public WorkflowAssignmentPermissionDecision Evaluate(WorkflowAssignmentPermissionContext context) + => permissionEvaluator.Evaluate(context); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Constants/ConstantsClass.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Constants/ConstantsClass.cs new file mode 100644 index 000000000..f7d15e70a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Constants/ConstantsClass.cs @@ -0,0 +1,7 @@ +namespace StellaOps.Workflow.Engine.Constants; + +public static class ConstantsClass +{ + public const string ServiceName = "StellaOps.Workflow.Engine"; + public const string ConnectionNameDefault = "DefaultConnection"; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Constants/MessageKeys.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Constants/MessageKeys.cs new file mode 100644 index 000000000..915b1a5e3 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Constants/MessageKeys.cs @@ -0,0 +1,14 @@ +namespace StellaOps.Workflow.Engine.Constants; + +public static class MessageKeys +{ + public static string WorkflowDefinitionNotFound => nameof(WorkflowDefinitionNotFound); + public static string WorkflowInstanceNotFound => nameof(WorkflowInstanceNotFound); + public static string WorkflowTaskNotFound => nameof(WorkflowTaskNotFound); + public static string WorkflowTaskActionDenied => nameof(WorkflowTaskActionDenied); + public static string WorkflowTaskAlreadyCompleted => nameof(WorkflowTaskAlreadyCompleted); + public static string WorkflowBusinessIdMissing => nameof(WorkflowBusinessIdMissing); + public static string WorkflowPayloadFieldMissing => nameof(WorkflowPayloadFieldMissing); + public static string WorkflowTransportFailed => nameof(WorkflowTransportFailed); + public static string WorkflowRuntimeFailed => nameof(WorkflowRuntimeFailed); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Constants/WorkflowStatuses.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Constants/WorkflowStatuses.cs new file mode 100644 index 000000000..b28da8900 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Constants/WorkflowStatuses.cs @@ -0,0 +1,25 @@ +namespace StellaOps.Workflow.Engine.Constants; + +public static class WorkflowInstanceStatuses +{ + public const string Open = "Open"; + public const string Stale = "Stale"; + public const string Completed = "Completed"; +} + +public static class WorkflowTaskStatuses +{ + public const string Open = "Open"; + public const string Assigned = "Assigned"; + public const string Stale = "Stale"; + public const string Completed = "Completed"; +} + +public static class WorkflowTaskEventTypes +{ + public const string Created = "Created"; + public const string Assigned = "Assigned"; + public const string Reassigned = "Reassigned"; + public const string Released = "Released"; + public const string Completed = "Completed"; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Definitions/WorkflowRuntimeDefinitionStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Definitions/WorkflowRuntimeDefinitionStore.cs new file mode 100644 index 000000000..a3a59b8ab --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Definitions/WorkflowRuntimeDefinitionStore.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Threading; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Definitions; + +public sealed class WorkflowRuntimeDefinitionStore : IWorkflowRuntimeDefinitionStore +{ + private static readonly MethodInfo BuildDeclarativeDefinitionMethod = typeof(WorkflowRuntimeDefinitionStore) + .GetMethod(nameof(BuildDeclarativeDefinitionCore), BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Workflow runtime definition builder method was not found."); + + private readonly WorkflowRegistration[] registrations; + private readonly IReadOnlyCollection installedModules; + private readonly IWorkflowFunctionCatalog workflowFunctionCatalog; + private readonly ConcurrentDictionary> definitionCache = + new(StringComparer.OrdinalIgnoreCase); + + public WorkflowRuntimeDefinitionStore( + IWorkflowRegistrationCatalog workflowRegistrationCatalog, + IWorkflowModuleCatalog workflowModuleCatalog, + IWorkflowFunctionCatalog workflowFunctionCatalog) + { + this.workflowFunctionCatalog = workflowFunctionCatalog ?? throw new ArgumentNullException(nameof(workflowFunctionCatalog)); + this.installedModules = workflowModuleCatalog.GetInstalledModules(); + registrations = workflowRegistrationCatalog + .GetRegistrations() + .OrderBy(x => x.Definition.WorkflowName, StringComparer.OrdinalIgnoreCase) + .ThenByDescending(x => x.Definition.WorkflowVersion, WorkflowVersioning.SemanticComparer) + .ToArray(); + } + + public IReadOnlyCollection GetDefinitions() + { + return registrations + .Select(GetDefinition) + .ToArray(); + } + + public WorkflowRuntimeDefinition? GetDefinition(string workflowName, string? workflowVersion = null) + { + var query = registrations.Where(x => + string.Equals(x.Definition.WorkflowName, workflowName, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(workflowVersion)) + { + query = query.Where(x => + string.Equals(x.Definition.WorkflowVersion, workflowVersion, StringComparison.OrdinalIgnoreCase)); + } + + var registration = query + .OrderByDescending(x => x.Definition.WorkflowVersion, WorkflowVersioning.SemanticComparer) + .FirstOrDefault(); + + return registration is null + ? null + : GetDefinition(registration); + } + + public WorkflowRuntimeDefinition GetRequiredDefinition(string workflowName, string? workflowVersion = null) + { + return GetDefinition(workflowName, workflowVersion) + ?? throw new InvalidOperationException( + $"Workflow runtime definition '{workflowName}' version '{workflowVersion ?? ""}' is not available."); + } + + private WorkflowRuntimeDefinition GetDefinition(WorkflowRegistration registration) + { + var cacheKey = $"{registration.Definition.WorkflowName}\u0000{registration.Definition.WorkflowVersion}"; + var lazyDefinition = definitionCache.GetOrAdd( + cacheKey, + _ => new Lazy( + () => BuildDefinition(registration, installedModules, workflowFunctionCatalog), + LazyThreadSafetyMode.ExecutionAndPublication)); + + return lazyDefinition.Value; + } + + private static WorkflowRuntimeDefinition BuildDefinition( + WorkflowRegistration registration, + IReadOnlyCollection installedModules, + IWorkflowFunctionCatalog workflowFunctionCatalog) + { + if (IsDeclarativeWorkflow(registration.WorkflowType)) + { + try + { + return (WorkflowRuntimeDefinition)BuildDeclarativeDefinitionMethod + .MakeGenericMethod(registration.StartRequestType) + .Invoke(null, [registration, installedModules, workflowFunctionCatalog])!; + } + catch (TargetInvocationException exception) when (exception.InnerException is not null) + { + ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); + throw; + } + } + + return new WorkflowRuntimeDefinition + { + Registration = registration, + Descriptor = CloneDescriptor(registration.Definition), + ExecutionKind = registration.HandlerType is null + ? WorkflowRuntimeExecutionKind.DefinitionOnly + : WorkflowRuntimeExecutionKind.CustomHandler, + }; + } + + private static WorkflowRuntimeDefinition BuildDeclarativeDefinitionCore( + WorkflowRegistration registration, + IReadOnlyCollection installedModules, + IWorkflowFunctionCatalog workflowFunctionCatalog) + where TStartRequest : class + { + if (Activator.CreateInstance(registration.WorkflowType) is not IDeclarativeWorkflow workflow) + { + throw new InvalidOperationException( + $"Workflow '{registration.WorkflowType.FullName}' could not be created as a declarative workflow."); + } + + var compilation = WorkflowCanonicalDefinitionCompiler.Compile(workflow, workflowFunctionCatalog); + if (!compilation.Succeeded || compilation.Definition is null) + { + throw BuildCompilationException( + registration, + compilation.Diagnostics.Select(x => $"{x.Code} {x.Path}: {x.Message}")); + } + + var importValidation = WorkflowCanonicalImportValidator.Validate( + WorkflowCanonicalJsonSerializer.Serialize(compilation.Definition), + installedModules, + workflowFunctionCatalog); + if (!importValidation.Succeeded || importValidation.Definition is null) + { + var issues = importValidation.SchemaErrors + .Concat(importValidation.SemanticErrors) + .Concat(importValidation.ModuleErrors) + .Select(x => $"{x.Code} {x.Path}: {x.Message}"); + throw BuildCompilationException(registration, issues); + } + + return new WorkflowRuntimeDefinition + { + Registration = registration, + Descriptor = CloneDescriptor(registration.Definition), + ExecutionKind = WorkflowRuntimeExecutionKind.Declarative, + CanonicalDefinition = importValidation.Definition, + }; + } + + private static InvalidOperationException BuildCompilationException( + WorkflowRegistration registration, + IEnumerable diagnostics) + { + var details = string.Join(Environment.NewLine, diagnostics); + return new InvalidOperationException( + $"Workflow runtime definition '{registration.Definition.WorkflowName}' version '{registration.Definition.WorkflowVersion}' is invalid.{Environment.NewLine}{details}"); + } + + private static bool IsDeclarativeWorkflow(Type workflowType) + { + return workflowType.GetInterfaces().Any(x => + x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IDeclarativeWorkflow<>)); + } + + private static WorkflowDefinitionDescriptor CloneDescriptor(WorkflowDefinitionDescriptor descriptor) + { + return descriptor with + { + WorkflowRoles = descriptor.WorkflowRoles.ToArray(), + Tasks = descriptor.Tasks.ToArray(), + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Definitions/WorkflowRuntimeExecutionHandlerFactory.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Definitions/WorkflowRuntimeExecutionHandlerFactory.cs new file mode 100644 index 000000000..6ebe4b943 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Definitions/WorkflowRuntimeExecutionHandlerFactory.cs @@ -0,0 +1,42 @@ +using System; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Engine.Execution; + +using Microsoft.Extensions.DependencyInjection; + +namespace StellaOps.Workflow.Engine.Definitions; + +public sealed class WorkflowRuntimeExecutionHandlerFactory( + IServiceProvider serviceProvider) : IWorkflowRuntimeExecutionHandlerFactory +{ + public IWorkflowExecutionHandler? TryCreateHandler(WorkflowRuntimeDefinition definition) + { + ArgumentNullException.ThrowIfNull(definition); + + return definition.ExecutionKind switch + { + WorkflowRuntimeExecutionKind.DefinitionOnly => null, + WorkflowRuntimeExecutionKind.CustomHandler => ResolveCustomHandler(definition), + WorkflowRuntimeExecutionKind.Declarative => CreateDeclarativeHandler(definition), + _ => throw new NotSupportedException( + $"Workflow runtime execution kind '{definition.ExecutionKind}' is not supported."), + }; + } + + private IWorkflowExecutionHandler? ResolveCustomHandler(WorkflowRuntimeDefinition definition) + { + var handlerType = definition.Registration.HandlerType; + if (handlerType is null) + { + return null; + } + + return serviceProvider.GetRequiredService(handlerType) as IWorkflowExecutionHandler; + } + + private IWorkflowExecutionHandler? CreateDeclarativeHandler(WorkflowRuntimeDefinition definition) + { + return ActivatorUtilities.CreateInstance(serviceProvider, definition); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/CanonicalWorkflowExecutionHandler.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/CanonicalWorkflowExecutionHandler.cs new file mode 100644 index 000000000..2d329dc47 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/CanonicalWorkflowExecutionHandler.cs @@ -0,0 +1,2504 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Engine.Exceptions; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Engine.Constants; +using StellaOps.Workflow.Contracts; +using StellaOps.Workflow.Engine.Services; + +namespace StellaOps.Workflow.Engine.Execution; + +public sealed class CanonicalWorkflowExecutionHandler( + WorkflowRuntimeDefinition runtimeDefinition, + IWorkflowMicroserviceTransport microserviceTransport, + IWorkflowRabbitTransport rabbitTransport, + IWorkflowLegacyRabbitTransport legacyRabbitTransport, + IWorkflowGraphqlTransport graphqlTransport, + IWorkflowHttpTransport httpTransport, + IWorkflowFunctionRuntime workflowFunctionRuntime, + IWorkflowRegistrationCatalog workflowRegistrationCatalog, + IWorkflowRuntimeDefinitionStore workflowRuntimeDefinitionStore, + IWorkflowRuntimeExecutionHandlerFactory workflowRuntimeExecutionHandlerFactory) + : IWorkflowExecutionHandler, IWorkflowSignalResumableExecutionHandler, IDeclarativeWorkflowResumeHandler +{ + private const string WorkflowCompletedStatus = "Completed"; + private const string WorkflowOpenStatus = "Open"; + private const string WorkflowNamePayloadKey = "workflowName"; + private const string WorkflowVersionPayloadKey = "workflowVersion"; + private const string ResumePointerResumeStateKey = "resumePointer"; + private const string ResultKeyResumeStateKey = "resultKey"; + private const string SubWorkflowFramesStateKey = "__serdica.subWorkflowFrames"; + private const string ProjectionWorkflowInstanceIdStateKey = "__serdica.projectionWorkflowInstanceId"; + + private readonly WorkflowCanonicalDefinition definition = runtimeDefinition.CanonicalDefinition + ?? throw new InvalidOperationException( + $"Workflow '{runtimeDefinition.Descriptor.WorkflowName}' does not have a canonical definition."); + + public Task StartAsync( + WorkflowStartExecutionContext context, + CancellationToken cancellationToken = default) + { + var workflowState = WorkflowCanonicalExpressionRuntime + .Evaluate( + definition.Start.InitializeStateExpression, + WorkflowCanonicalEvaluationContext.ForStartRequest(context.StartRequest, workflowFunctionRuntime)) + .AsWorkflowJsonDictionary(); + var executionContext = new WorkflowCanonicalExecutionContext( + definition.WorkflowName, + definition.WorkflowVersion, + runtimeDefinition.Descriptor.WorkflowRoles, + context.StartRequest, + workflowState, + CloneJsonDictionary(context.Payload), + context.BusinessReference, + workflowFunctionRuntime); + + if (executionContext.BusinessReference is null && definition.BusinessReference is not null) + { + executionContext.SetBusinessReference( + NormalizeBusinessReference( + WorkflowCanonicalExpressionRuntime.EvaluateBusinessReference( + definition.BusinessReference, + executionContext.ToEvaluationContext()))); + } + + if (!string.IsNullOrWhiteSpace(definition.Start.InitialTaskName)) + { + var initialTask = BuildTaskPlan( + GetRequiredTask(definition.Start.InitialTaskName), + executionContext, + []); + + return Task.FromResult(new WorkflowStartExecutionPlan + { + InstanceStatus = WorkflowOpenStatus, + BusinessReference = executionContext.BusinessReference, + WorkflowState = workflowState, + Tasks = [initialTask], + }); + } + + return StartFromSequenceAsync( + workflowState, + executionContext, + CanonicalWorkflowExecutionLocation.InitialSequence(), + cancellationToken); + } + + public async Task CompleteTaskAsync( + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default) + { + if (CanonicalWorkflowForkState.TryReadTaskContext( + context.CurrentTask.Payload, + out var forkCoordinatorId, + out var forkBranchIndex)) + { + return await CompleteForkTaskAsync( + context, + forkCoordinatorId!, + forkBranchIndex, + cancellationToken); + } + + var workflowState = context.WorkflowState.CloneJson(); + var executionContext = new WorkflowCanonicalExecutionContext( + definition.WorkflowName, + definition.WorkflowVersion, + runtimeDefinition.Descriptor.WorkflowRoles, + null, + workflowState, + CloneJsonDictionary(context.Payload), + context.CurrentTask.BusinessReference, + workflowFunctionRuntime); + var task = GetRequiredTask(context.CurrentTask.TaskName); + var continuations = new List(); + var terminalResult = await ExecuteSequenceAsync( + task.OnComplete, + executionContext, + CanonicalWorkflowExecutionLocation.TaskOnComplete(task.TaskName), + continuations, + cancellationToken); + + if (terminalResult is null) + { + throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' did not reach a terminal step for task '{task.TaskName}'."); + } + + return await FinalizeNestedCompletionAsync( + BuildCompletionPlan(workflowState, terminalResult), + cancellationToken); + } + + Task IDeclarativeWorkflowResumeHandler.ResumeAsync( + WorkflowResumeExecutionContext context, + CancellationToken cancellationToken) + { + return ResumeNestedAsync(context, cancellationToken); + } + + Task IWorkflowSignalResumableExecutionHandler.ResumeSignalAsync( + WorkflowSignalResumeContext context, + CancellationToken cancellationToken) + { + return ResumeSignalAsync(context, cancellationToken); + } + + private async Task ResumeNestedAsync( + WorkflowResumeExecutionContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.ResumePointer is not CanonicalWorkflowResumePointer resumePointer) + { + throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' cannot resume with a non-canonical resume pointer."); + } + + var workflowState = context.WorkflowState.CloneJson(); + var publicSubWorkflowState = StripInternalState(context.SubWorkflowState); + MergeSubWorkflowState(workflowState, publicSubWorkflowState); + + if (!string.IsNullOrWhiteSpace(context.ResultKey)) + { + workflowState.Assign(context.ResultKey, publicSubWorkflowState); + } + + var executionContext = new WorkflowCanonicalExecutionContext( + definition.WorkflowName, + definition.WorkflowVersion, + runtimeDefinition.Descriptor.WorkflowRoles, + null, + workflowState, + new Dictionary(StringComparer.OrdinalIgnoreCase), + context.BusinessReference, + workflowFunctionRuntime); + var continuations = new List(context.Continuations); + var terminalResult = await ResumeAsync( + resumePointer, + executionContext, + continuations, + cancellationToken); + + return await FinalizeNestedCompletionAsync( + BuildCompletionPlan( + workflowState, + terminalResult ?? new WorkflowExecutionTerminalResult( + WorkflowCompletedStatus, + executionContext.BusinessReference, + [], + [], + continuations.ToArray())), + cancellationToken); + } + + private async Task ResumeSignalAsync( + WorkflowSignalResumeContext context, + CancellationToken cancellationToken) + { + var isSubWorkflowSignal = string.Equals( + context.Signal.SignalType, + WorkflowSignalTypes.SubWorkflowCompleted, + StringComparison.OrdinalIgnoreCase); + var isTimerSignal = string.Equals(context.Signal.SignalType, WorkflowSignalTypes.TimerDue, StringComparison.OrdinalIgnoreCase); + var isExternalSignal = string.Equals(context.Signal.SignalType, WorkflowSignalTypes.ExternalSignal, StringComparison.OrdinalIgnoreCase); + if (isSubWorkflowSignal) + { + return await ResumeSubWorkflowSignalAsync(context, cancellationToken); + } + + if (!isTimerSignal && !isExternalSignal) + { + throw new NotSupportedException( + $"Canonical workflow '{definition.WorkflowName}' does not support signal type '{context.Signal.SignalType}'."); + } + + if (!context.ResumeState.TryGetValue(ResumePointerResumeStateKey, out var resumePointerElement)) + { + throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' signal resume is missing resume pointer state."); + } + + if (TryReadResumeStateString( + context.ResumeState, + CanonicalWorkflowForkState.ForkCoordinatorIdPayloadKey, + out var forkCoordinatorId) + && TryReadResumeStateInt32( + context.ResumeState, + CanonicalWorkflowForkState.ForkBranchIndexPayloadKey, + out var forkBranchIndex)) + { + return await ResumeForkSignalAsync( + context, + forkCoordinatorId!, + forkBranchIndex, + cancellationToken); + } + + var resumePointer = JsonSerializer.Deserialize(resumePointerElement.GetRawText()) + ?? throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' signal resume pointer could not be deserialized."); + var workflowState = context.WorkflowState.CloneJson(); + var executionContext = new WorkflowCanonicalExecutionContext( + definition.WorkflowName, + definition.WorkflowVersion, + runtimeDefinition.Descriptor.WorkflowRoles, + null, + workflowState, + CloneJsonDictionary(context.Signal.Payload), + context.BusinessReference, + workflowFunctionRuntime); + + if (isExternalSignal + && TryReadResumeStateString(context.ResumeState, ResultKeyResumeStateKey, out var resultKey)) + { + executionContext.SetResult(resultKey!, SerializeToJsonElement(context.Signal.Payload)); + } + + var continuations = new List(context.Continuations); + var terminalResult = await ResumeAsync( + resumePointer, + executionContext, + continuations, + cancellationToken); + + return BuildCompletionPlan( + workflowState, + terminalResult ?? new WorkflowExecutionTerminalResult( + WorkflowCompletedStatus, + executionContext.BusinessReference, + [], + [], + continuations.ToArray())); + } + + private async Task ResumeSubWorkflowSignalAsync( + WorkflowSignalResumeContext context, + CancellationToken cancellationToken) + { + var frames = ReadSubWorkflowFrames(context.WorkflowState); + if (frames.Count == 0) + { + throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' cannot resume sub workflow completion without parent frames."); + } + + var frame = frames[^1]; + frames.RemoveAt(frames.Count - 1); + + var parentRuntimeDefinition = workflowRuntimeDefinitionStore.GetRequiredDefinition( + frame.WorkflowName, + frame.WorkflowVersion); + var parentHandler = workflowRuntimeExecutionHandlerFactory.TryCreateHandler(parentRuntimeDefinition) + as IDeclarativeWorkflowResumeHandler + ?? throw new NotSupportedException( + $"Workflow '{frame.WorkflowName}' cannot resume from a durable sub workflow completion."); + + var parentState = frame.WorkflowState.CloneJson(); + WriteSubWorkflowFrames(parentState, frames); + + return await parentHandler.ResumeAsync(new WorkflowResumeExecutionContext + { + WorkflowState = parentState, + BusinessReference = context.BusinessReference ?? frame.BusinessReference, + ResumePointer = frame.ResumePointer, + SubWorkflowState = context.WorkflowState, + ResultKey = frame.ResultKey, + Continuations = context.Continuations, + }, cancellationToken); + } + + private async Task CompleteForkTaskAsync( + WorkflowTaskExecutionContext context, + string coordinatorId, + int branchIndex, + CancellationToken cancellationToken) + { + var rootWorkflowState = context.WorkflowState.CloneJson(); + var forkFrames = CanonicalWorkflowForkState.ReadFrames(rootWorkflowState); + var (forkFrame, branchFrame) = GetRequiredForkBranch(forkFrames, coordinatorId, branchIndex); + var task = GetRequiredTask(context.CurrentTask.TaskName); + var executionContext = new WorkflowCanonicalExecutionContext( + definition.WorkflowName, + definition.WorkflowVersion, + runtimeDefinition.Descriptor.WorkflowRoles, + null, + CloneJsonDictionary(branchFrame.WorkflowState), + CloneJsonDictionary(context.Payload), + branchFrame.BusinessReference ?? context.CurrentTask.BusinessReference, + workflowFunctionRuntime); + var continuations = new List(); + var terminalResult = await ExecuteSequenceAsync( + task.OnComplete, + executionContext, + CanonicalWorkflowExecutionLocation.TaskOnComplete(task.TaskName), + continuations, + cancellationToken); + + return await FinalizeForkBranchProgressAsync( + rootWorkflowState, + forkFrames, + forkFrame, + branchIndex, + executionContext, + terminalResult, + continuations, + cancellationToken); + } + + private async Task ResumeForkSignalAsync( + WorkflowSignalResumeContext context, + string coordinatorId, + int branchIndex, + CancellationToken cancellationToken) + { + var rootWorkflowState = context.WorkflowState.CloneJson(); + var forkFrames = CanonicalWorkflowForkState.ReadFrames(rootWorkflowState); + var (forkFrame, branchFrame) = GetRequiredForkBranch(forkFrames, coordinatorId, branchIndex); + if (!context.ResumeState.TryGetValue(ResumePointerResumeStateKey, out var resumePointerElement)) + { + throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' signal resume is missing resume pointer state."); + } + + var resumePointer = JsonSerializer.Deserialize(resumePointerElement.GetRawText()) + ?? throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' signal resume pointer could not be deserialized."); + var executionContext = new WorkflowCanonicalExecutionContext( + definition.WorkflowName, + definition.WorkflowVersion, + runtimeDefinition.Descriptor.WorkflowRoles, + null, + CloneJsonDictionary(branchFrame.WorkflowState), + CloneJsonDictionary(context.Signal.Payload), + branchFrame.BusinessReference ?? context.BusinessReference, + workflowFunctionRuntime); + + if (string.Equals(context.Signal.SignalType, WorkflowSignalTypes.ExternalSignal, StringComparison.OrdinalIgnoreCase) + && TryReadResumeStateString(context.ResumeState, ResultKeyResumeStateKey, out var resultKey)) + { + executionContext.SetResult(resultKey!, SerializeToJsonElement(context.Signal.Payload)); + } + + var continuations = new List(); + var terminalResult = await ResumeAsync( + ResolveForkBranchSequence(forkFrame, branchIndex), + TrimForkResumePointer(resumePointer), + executionContext, + continuations, + cancellationToken, + 0); + + return await FinalizeForkBranchProgressAsync( + rootWorkflowState, + forkFrames, + forkFrame, + branchIndex, + executionContext, + terminalResult, + continuations, + cancellationToken); + } + + private async Task FinalizeForkBranchProgressAsync( + Dictionary rootWorkflowState, + List forkFrames, + CanonicalWorkflowForkFrame forkFrame, + int branchIndex, + WorkflowCanonicalExecutionContext branchContext, + WorkflowExecutionTerminalResult? terminalResult, + IReadOnlyCollection continuations, + CancellationToken cancellationToken) + { + var updatedBranches = forkFrame.Branches + .Select(x => x.BranchIndex == branchIndex + ? BuildUpdatedForkBranchFrame(x, branchContext, terminalResult, forkFrame.CoordinatorId) + : CloneForkBranchFrame(x)) + .OrderBy(x => x.BranchIndex) + .ToArray(); + var updatedForkFrame = forkFrame with { Branches = updatedBranches }; + var branchContinuations = terminalResult?.Continuations ?? continuations.ToArray(); + + if (IsForkBranchStillOpen(terminalResult)) + { + ReplaceForkFrame(forkFrames, updatedForkFrame); + ReplaceWorkflowState(rootWorkflowState, CloneJsonDictionary(forkFrame.BaseState)); + CanonicalWorkflowForkState.WriteFrames(rootWorkflowState, forkFrames); + + var currentBranch = updatedBranches.First(x => x.BranchIndex == branchIndex); + return new WorkflowTaskCompletionPlan + { + InstanceStatus = WorkflowOpenStatus, + BusinessReference = CanonicalWorkflowForkState.ResolveBusinessReference(updatedForkFrame), + WorkflowState = rootWorkflowState, + NextTasks = terminalResult is null + ? [] + : CanonicalWorkflowForkState.AttachTaskContext( + terminalResult.NextTasks, + updatedForkFrame.CoordinatorId, + branchIndex), + PendingSignals = currentBranch.PendingSignals, + Continuations = branchContinuations, + }; + } + + if (updatedBranches.Any(x => !x.Completed)) + { + ReplaceForkFrame(forkFrames, updatedForkFrame); + ReplaceWorkflowState(rootWorkflowState, CloneJsonDictionary(forkFrame.BaseState)); + CanonicalWorkflowForkState.WriteFrames(rootWorkflowState, forkFrames); + + return new WorkflowTaskCompletionPlan + { + InstanceStatus = WorkflowOpenStatus, + BusinessReference = CanonicalWorkflowForkState.ResolveBusinessReference(updatedForkFrame), + WorkflowState = rootWorkflowState, + Continuations = branchContinuations, + }; + } + + var remainingFrames = forkFrames + .Where(x => !string.Equals(x.CoordinatorId, updatedForkFrame.CoordinatorId, StringComparison.Ordinal)) + .ToArray(); + var mergedState = CloneJsonDictionary(forkFrame.BaseState); + var mergedBusinessReference = forkFrame.BaseBusinessReference; + foreach (var branch in updatedBranches) + { + MergeForkBranchState(mergedState, forkFrame.BaseState, branch.WorkflowState); + if (branch.BusinessReference is not null) + { + mergedBusinessReference = branch.BusinessReference; + } + } + + CanonicalWorkflowForkState.WriteFrames(mergedState, remainingFrames); + + var joinPointer = JsonSerializer.Deserialize(updatedForkFrame.ResumePointer.GetRawText()) + ?? throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' fork join pointer could not be deserialized."); + var rootContext = new WorkflowCanonicalExecutionContext( + definition.WorkflowName, + definition.WorkflowVersion, + runtimeDefinition.Descriptor.WorkflowRoles, + null, + mergedState, + new Dictionary(StringComparer.OrdinalIgnoreCase), + mergedBusinessReference, + workflowFunctionRuntime); + var rootContinuations = new List(branchContinuations); + var joinResult = await ResumeAsync( + joinPointer, + rootContext, + rootContinuations, + cancellationToken); + + return BuildCompletionPlan( + mergedState, + joinResult ?? new WorkflowExecutionTerminalResult( + WorkflowCompletedStatus, + rootContext.BusinessReference, + [], + [], + rootContinuations.ToArray())); + } + + private async Task StartFromSequenceAsync( + Dictionary workflowState, + WorkflowCanonicalExecutionContext executionContext, + CanonicalWorkflowExecutionLocation location, + CancellationToken cancellationToken) + { + var continuations = new List(); + var terminalResult = await ExecuteSequenceAsync( + definition.Start.InitialSequence, + executionContext, + location, + continuations, + cancellationToken); + + if (terminalResult is null) + { + throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' did not reach a terminal start step."); + } + + return new WorkflowStartExecutionPlan + { + InstanceStatus = terminalResult.InstanceStatus, + BusinessReference = terminalResult.BusinessReference, + WorkflowState = workflowState, + Tasks = terminalResult.NextTasks, + PendingSignals = terminalResult.PendingSignals, + Continuations = terminalResult.Continuations, + }; + } + + private async Task ExecuteSequenceAsync( + WorkflowStepSequenceDeclaration sequence, + WorkflowCanonicalExecutionContext context, + CanonicalWorkflowExecutionLocation location, + List continuations, + CancellationToken cancellationToken, + int startIndex = 0) + { + var steps = sequence.Steps.ToArray(); + for (var index = startIndex; index < steps.Length; index++) + { + var step = steps[index]; + + switch (step) + { + case WorkflowSetStateStepDeclaration setState: + ApplySetState(context, setState); + break; + case WorkflowAssignBusinessReferenceStepDeclaration businessReferenceStep: + context.SetBusinessReference( + NormalizeBusinessReference( + WorkflowCanonicalExpressionRuntime.EvaluateBusinessReference( + businessReferenceStep.BusinessReference, + context.ToEvaluationContext()))); + break; + case WorkflowTransportCallStepDeclaration transportCall: + { + var result = await ExecuteTransportAsync( + context, + transportCall, + location, + index, + continuations, + cancellationToken); + if (result is not null) + { + return result; + } + + break; + } + case WorkflowDecisionStepDeclaration decisionStep: + { + var condition = ConvertToBoolean( + WorkflowCanonicalExpressionRuntime.Evaluate( + decisionStep.ConditionExpression, + context.ToEvaluationContext())); + var branch = condition + ? decisionStep.WhenTrue + : decisionStep.WhenElse; + var branchResult = await ExecuteSequenceAsync( + branch, + context, + location.EnterBranch(index, condition ? WorkflowResumeBranchKind.True : WorkflowResumeBranchKind.Else), + continuations, + cancellationToken); + if (branchResult is not null) + { + return branchResult; + } + + break; + } + case WorkflowActivateTaskStepDeclaration activateTaskStep: + { + var runtimeRoles = activateTaskStep.RuntimeRolesExpression is null + ? [] + : ConvertToStringCollection( + WorkflowCanonicalExpressionRuntime.Evaluate( + activateTaskStep.RuntimeRolesExpression, + context.ToEvaluationContext())); + var nextTask = BuildTaskPlan( + GetRequiredTask(activateTaskStep.TaskName), + context, + runtimeRoles, + activateTaskStep.TimeoutSeconds); + return new WorkflowExecutionTerminalResult( + WorkflowOpenStatus, + context.BusinessReference, + [nextTask], + [], + continuations.ToArray()); + } + case WorkflowContinueWithWorkflowStepDeclaration continueWithStep: + continuations.Add(new WorkflowContinuationPlan + { + Request = BuildStartWorkflowRequest(continueWithStep.Invocation, context), + }); + return new WorkflowExecutionTerminalResult( + WorkflowCompletedStatus, + context.BusinessReference, + [], + [], + continuations.ToArray()); + case WorkflowSubWorkflowStepDeclaration subWorkflowStep: + { + var subWorkflowResult = await ExecuteSubWorkflowAsync( + context, + subWorkflowStep, + location.CreateResumePointer(index + 1), + continuations, + cancellationToken); + if (subWorkflowResult is not null) + { + return subWorkflowResult; + } + + break; + } + case WorkflowTimerStepDeclaration timerStep: + return BuildTimerWaitResult(context, timerStep, location.CreateResumePointer(index + 1), continuations); + case WorkflowExternalSignalStepDeclaration externalSignalStep: + return BuildExternalSignalWaitResult( + context, + externalSignalStep, + location.CreateResumePointer(index + 1), + continuations); + case WorkflowRepeatStepDeclaration repeatStep: + { + var repeatResult = await ExecuteRepeatAsync( + context, + repeatStep, + location, + index, + continuations, + cancellationToken); + if (repeatResult is not null) + { + return repeatResult; + } + + break; + } + case WorkflowForkStepDeclaration forkStep: + { + var forkResult = await ExecuteForkAsync( + context, + forkStep, + location, + index, + continuations, + cancellationToken); + if (forkResult is not null) + { + return forkResult; + } + + break; + } + case WorkflowCompleteStepDeclaration: + return new WorkflowExecutionTerminalResult( + WorkflowCompletedStatus, + context.BusinessReference, + [], + [], + continuations.ToArray()); + default: + throw new InvalidOperationException( + $"Workflow step type '{step.GetType().FullName}' is not supported by the canonical runtime interpreter."); + } + } + + return null; + } + + private async Task ResumeAsync( + CanonicalWorkflowResumePointer pointer, + WorkflowCanonicalExecutionContext context, + List continuations, + CancellationToken cancellationToken, + int branchDepth = 0) + { + var sequence = ResolveEntryPointSequence(pointer); + + if (branchDepth >= pointer.BranchPath.Count) + { + return await ExecuteSequenceAsync( + sequence, + context, + new CanonicalWorkflowExecutionLocation + { + EntryPointKind = pointer.EntryPointKind, + TaskName = pointer.TaskName, + BranchPath = pointer.BranchPath, + }, + continuations, + cancellationToken, + pointer.NextStepIndex); + } + + var branchPointer = pointer.BranchPath.ElementAt(branchDepth); + var steps = sequence.Steps.ToArray(); + if (branchPointer.StepIndex < 0 || branchPointer.StepIndex >= steps.Length) + { + throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' cannot resolve branch step index '{branchPointer.StepIndex}' for resume."); + } + + var branchResult = await ResumeAsync( + ResolveNestedSequence(steps[branchPointer.StepIndex], branchPointer), + pointer, + context, + continuations, + cancellationToken, + branchDepth + 1); + + if (branchResult is not null) + { + return branchResult; + } + + if (steps[branchPointer.StepIndex] is WorkflowRepeatStepDeclaration repeatStep + && branchPointer.BranchKind == WorkflowResumeBranchKind.Repeat) + { + return await ContinueRepeatAfterBodyAsync( + repeatStep, + branchPointer.BranchIndex ?? 0, + sequence, + pointer, + branchPointer.StepIndex, + context, + continuations, + cancellationToken); + } + + return await ExecuteSequenceAsync( + sequence, + context, + new CanonicalWorkflowExecutionLocation + { + EntryPointKind = pointer.EntryPointKind, + TaskName = pointer.TaskName, + BranchPath = pointer.BranchPath.Take(branchDepth).ToArray(), + }, + continuations, + cancellationToken, + branchPointer.StepIndex + 1); + } + + private async Task ResumeAsync( + WorkflowStepSequenceDeclaration sequence, + CanonicalWorkflowResumePointer pointer, + WorkflowCanonicalExecutionContext context, + List continuations, + CancellationToken cancellationToken, + int branchDepth) + { + if (branchDepth >= pointer.BranchPath.Count) + { + return await ExecuteSequenceAsync( + sequence, + context, + new CanonicalWorkflowExecutionLocation + { + EntryPointKind = pointer.EntryPointKind, + TaskName = pointer.TaskName, + BranchPath = pointer.BranchPath, + }, + continuations, + cancellationToken, + pointer.NextStepIndex); + } + + var branchPointer = pointer.BranchPath.ElementAt(branchDepth); + var steps = sequence.Steps.ToArray(); + if (branchPointer.StepIndex < 0 || branchPointer.StepIndex >= steps.Length) + { + throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' cannot resolve branch step index '{branchPointer.StepIndex}' for nested resume."); + } + + var branchResult = await ResumeAsync( + ResolveNestedSequence(steps[branchPointer.StepIndex], branchPointer), + pointer, + context, + continuations, + cancellationToken, + branchDepth + 1); + + if (branchResult is not null) + { + return branchResult; + } + + if (steps[branchPointer.StepIndex] is WorkflowRepeatStepDeclaration repeatStep + && branchPointer.BranchKind == WorkflowResumeBranchKind.Repeat) + { + return await ContinueRepeatAfterBodyAsync( + repeatStep, + branchPointer.BranchIndex ?? 0, + sequence, + pointer, + branchPointer.StepIndex, + context, + continuations, + cancellationToken); + } + + return await ExecuteSequenceAsync( + sequence, + context, + new CanonicalWorkflowExecutionLocation + { + EntryPointKind = pointer.EntryPointKind, + TaskName = pointer.TaskName, + BranchPath = pointer.BranchPath.Take(branchDepth).ToArray(), + }, + continuations, + cancellationToken, + branchPointer.StepIndex + 1); + } + + private async Task ExecuteSubWorkflowAsync( + WorkflowCanonicalExecutionContext context, + WorkflowSubWorkflowStepDeclaration step, + CanonicalWorkflowResumePointer resumePointer, + List continuations, + CancellationToken cancellationToken) + { + var childRequest = BuildStartWorkflowRequest(step.Invocation, context); + var childRegistration = workflowRegistrationCatalog.GetRegistration( + childRequest.WorkflowName, + childRequest.WorkflowVersion) + ?? throw new BaseResultException( + MessageKeys.WorkflowDefinitionNotFound, + childRequest.WorkflowName); + var childWorkflowVersion = childRequest.WorkflowVersion ?? childRegistration.Definition.WorkflowVersion; + var childRuntimeDefinition = workflowRuntimeDefinitionStore.GetRequiredDefinition( + childRequest.WorkflowName, + childWorkflowVersion); + var childHandler = workflowRuntimeExecutionHandlerFactory.TryCreateHandler(childRuntimeDefinition) + ?? throw new InvalidOperationException( + $"Workflow '{childRequest.WorkflowName}' does not have an execution handler."); + + if (childHandler is not IDeclarativeWorkflowResumeHandler) + { + throw new NotSupportedException( + $"Workflow '{childRequest.WorkflowName}' cannot be used as a synchronous sub workflow because it does not support nested resume."); + } + + var childStartRequest = childRegistration.BindStartRequest(childRequest.Payload); + var childBusinessReference = childRequest.BusinessReference + ?? childRegistration.ExtractBusinessReference(childStartRequest); + var childPlan = await childHandler.StartAsync(new WorkflowStartExecutionContext + { + Registration = childRegistration, + Definition = childRegistration.Definition, + BusinessReference = childBusinessReference, + StartRequest = childStartRequest, + Payload = SerializePayload(childRequest.Payload), + }, cancellationToken); + + continuations.AddRange(childPlan.Continuations); + + if (childPlan.Tasks.Count > 0 || childPlan.PendingSignals.Count > 0) + { + var childState = childPlan.WorkflowState.CloneJson(); + var projectionWorkflowInstanceId = EnsureProjectionWorkflowInstanceId(childState); + var frames = ReadSubWorkflowFrames(childState); + frames.Add(new CanonicalWorkflowSubWorkflowFrame + { + WorkflowName = definition.WorkflowName, + WorkflowVersion = definition.WorkflowVersion, + BusinessReference = context.BusinessReference, + WorkflowState = context.WorkflowState.CloneJson(), + ResumePointer = resumePointer, + ResultKey = step.ResultKey, + }); + WriteSubWorkflowFrames(childState, frames); + ReplaceWorkflowState(context.WorkflowState, childState); + context.SetBusinessReference(childPlan.BusinessReference ?? childBusinessReference); + + return new WorkflowExecutionTerminalResult( + WorkflowOpenStatus, + context.BusinessReference, + AttachProjectionWorkflowInstanceId(childPlan.Tasks, projectionWorkflowInstanceId), + childPlan.PendingSignals, + continuations.ToArray()); + } + + if (!string.Equals(childPlan.InstanceStatus, WorkflowCompletedStatus, StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException( + $"Workflow '{childRequest.WorkflowName}' returned status '{childPlan.InstanceStatus}' without tasks or pending signals. This runtime path is not supported."); + } + + ApplyCompletedSubWorkflowState( + context, + step.ResultKey, + childPlan.WorkflowState, + childPlan.BusinessReference ?? childBusinessReference); + + return null; + } + + private async Task ExecuteRepeatAsync( + WorkflowCanonicalExecutionContext context, + WorkflowRepeatStepDeclaration step, + CanonicalWorkflowExecutionLocation location, + int stepIndex, + List continuations, + CancellationToken cancellationToken) + { + var maxIterations = ConvertToInt32( + WorkflowCanonicalExpressionRuntime.Evaluate( + step.MaxIterationsExpression, + context.ToEvaluationContext()), + $"Workflow repeat max-iterations expression for step '{step.StepName}'"); + if (maxIterations <= 0) + { + return null; + } + + for (var iteration = 1; iteration <= maxIterations; iteration++) + { + ApplyRepeatIterationState(context, step, iteration); + + var bodyResult = await ExecuteSequenceAsync( + step.Body, + context, + location.EnterBranch(stepIndex, WorkflowResumeBranchKind.Repeat, iteration), + continuations, + cancellationToken); + if (bodyResult is not null) + { + return bodyResult; + } + + if (!ShouldContinueRepeat(context, step, iteration, maxIterations)) + { + break; + } + } + + return null; + } + + private async Task ExecuteForkAsync( + WorkflowCanonicalExecutionContext context, + WorkflowForkStepDeclaration step, + CanonicalWorkflowExecutionLocation location, + int stepIndex, + List continuations, + CancellationToken cancellationToken) + { + var baseState = CloneJsonDictionary(context.WorkflowState); + var existingForkFrames = CanonicalWorkflowForkState.ReadFrames(baseState); + var mergedState = CloneJsonDictionary(context.WorkflowState); + var mergedBusinessReference = context.BusinessReference; + var coordinatorId = $"fork-{step.StepName}-{Guid.NewGuid():N}"; + var branchFrames = new List(step.Branches.Count); + var nextTasks = new List(); + var pendingSignals = new List(); + + for (var branchIndex = 0; branchIndex < step.Branches.Count; branchIndex++) + { + var branchContext = new WorkflowCanonicalExecutionContext( + context.WorkflowName, + context.WorkflowVersion, + context.WorkflowRoles, + null, + CloneJsonDictionary(baseState), + CloneJsonDictionary(context.Payload), + context.BusinessReference, + workflowFunctionRuntime); + var branchResult = await ExecuteSequenceAsync( + step.Branches.ElementAt(branchIndex), + branchContext, + location.EnterBranch(stepIndex, WorkflowResumeBranchKind.Fork, branchIndex), + continuations, + cancellationToken); + + if (branchResult is not null) + { + if (branchResult.NextTasks.Count == 0 + && branchResult.PendingSignals.Count == 0 + && string.Equals(branchResult.InstanceStatus, WorkflowOpenStatus, StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException( + $"Canonical fork branch open-state transitions are not yet supported by the runtime interpreter for workflow '{definition.WorkflowName}'."); + } + } + + if (IsForkBranchStillOpen(branchResult)) + { + var decoratedBranchTasks = CanonicalWorkflowForkState.AttachTaskContext( + branchResult!.NextTasks, + coordinatorId, + branchIndex); + var decoratedBranchSignals = CanonicalWorkflowForkState.AttachSignalContext( + branchResult.PendingSignals, + coordinatorId, + branchIndex); + + nextTasks.AddRange(decoratedBranchTasks); + pendingSignals.AddRange(decoratedBranchSignals); + branchFrames.Add(new CanonicalWorkflowForkBranchFrame + { + BranchIndex = branchIndex, + WorkflowState = CloneJsonDictionary(branchContext.WorkflowState), + BusinessReference = branchContext.BusinessReference, + ActiveTasks = CanonicalWorkflowForkState.CreateTaskFrames(decoratedBranchTasks), + PendingSignals = decoratedBranchSignals + .Select(ClonePendingSignalPlan) + .ToArray(), + }); + continue; + } + + MergeForkBranchState(mergedState, baseState, branchContext.WorkflowState); + if (branchContext.BusinessReference is not null) + { + mergedBusinessReference = branchContext.BusinessReference; + } + + branchFrames.Add(new CanonicalWorkflowForkBranchFrame + { + BranchIndex = branchIndex, + WorkflowState = CloneJsonDictionary(branchContext.WorkflowState), + BusinessReference = branchContext.BusinessReference, + Completed = true, + }); + } + + if (nextTasks.Count == 0 && pendingSignals.Count == 0) + { + ReplaceWorkflowState(context.WorkflowState, mergedState); + context.SetBusinessReference(mergedBusinessReference); + return null; + } + + var forkFrame = new CanonicalWorkflowForkFrame + { + CoordinatorId = coordinatorId, + ResumePointer = JsonSerializer.SerializeToElement(location.CreateResumePointer(stepIndex + 1)), + BaseState = CloneJsonDictionary(baseState), + BaseBusinessReference = context.BusinessReference, + Branches = branchFrames, + }; + existingForkFrames.Add(forkFrame); + ReplaceWorkflowState(context.WorkflowState, CloneJsonDictionary(baseState)); + CanonicalWorkflowForkState.WriteFrames(context.WorkflowState, existingForkFrames); + context.SetBusinessReference(CanonicalWorkflowForkState.ResolveBusinessReference(forkFrame)); + + return new WorkflowExecutionTerminalResult( + WorkflowOpenStatus, + context.BusinessReference, + nextTasks, + pendingSignals, + continuations.ToArray()); + } + + private async Task ContinueRepeatAfterBodyAsync( + WorkflowRepeatStepDeclaration step, + int completedIteration, + WorkflowStepSequenceDeclaration sequence, + CanonicalWorkflowResumePointer pointer, + int stepIndex, + WorkflowCanonicalExecutionContext context, + List continuations, + CancellationToken cancellationToken) + { + var maxIterations = ConvertToInt32( + WorkflowCanonicalExpressionRuntime.Evaluate( + step.MaxIterationsExpression, + context.ToEvaluationContext()), + $"Workflow repeat max-iterations expression for step '{step.StepName}'"); + if (completedIteration > 0) + { + ApplyRepeatIterationState(context, step, completedIteration); + } + + if (ShouldContinueRepeat(context, step, completedIteration, maxIterations)) + { + for (var iteration = completedIteration + 1; iteration <= maxIterations; iteration++) + { + ApplyRepeatIterationState(context, step, iteration); + + var bodyResult = await ExecuteSequenceAsync( + step.Body, + context, + new CanonicalWorkflowExecutionLocation + { + EntryPointKind = pointer.EntryPointKind, + TaskName = pointer.TaskName, + BranchPath = pointer.BranchPath.Take(pointer.BranchPath.Count - 1) + .Append(new CanonicalWorkflowResumeBranchPointer + { + StepIndex = stepIndex, + BranchKind = WorkflowResumeBranchKind.Repeat, + BranchIndex = iteration, + }) + .ToArray(), + }, + continuations, + cancellationToken); + if (bodyResult is not null) + { + return bodyResult; + } + + if (!ShouldContinueRepeat(context, step, iteration, maxIterations)) + { + break; + } + } + } + + return await ExecuteSequenceAsync( + sequence, + context, + new CanonicalWorkflowExecutionLocation + { + EntryPointKind = pointer.EntryPointKind, + TaskName = pointer.TaskName, + BranchPath = pointer.BranchPath.Take(pointer.BranchPath.Count - 1).ToArray(), + }, + continuations, + cancellationToken, + stepIndex + 1); + } + + private async Task ExecuteTransportAsync( + WorkflowCanonicalExecutionContext context, + WorkflowTransportCallStepDeclaration step, + CanonicalWorkflowExecutionLocation location, + int stepIndex, + List continuations, + CancellationToken cancellationToken) + { + var stepTimeoutSeconds = step.TimeoutSeconds ?? WorkflowTimeoutDefaults.DefaultTimeoutForServiceTaskCallsSeconds; + using var stepCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (stepTimeoutSeconds > 0) + { + stepCts.CancelAfter(TimeSpan.FromSeconds(stepTimeoutSeconds)); + } + + try + { + switch (step.Invocation.Address) + { + case WorkflowMicroserviceAddressDeclaration microserviceAddress: + { + var response = await microserviceTransport.ExecuteAsync(new WorkflowMicroserviceRequest + { + MicroserviceName = microserviceAddress.MicroserviceName, + Command = microserviceAddress.Command, + Payload = EvaluatePayload(step.Invocation.PayloadExpression, context), + }, stepCts.Token); + + return await HandleMicroserviceResponseAsync( + context, + step, + response, + location, + stepIndex, + continuations, + cancellationToken, + $"{microserviceAddress.MicroserviceName}:{microserviceAddress.Command}"); + } + case WorkflowLegacyRabbitAddressDeclaration legacyRabbitAddress: + { + var response = await legacyRabbitTransport.ExecuteAsync(new WorkflowLegacyRabbitRequest + { + Command = legacyRabbitAddress.Command, + Mode = legacyRabbitAddress.Mode, + Payload = EvaluatePayload(step.Invocation.PayloadExpression, context), + }, stepCts.Token); + + return await HandleMicroserviceResponseAsync( + context, + step, + response, + location, + stepIndex, + continuations, + cancellationToken, + $"legacy-rabbit:{legacyRabbitAddress.Command}"); + } + case WorkflowGraphqlAddressDeclaration graphqlAddress: + { + var response = await graphqlTransport.ExecuteAsync(new WorkflowGraphqlRequest + { + Target = graphqlAddress.Target, + Query = graphqlAddress.Query, + OperationName = graphqlAddress.OperationName, + Variables = EvaluatePayload(step.Invocation.PayloadExpression, context).AsWorkflowObjectDictionary(), + }, stepCts.Token); + + return await HandleGraphqlResponseAsync( + context, + step, + response, + location, + stepIndex, + continuations, + cancellationToken); + } + case WorkflowHttpAddressDeclaration httpAddress: + { + var response = await httpTransport.ExecuteAsync(new WorkflowHttpRequest + { + Target = httpAddress.Target, + Method = httpAddress.Method, + Path = httpAddress.Path, + Payload = EvaluatePayload(step.Invocation.PayloadExpression, context), + }, stepCts.Token); + + return await HandleHttpResponseAsync( + context, + step, + response, + location, + stepIndex, + continuations, + cancellationToken); + } + case WorkflowRabbitAddressDeclaration rabbitAddress: + { + var response = await rabbitTransport.ExecuteAsync(new WorkflowRabbitRequest + { + Exchange = rabbitAddress.Exchange, + RoutingKey = rabbitAddress.RoutingKey, + Payload = EvaluatePayload(step.Invocation.PayloadExpression, context), + }, stepCts.Token); + + return await HandleRabbitResponseAsync( + context, + step, + response, + location, + stepIndex, + continuations, + cancellationToken, + $"{rabbitAddress.Exchange}:{rabbitAddress.RoutingKey}"); + } + default: + throw new InvalidOperationException( + $"Workflow transport address type '{step.Invocation.Address.GetType().FullName}' is not supported."); + } + } + catch (OperationCanceledException) when (stepCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"Workflow step '{step.StepName}' timed out after {stepTimeoutSeconds} seconds."); + } + catch (TimeoutException) + { + var timeoutResult = await ExecuteTimeoutBranchAsync( + step, + context, + location, + stepIndex, + continuations, + cancellationToken); + if (timeoutResult is null && !HasTimeoutHandlingConfigured(step)) + { + throw; + } + + return timeoutResult; + } + } + + private async Task HandleMicroserviceResponseAsync( + WorkflowCanonicalExecutionContext context, + WorkflowTransportCallStepDeclaration step, + WorkflowMicroserviceResponse response, + CanonicalWorkflowExecutionLocation location, + int stepIndex, + List continuations, + CancellationToken cancellationToken, + string operationName) + { + if (!response.Succeeded) + { + var failureResult = await ExecuteFailureBranchAsync( + step, + context, + location, + stepIndex, + continuations, + cancellationToken); + if (failureResult is null && !HasFailureHandlingConfigured(step)) + { + throw new BaseResultException( + MessageKeys.WorkflowTransportFailed, + operationName, + operationName, + response.Error ?? "Transport request failed."); + } + + return failureResult; + } + + if (!string.IsNullOrWhiteSpace(step.ResultKey)) + { + context.SetResult(step.ResultKey!, SerializeToJsonElement(response.Payload)); + } + + return null; + } + + private async Task HandleRabbitResponseAsync( + WorkflowCanonicalExecutionContext context, + WorkflowTransportCallStepDeclaration step, + WorkflowRabbitResponse response, + CanonicalWorkflowExecutionLocation location, + int stepIndex, + List continuations, + CancellationToken cancellationToken, + string operationName) + { + if (!response.Succeeded) + { + var failureResult = await ExecuteFailureBranchAsync( + step, + context, + location, + stepIndex, + continuations, + cancellationToken); + if (failureResult is null && !HasFailureHandlingConfigured(step)) + { + throw new BaseResultException( + MessageKeys.WorkflowTransportFailed, + operationName, + operationName, + response.Error ?? "Transport request failed."); + } + + return failureResult; + } + + if (!string.IsNullOrWhiteSpace(step.ResultKey)) + { + context.SetResult(step.ResultKey!, SerializeToJsonElement(response.Payload)); + } + + return null; + } + + private async Task HandleGraphqlResponseAsync( + WorkflowCanonicalExecutionContext context, + WorkflowTransportCallStepDeclaration step, + WorkflowGraphqlResponse response, + CanonicalWorkflowExecutionLocation location, + int stepIndex, + List continuations, + CancellationToken cancellationToken) + { + if (!response.Succeeded) + { + var failureResult = await ExecuteFailureBranchAsync( + step, + context, + location, + stepIndex, + continuations, + cancellationToken); + if (failureResult is null && !HasFailureHandlingConfigured(step)) + { + throw new BaseResultException( + MessageKeys.WorkflowTransportFailed, + "graphql", + "graphql", + response.Error ?? "GraphQL request failed."); + } + + return failureResult; + } + + if (!string.IsNullOrWhiteSpace(step.ResultKey)) + { + context.SetResult(step.ResultKey!, DeserializeJsonPayload(response.JsonPayload)); + } + + return null; + } + + private async Task HandleHttpResponseAsync( + WorkflowCanonicalExecutionContext context, + WorkflowTransportCallStepDeclaration step, + WorkflowHttpResponse response, + CanonicalWorkflowExecutionLocation location, + int stepIndex, + List continuations, + CancellationToken cancellationToken) + { + if (!response.Succeeded) + { + var failureResult = await ExecuteFailureBranchAsync( + step, + context, + location, + stepIndex, + continuations, + cancellationToken); + if (failureResult is null && !HasFailureHandlingConfigured(step)) + { + throw new BaseResultException( + MessageKeys.WorkflowTransportFailed, + "http", + "http", + response.Error ?? "HTTP request failed."); + } + + return failureResult; + } + + if (!string.IsNullOrWhiteSpace(step.ResultKey)) + { + context.SetResult(step.ResultKey!, DeserializeJsonPayload(response.JsonPayload)); + } + + return null; + } + + private async Task ExecuteFailureBranchAsync( + WorkflowTransportCallStepDeclaration step, + WorkflowCanonicalExecutionContext context, + CanonicalWorkflowExecutionLocation location, + int stepIndex, + List continuations, + CancellationToken cancellationToken) + { + if (step.WhenFailure is null) + { + return null; + } + + return await ExecuteSequenceAsync( + step.WhenFailure, + context, + location.EnterBranch(stepIndex, WorkflowResumeBranchKind.Failure), + continuations, + cancellationToken); + } + + private async Task ExecuteTimeoutBranchAsync( + WorkflowTransportCallStepDeclaration step, + WorkflowCanonicalExecutionContext context, + CanonicalWorkflowExecutionLocation location, + int stepIndex, + List continuations, + CancellationToken cancellationToken) + { + if (step.WhenTimeout is not null) + { + return await ExecuteSequenceAsync( + step.WhenTimeout, + context, + location.EnterBranch(stepIndex, WorkflowResumeBranchKind.Timeout), + continuations, + cancellationToken); + } + + return await ExecuteFailureBranchAsync( + step, + context, + location, + stepIndex, + continuations, + cancellationToken); + } + + private Task FinalizeNestedCompletionAsync( + WorkflowTaskCompletionPlan completionPlan, + CancellationToken cancellationToken) + { + _ = cancellationToken; + + if (!string.Equals(completionPlan.InstanceStatus, WorkflowCompletedStatus, StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(completionPlan); + } + + var frames = ReadSubWorkflowFrames(completionPlan.WorkflowState); + if (frames.Count == 0) + { + return Task.FromResult(completionPlan); + } + var resumeState = new Dictionary(StringComparer.OrdinalIgnoreCase); + WorkflowSignalResumeState.WriteContinuations(resumeState, completionPlan.Continuations); + + return Task.FromResult(completionPlan with + { + InstanceStatus = WorkflowOpenStatus, + BusinessReference = completionPlan.BusinessReference ?? frames[^1].BusinessReference, + PendingSignals = + [ + new WorkflowPendingSignalPlan + { + SignalType = WorkflowSignalTypes.SubWorkflowCompleted, + WaitingToken = $"subworkflow-{Guid.NewGuid():N}", + ResumeState = resumeState, + }, + ], + Continuations = [], + }); + } + + private WorkflowTaskDeclaration GetRequiredTask(string taskName) + { + return definition.Tasks.FirstOrDefault(x => string.Equals(x.TaskName, taskName, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException( + $"Workflow task '{taskName}' is not declared in canonical workflow '{definition.WorkflowName}'."); + } + + private WorkflowStepSequenceDeclaration ResolveEntryPointSequence(CanonicalWorkflowResumePointer pointer) + { + return pointer.EntryPointKind switch + { + WorkflowResumeEntryPointKind.InitialSequence => definition.Start.InitialSequence, + WorkflowResumeEntryPointKind.TaskOnComplete when !string.IsNullOrWhiteSpace(pointer.TaskName) => + GetRequiredTask(pointer.TaskName).OnComplete, + _ => throw new InvalidOperationException( + $"Workflow '{definition.WorkflowName}' cannot resolve resume entry point '{pointer.EntryPointKind}'."), + }; + } + + private WorkflowStepSequenceDeclaration ResolveForkBranchSequence( + CanonicalWorkflowForkFrame forkFrame, + int branchIndex) + { + var joinPointer = JsonSerializer.Deserialize(forkFrame.ResumePointer.GetRawText()) + ?? throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' fork join pointer could not be deserialized."); + if (joinPointer.NextStepIndex <= 0) + { + throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' fork join pointer is missing the fork step location."); + } + + var containingSequence = ResolveSequenceForBranchPath(joinPointer); + var steps = containingSequence.Steps.ToArray(); + var forkStep = steps[joinPointer.NextStepIndex - 1] as WorkflowForkStepDeclaration + ?? throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' could not resolve fork step for coordinator '{forkFrame.CoordinatorId}'."); + + return forkStep.Branches.ElementAt(branchIndex); + } + + private WorkflowStepSequenceDeclaration ResolveSequenceForBranchPath(CanonicalWorkflowResumePointer pointer) + { + var sequence = ResolveEntryPointSequence(pointer); + foreach (var branchPointer in pointer.BranchPath) + { + var steps = sequence.Steps.ToArray(); + if (branchPointer.StepIndex < 0 || branchPointer.StepIndex >= steps.Length) + { + throw new InvalidOperationException( + $"Canonical workflow '{definition.WorkflowName}' cannot resolve branch step index '{branchPointer.StepIndex}' while locating nested sequence."); + } + + sequence = ResolveNestedSequence(steps[branchPointer.StepIndex], branchPointer); + } + + return sequence; + } + + private static WorkflowStepSequenceDeclaration ResolveNestedSequence( + WorkflowStepDeclaration step, + CanonicalWorkflowResumeBranchPointer pointer) + { + if (TryResolveFailureHandlerSequence(step, pointer, out var failureHandlerSequence)) + { + return failureHandlerSequence; + } + + return step switch + { + WorkflowDecisionStepDeclaration decision when pointer.BranchKind == WorkflowResumeBranchKind.True => + decision.WhenTrue, + WorkflowDecisionStepDeclaration decision when pointer.BranchKind is WorkflowResumeBranchKind.False or WorkflowResumeBranchKind.Else => + decision.WhenElse, + WorkflowForkStepDeclaration fork when pointer.BranchKind == WorkflowResumeBranchKind.Fork && pointer.BranchIndex.HasValue => + fork.Branches.ElementAt(pointer.BranchIndex.Value), + WorkflowRepeatStepDeclaration repeat when pointer.BranchKind == WorkflowResumeBranchKind.Repeat => + repeat.Body, + _ => throw new InvalidOperationException( + $"Canonical workflow cannot resolve nested branch '{pointer.BranchKind}' for step type '{step.GetType().Name}'."), + }; + } + + private static bool TryResolveFailureHandlerSequence( + WorkflowStepDeclaration step, + CanonicalWorkflowResumeBranchPointer pointer, + out WorkflowStepSequenceDeclaration sequence) + { + if (step is WorkflowTransportCallStepDeclaration transportStep) + { + if (pointer.BranchKind == WorkflowResumeBranchKind.Failure && transportStep.WhenFailure is not null) + { + sequence = transportStep.WhenFailure; + return true; + } + + if (pointer.BranchKind == WorkflowResumeBranchKind.Timeout && transportStep.WhenTimeout is not null) + { + sequence = transportStep.WhenTimeout; + return true; + } + } + + sequence = new WorkflowStepSequenceDeclaration(); + return false; + } + + private static CanonicalWorkflowResumePointer TrimForkResumePointer(CanonicalWorkflowResumePointer pointer) + { + if (pointer.BranchPath.Count == 0) + { + return pointer; + } + + return pointer with + { + BranchPath = pointer.BranchPath.Skip(1).ToArray(), + }; + } + + private static WorkflowExecutionTaskPlan BuildTaskPlan( + WorkflowTaskDeclaration task, + WorkflowCanonicalExecutionContext context, + IReadOnlyCollection runtimeRoles, + int? timeoutSeconds = null) + { + var payload = WorkflowCanonicalExpressionRuntime + .Evaluate(task.PayloadExpression, context.ToEvaluationContext()) + .AsWorkflowJsonDictionary(); + if (!payload.ContainsKey(WorkflowNamePayloadKey)) + { + payload[WorkflowNamePayloadKey] = context.WorkflowName.AsJsonElement(); + } + + if (!payload.ContainsKey(WorkflowVersionPayloadKey)) + { + payload[WorkflowVersionPayloadKey] = context.WorkflowVersion.AsJsonElement(); + } + + if (!payload.ContainsKey(WorkflowRuntimePayloadKeys.RuntimeTaskTokenPayloadKey)) + { + payload[WorkflowRuntimePayloadKeys.RuntimeTaskTokenPayloadKey] = + JsonSerializer.SerializeToElement($"canonical-task-{Guid.NewGuid():N}"); + } + + if (!payload.ContainsKey(WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey) + && TryReadProjectionWorkflowInstanceId(context.WorkflowState, out var projectionWorkflowInstanceId)) + { + payload[WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey] = + JsonSerializer.SerializeToElement(projectionWorkflowInstanceId); + } + + return new WorkflowExecutionTaskPlan + { + WorkflowName = context.WorkflowName, + WorkflowVersion = context.WorkflowVersion, + WorkflowRoles = context.WorkflowRoles, + TaskName = task.TaskName, + TaskType = task.TaskType, + Route = ConvertToRequiredString( + WorkflowCanonicalExpressionRuntime.Evaluate(task.RouteExpression, context.ToEvaluationContext()), + $"Workflow task '{task.TaskName}' route expression"), + TaskRoles = task.TaskRoles, + RuntimeRoles = runtimeRoles, + Payload = payload, + TimeoutSeconds = timeoutSeconds, + }; + } + + private static WorkflowTaskCompletionPlan BuildCompletionPlan( + Dictionary workflowState, + WorkflowExecutionTerminalResult terminalResult) + { + return new WorkflowTaskCompletionPlan + { + InstanceStatus = terminalResult.InstanceStatus, + BusinessReference = terminalResult.BusinessReference, + WorkflowState = workflowState, + NextTasks = terminalResult.NextTasks, + PendingSignals = terminalResult.PendingSignals, + Continuations = terminalResult.Continuations, + }; + } + + private static WorkflowExecutionTerminalResult BuildTimerWaitResult( + WorkflowCanonicalExecutionContext context, + WorkflowTimerStepDeclaration step, + CanonicalWorkflowResumePointer resumePointer, + IReadOnlyCollection continuations) + { + var delay = ConvertToTimeSpan( + WorkflowCanonicalExpressionRuntime.Evaluate( + step.DelayExpression, + context.ToEvaluationContext())); + if (delay < TimeSpan.Zero) + { + delay = TimeSpan.Zero; + } + + var waitingToken = $"timer-{step.StepName}-{Guid.NewGuid():N}"; + return new WorkflowExecutionTerminalResult( + WorkflowOpenStatus, + context.BusinessReference, + [], + [ + new WorkflowPendingSignalPlan + { + SignalType = WorkflowSignalTypes.TimerDue, + WaitingToken = waitingToken, + DueAtUtc = DateTime.UtcNow.Add(delay), + ResumeState = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [ResumePointerResumeStateKey] = JsonSerializer.SerializeToElement(resumePointer), + }, + }, + ], + continuations.ToArray()); + } + + private static string EnsureProjectionWorkflowInstanceId(Dictionary workflowState) + { + if (TryReadProjectionWorkflowInstanceId(workflowState, out var existingProjectionWorkflowInstanceId)) + { + return existingProjectionWorkflowInstanceId!; + } + + var projectionWorkflowInstanceId = $"swf-{Guid.NewGuid():N}"; + workflowState[ProjectionWorkflowInstanceIdStateKey] = JsonSerializer.SerializeToElement(projectionWorkflowInstanceId); + return projectionWorkflowInstanceId; + } + + private static bool TryReadProjectionWorkflowInstanceId( + IReadOnlyDictionary workflowState, + out string? projectionWorkflowInstanceId) + { + projectionWorkflowInstanceId = null; + if (!workflowState.TryGetValue(ProjectionWorkflowInstanceIdStateKey, out var projectionWorkflowInstanceIdElement) + || projectionWorkflowInstanceIdElement.ValueKind != JsonValueKind.String) + { + return false; + } + + projectionWorkflowInstanceId = projectionWorkflowInstanceIdElement.GetString(); + return !string.IsNullOrWhiteSpace(projectionWorkflowInstanceId); + } + + private static IReadOnlyCollection AttachProjectionWorkflowInstanceId( + IReadOnlyCollection taskPlans, + string projectionWorkflowInstanceId) + { + return taskPlans + .Select(taskPlan => + { + if (taskPlan.Payload.ContainsKey(WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey)) + { + return taskPlan; + } + + var payload = new Dictionary(taskPlan.Payload, StringComparer.OrdinalIgnoreCase) + { + [WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey] = + JsonSerializer.SerializeToElement(projectionWorkflowInstanceId), + }; + return taskPlan with { Payload = payload }; + }) + .ToArray(); + } + + private static WorkflowExecutionTerminalResult BuildExternalSignalWaitResult( + WorkflowCanonicalExecutionContext context, + WorkflowExternalSignalStepDeclaration step, + CanonicalWorkflowResumePointer resumePointer, + IReadOnlyCollection continuations) + { + var signalName = ConvertToRequiredString( + WorkflowCanonicalExpressionRuntime.Evaluate( + step.SignalNameExpression, + context.ToEvaluationContext()), + $"Workflow '{context.WorkflowName}' external signal step '{step.StepName}' signal name"); + var waitingToken = $"signal-{step.StepName}-{Guid.NewGuid():N}"; + var resumeState = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [ResumePointerResumeStateKey] = JsonSerializer.SerializeToElement(resumePointer), + [WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey] = JsonSerializer.SerializeToElement(signalName), + }; + + if (!string.IsNullOrWhiteSpace(step.ResultKey)) + { + resumeState[ResultKeyResumeStateKey] = JsonSerializer.SerializeToElement(step.ResultKey); + } + + return new WorkflowExecutionTerminalResult( + WorkflowOpenStatus, + context.BusinessReference, + [], + [ + new WorkflowPendingSignalPlan + { + SignalType = WorkflowSignalTypes.ExternalSignal, + WaitingToken = waitingToken, + AutoDispatch = false, + ResumeState = resumeState, + }, + ], + continuations.ToArray()); + } + + private static void ApplySetState( + WorkflowCanonicalExecutionContext context, + WorkflowSetStateStepDeclaration step) + { + var value = WorkflowCanonicalExpressionRuntime.Evaluate(step.ValueExpression, context.ToEvaluationContext()); + if (step.OnlyIfPresent && !HasValue(value)) + { + return; + } + + context.WorkflowState.Assign(step.StateKey, value); + } + + private static void ApplyRepeatIterationState( + WorkflowCanonicalExecutionContext context, + WorkflowRepeatStepDeclaration step, + int iteration) + { + if (!string.IsNullOrWhiteSpace(step.IterationStateKey)) + { + context.WorkflowState.Assign(step.IterationStateKey, iteration); + } + } + + private static bool ShouldContinueRepeat( + WorkflowCanonicalExecutionContext context, + WorkflowRepeatStepDeclaration step, + int iteration, + int maxIterations) + { + if (iteration >= maxIterations) + { + return false; + } + + if (step.ContinueWhileExpression is null) + { + return false; + } + + return WorkflowCanonicalExpressionRuntime.Evaluate( + step.ContinueWhileExpression, + context.ToEvaluationContext()) switch + { + bool boolean => boolean, + null => false, + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.True => true, + JsonElement jsonElement when jsonElement.ValueKind is JsonValueKind.False or JsonValueKind.Null or JsonValueKind.Undefined => false, + _ => throw new InvalidOperationException( + $"Workflow repeat expression for step '{step.StepName}' must evaluate to a boolean value."), + }; + } + + private static void ApplyCompletedSubWorkflowState( + WorkflowCanonicalExecutionContext context, + string? resultKey, + IReadOnlyDictionary childWorkflowState, + WorkflowBusinessReference? childBusinessReference) + { + var publicSubWorkflowState = StripInternalState(childWorkflowState); + MergeSubWorkflowState(context.WorkflowState, publicSubWorkflowState); + + if (!string.IsNullOrWhiteSpace(resultKey)) + { + context.WorkflowState.Assign(resultKey, publicSubWorkflowState); + } + + context.SetBusinessReference(childBusinessReference); + } + + private static void MergeSubWorkflowState( + Dictionary target, + IReadOnlyDictionary source) + { + foreach (var item in source) + { + target[item.Key] = item.Value.Clone(); + } + } + + private static void MergeForkBranchState( + Dictionary target, + IReadOnlyDictionary baseState, + IReadOnlyDictionary branchState) + { + foreach (var item in branchState) + { + if (baseState.TryGetValue(item.Key, out var baseValue) && JsonElementDeepEquals(baseValue, item.Value)) + { + continue; + } + + target[item.Key] = item.Value.Clone(); + } + } + + private static (CanonicalWorkflowForkFrame ForkFrame, CanonicalWorkflowForkBranchFrame BranchFrame) GetRequiredForkBranch( + IReadOnlyCollection forkFrames, + string coordinatorId, + int branchIndex) + { + var forkFrame = forkFrames.FirstOrDefault(x => + string.Equals(x.CoordinatorId, coordinatorId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Canonical workflow fork coordinator '{coordinatorId}' was not found."); + var branchFrame = forkFrame.Branches.FirstOrDefault(x => x.BranchIndex == branchIndex) + ?? throw new InvalidOperationException( + $"Canonical workflow fork coordinator '{coordinatorId}' does not contain branch '{branchIndex}'."); + return (forkFrame, branchFrame); + } + + private static CanonicalWorkflowForkBranchFrame BuildUpdatedForkBranchFrame( + CanonicalWorkflowForkBranchFrame existingBranch, + WorkflowCanonicalExecutionContext branchContext, + WorkflowExecutionTerminalResult? terminalResult, + string coordinatorId) + { + if (IsForkBranchStillOpen(terminalResult)) + { + var decoratedTasks = CanonicalWorkflowForkState.AttachTaskContext( + terminalResult!.NextTasks, + coordinatorId, + existingBranch.BranchIndex); + var decoratedSignals = CanonicalWorkflowForkState.AttachSignalContext( + terminalResult.PendingSignals, + coordinatorId, + existingBranch.BranchIndex); + + return new CanonicalWorkflowForkBranchFrame + { + BranchIndex = existingBranch.BranchIndex, + WorkflowState = CloneJsonDictionary(branchContext.WorkflowState), + BusinessReference = branchContext.BusinessReference, + ActiveTasks = CanonicalWorkflowForkState.CreateTaskFrames(decoratedTasks), + PendingSignals = decoratedSignals + .Select(ClonePendingSignalPlan) + .ToArray(), + }; + } + + return new CanonicalWorkflowForkBranchFrame + { + BranchIndex = existingBranch.BranchIndex, + WorkflowState = CloneJsonDictionary(branchContext.WorkflowState), + BusinessReference = branchContext.BusinessReference, + Completed = true, + }; + } + + private static CanonicalWorkflowForkBranchFrame CloneForkBranchFrame(CanonicalWorkflowForkBranchFrame branch) + { + return new CanonicalWorkflowForkBranchFrame + { + BranchIndex = branch.BranchIndex, + WorkflowState = CloneJsonDictionary(branch.WorkflowState), + BusinessReference = branch.BusinessReference, + Completed = branch.Completed, + ActiveTasks = branch.ActiveTasks + .Select(x => x with { }) + .ToArray(), + PendingSignals = branch.PendingSignals + .Select(ClonePendingSignalPlan) + .ToArray(), + }; + } + + private static void ReplaceForkFrame( + IList forkFrames, + CanonicalWorkflowForkFrame replacement) + { + for (var i = 0; i < forkFrames.Count; i++) + { + if (!string.Equals(forkFrames[i].CoordinatorId, replacement.CoordinatorId, StringComparison.Ordinal)) + { + continue; + } + + forkFrames[i] = replacement; + return; + } + + throw new InvalidOperationException( + $"Canonical workflow fork coordinator '{replacement.CoordinatorId}' was not found."); + } + + private static bool IsForkBranchStillOpen(WorkflowExecutionTerminalResult? terminalResult) + { + if (terminalResult is null) + { + return false; + } + + if (terminalResult.NextTasks.Count > 0 || terminalResult.PendingSignals.Count > 0) + { + return true; + } + + return string.Equals(terminalResult.InstanceStatus, WorkflowOpenStatus, StringComparison.OrdinalIgnoreCase); + } + + private static WorkflowPendingSignalPlan ClonePendingSignalPlan(WorkflowPendingSignalPlan signal) + { + return signal with + { + Payload = CloneJsonDictionary(signal.Payload), + ResumeState = CloneJsonDictionary(signal.ResumeState), + }; + } + + private static Dictionary StripInternalState( + IReadOnlyDictionary state) + { + var filtered = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in state) + { + if (string.Equals(item.Key, SubWorkflowFramesStateKey, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals(item.Key, ProjectionWorkflowInstanceIdStateKey, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals(item.Key, CanonicalWorkflowForkState.ForkFramesStateKey, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + filtered[item.Key] = item.Value.Clone(); + } + + return filtered; + } + + private static List ReadSubWorkflowFrames( + IReadOnlyDictionary workflowState) + { + if (!workflowState.TryGetValue(SubWorkflowFramesStateKey, out var value) + || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return []; + } + + return JsonSerializer.Deserialize>(value.GetRawText()) ?? []; + } + + private static void WriteSubWorkflowFrames( + Dictionary workflowState, + IReadOnlyCollection frames) + { + if (frames.Count == 0) + { + workflowState.Remove(SubWorkflowFramesStateKey); + return; + } + + workflowState[SubWorkflowFramesStateKey] = JsonSerializer.SerializeToElement(frames); + } + + private static void ReplaceWorkflowState( + Dictionary target, + IReadOnlyDictionary replacement) + { + target.Clear(); + foreach (var item in replacement) + { + target[item.Key] = item.Value.Clone(); + } + } + + private static bool HasFailureHandlingConfigured(WorkflowTransportCallStepDeclaration step) + { + return step.WhenFailure is not null && step.WhenFailure.Steps.Count > 0; + } + + private static bool HasTimeoutHandlingConfigured(WorkflowTransportCallStepDeclaration step) + { + return (step.WhenTimeout is not null && step.WhenTimeout.Steps.Count > 0) + || HasFailureHandlingConfigured(step); + } + + private static object? EvaluatePayload( + WorkflowExpressionDefinition? payloadExpression, + WorkflowCanonicalExecutionContext context) + { + return payloadExpression is null + ? null + : WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context.ToEvaluationContext()); + } + + private static StartWorkflowRequest BuildStartWorkflowRequest( + WorkflowWorkflowInvocationDeclaration invocationDeclaration, + WorkflowCanonicalExecutionContext context) + { + var workflowName = WorkflowCanonicalExpressionRuntime.Evaluate( + invocationDeclaration.WorkflowNameExpression, + context.ToEvaluationContext())?.ToString(); + if (string.IsNullOrWhiteSpace(workflowName)) + { + throw new InvalidOperationException("Workflow invocation name expression must evaluate to a workflow name."); + } + + var workflowVersion = invocationDeclaration.WorkflowVersionExpression is null + ? null + : WorkflowCanonicalExpressionRuntime.Evaluate( + invocationDeclaration.WorkflowVersionExpression, + context.ToEvaluationContext())?.ToString(); + var payload = invocationDeclaration.PayloadExpression is null + ? null + : WorkflowCanonicalExpressionRuntime.Evaluate( + invocationDeclaration.PayloadExpression, + context.ToEvaluationContext()); + var businessReference = invocationDeclaration.BusinessReference is null + ? null + : WorkflowCanonicalExpressionRuntime.EvaluateBusinessReference( + invocationDeclaration.BusinessReference, + context.ToEvaluationContext()); + + return new StartWorkflowRequest + { + WorkflowName = workflowName, + WorkflowVersion = workflowVersion, + Payload = payload.AsWorkflowObjectDictionary(), + BusinessReference = NormalizeBusinessReference(businessReference), + }; + } + + private static WorkflowBusinessReference? NormalizeBusinessReference(WorkflowBusinessReference? businessReference) + { + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + } + + private static IReadOnlyCollection ConvertToStringCollection(object? value) + { + return value switch + { + null => [], + string stringValue when !string.IsNullOrWhiteSpace(stringValue) => [stringValue], + IEnumerable values => values + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(), + IEnumerable values => values + .Select(x => x?.ToString()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Cast() + .ToArray(), + _ => throw new InvalidOperationException("Workflow runtime roles expression must evaluate to a string or string collection."), + }; + } + + private static TimeSpan ConvertToTimeSpan(object? value) + { + return value switch + { + TimeSpan timeSpan => timeSpan, + long milliseconds => TimeSpan.FromMilliseconds(milliseconds), + int milliseconds => TimeSpan.FromMilliseconds(milliseconds), + decimal milliseconds => TimeSpan.FromMilliseconds((double)milliseconds), + double milliseconds => TimeSpan.FromMilliseconds(milliseconds), + string stringValue when TimeSpan.TryParse(stringValue, out var timeSpan) => timeSpan, + string stringValue when long.TryParse(stringValue, out var milliseconds) => TimeSpan.FromMilliseconds(milliseconds), + _ => throw new InvalidOperationException("Workflow timer expression must evaluate to a TimeSpan, numeric milliseconds, or time-span string."), + }; + } + + private static string ConvertToRequiredString(object? value, string contextMessage) + { + var text = value?.ToString(); + if (!string.IsNullOrWhiteSpace(text)) + { + return text; + } + + throw new InvalidOperationException($"{contextMessage} must evaluate to a non-empty string value."); + } + + private static int ConvertToInt32(object? value, string contextMessage) + { + return value switch + { + int intValue => intValue, + long longValue when longValue is >= int.MinValue and <= int.MaxValue => (int)longValue, + decimal decimalValue when decimalValue is >= int.MinValue and <= int.MaxValue => decimal.ToInt32(decimalValue), + double doubleValue when doubleValue is >= int.MinValue and <= int.MaxValue => Convert.ToInt32(doubleValue), + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetInt32(out var parsed) => parsed, + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.String && int.TryParse(jsonElement.GetString(), out var parsed) => parsed, + string stringValue when int.TryParse(stringValue, out var parsed) => parsed, + _ => throw new InvalidOperationException($"{contextMessage} must evaluate to an integer value."), + }; + } + + private static bool ConvertToBoolean(object? value) + { + return value switch + { + null => false, + bool boolean => boolean, + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.True => true, + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.False => false, + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetInt64(out var integer) => integer != 0, + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.String && bool.TryParse(jsonElement.GetString(), out var parsed) => parsed, + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.String && long.TryParse(jsonElement.GetString(), out var numeric) => numeric != 0, + string text when bool.TryParse(text, out var parsed) => parsed, + string text when long.TryParse(text, out var numeric) => numeric != 0, + long integer => integer != 0, + int integer => integer != 0, + decimal number => number != 0m, + double number => Math.Abs(number) > double.Epsilon, + _ => true, + }; + } + + private static bool HasValue(object? value) + { + return value switch + { + null => false, + string text => !string.IsNullOrWhiteSpace(text), + JsonElement jsonElement when jsonElement.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined => false, + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.String => !string.IsNullOrWhiteSpace(jsonElement.GetString()), + _ => true, + }; + } + + private static bool JsonElementDeepEquals(JsonElement left, JsonElement right) + { + if (left.ValueKind != right.ValueKind) + { + return false; + } + + return left.GetRawText() == right.GetRawText(); + } + + private static bool TryReadResumeStateString( + IReadOnlyDictionary resumeState, + string key, + out string? value) + { + value = null; + if (!resumeState.TryGetValue(key, out var element)) + { + return false; + } + + value = element.ValueKind == JsonValueKind.String + ? element.GetString() + : element.ToString(); + return !string.IsNullOrWhiteSpace(value); + } + + private static bool TryReadResumeStateInt32( + IReadOnlyDictionary resumeState, + string key, + out int value) + { + value = 0; + if (!resumeState.TryGetValue(key, out var element)) + { + return false; + } + + if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out value)) + { + return true; + } + + if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out value)) + { + return true; + } + + return false; + } + + private static Dictionary CloneJsonDictionary(IReadOnlyDictionary values) + { + var clone = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in values) + { + clone[item.Key] = item.Value.Clone(); + } + + return clone; + } + + private static JsonElement SerializeToJsonElement(object? payload) + { + return payload is JsonElement jsonElement + ? jsonElement.Clone() + : JsonSerializer.SerializeToElement(payload); + } + + private static IReadOnlyDictionary SerializePayload(IDictionary payload) + { + return payload.ToDictionary( + x => x.Key, + x => JsonSerializer.SerializeToElement(x.Value), + StringComparer.OrdinalIgnoreCase); + } + + private static JsonElement DeserializeJsonPayload(string? jsonPayload) + { + if (string.IsNullOrWhiteSpace(jsonPayload)) + { + return JsonSerializer.SerializeToElement(new Dictionary()); + } + + return JsonSerializer.Deserialize(jsonPayload); + } + + private sealed class WorkflowCanonicalExecutionContext( + string workflowName, + string workflowVersion, + IReadOnlyCollection workflowRoles, + object? startRequest, + Dictionary workflowState, + Dictionary payload, + WorkflowBusinessReference? businessReference, + IWorkflowFunctionRuntime functionRuntime) + { + private readonly JsonElement? startRequestElement = startRequest is null ? null : startRequest.AsJsonElement(); + + public string WorkflowName { get; } = workflowName; + + public string WorkflowVersion { get; } = workflowVersion; + + public IReadOnlyCollection WorkflowRoles { get; } = workflowRoles; + + public Dictionary WorkflowState { get; } = workflowState; + + public Dictionary Payload { get; } = payload; + + public Dictionary ResultValues { get; } = new(StringComparer.OrdinalIgnoreCase); + + public WorkflowBusinessReference? BusinessReference { get; private set; } = businessReference; + + public WorkflowCanonicalEvaluationContext ToEvaluationContext() + { + return new WorkflowCanonicalEvaluationContext + { + Start = startRequestElement, + State = WorkflowState.AsJsonElement(), + Payload = Payload.AsJsonElement(), + Result = ResultValues.AsJsonElement(), + BusinessReference = BusinessReference, + FunctionRuntime = functionRuntime, + }; + } + + public void SetBusinessReference(WorkflowBusinessReference? businessReference) + { + BusinessReference = businessReference; + } + + public void SetResult(string key, JsonElement value) + { + ResultValues[key] = value.Clone(); + } + } + + private sealed record WorkflowExecutionTerminalResult( + string InstanceStatus, + WorkflowBusinessReference? BusinessReference, + IReadOnlyCollection NextTasks, + IReadOnlyCollection PendingSignals, + IReadOnlyCollection Continuations); + + private sealed record CanonicalWorkflowSubWorkflowFrame + { + public required string WorkflowName { get; init; } + public required string WorkflowVersion { get; init; } + public WorkflowBusinessReference? BusinessReference { get; init; } + public required Dictionary WorkflowState { get; init; } + public required CanonicalWorkflowResumePointer ResumePointer { get; init; } + public string? ResultKey { get; init; } + } + + private sealed record CanonicalWorkflowResumeBranchPointer + { + public required int StepIndex { get; init; } + public required WorkflowResumeBranchKind BranchKind { get; init; } + public int? BranchIndex { get; init; } + } + + private sealed record CanonicalWorkflowResumePointer + { + public required WorkflowResumeEntryPointKind EntryPointKind { get; init; } + public string? TaskName { get; init; } + public IReadOnlyCollection BranchPath { get; init; } = []; + public required int NextStepIndex { get; init; } + } + + private sealed record CanonicalWorkflowExecutionLocation + { + public required WorkflowResumeEntryPointKind EntryPointKind { get; init; } + public string? TaskName { get; init; } + public IReadOnlyCollection BranchPath { get; init; } = []; + + public static CanonicalWorkflowExecutionLocation InitialSequence() + { + return new CanonicalWorkflowExecutionLocation + { + EntryPointKind = WorkflowResumeEntryPointKind.InitialSequence, + }; + } + + public static CanonicalWorkflowExecutionLocation TaskOnComplete(string taskName) + { + return new CanonicalWorkflowExecutionLocation + { + EntryPointKind = WorkflowResumeEntryPointKind.TaskOnComplete, + TaskName = taskName, + }; + } + + public CanonicalWorkflowExecutionLocation EnterBranch( + int stepIndex, + WorkflowResumeBranchKind branchKind, + int? branchIndex = null) + { + var path = new List(BranchPath.Count + 1); + path.AddRange(BranchPath); + path.Add(new CanonicalWorkflowResumeBranchPointer + { + StepIndex = stepIndex, + BranchKind = branchKind, + BranchIndex = branchIndex, + }); + + return new CanonicalWorkflowExecutionLocation + { + EntryPointKind = EntryPointKind, + TaskName = TaskName, + BranchPath = path, + }; + } + + public CanonicalWorkflowResumePointer CreateResumePointer(int nextStepIndex) + { + return new CanonicalWorkflowResumePointer + { + EntryPointKind = EntryPointKind, + TaskName = TaskName, + BranchPath = BranchPath, + NextStepIndex = nextStepIndex, + }; + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/CanonicalWorkflowForkState.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/CanonicalWorkflowForkState.cs new file mode 100644 index 000000000..dd6f20bf7 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/CanonicalWorkflowForkState.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Execution; + +internal static class CanonicalWorkflowForkState +{ + public const string ForkFramesStateKey = "__serdica.forkFrames"; + public const string ForkCoordinatorIdPayloadKey = "__serdica.forkCoordinatorId"; + public const string ForkBranchIndexPayloadKey = "__serdica.forkBranchIndex"; + + public static List ReadFrames(IReadOnlyDictionary workflowState) + { + if (!workflowState.TryGetValue(ForkFramesStateKey, out var value) + || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return []; + } + + return JsonSerializer.Deserialize>(value.GetRawText()) ?? []; + } + + public static void WriteFrames( + Dictionary workflowState, + IReadOnlyCollection frames) + { + if (frames.Count == 0) + { + workflowState.Remove(ForkFramesStateKey); + return; + } + + workflowState[ForkFramesStateKey] = JsonSerializer.SerializeToElement(frames); + } + + public static IReadOnlyCollection CollectPendingSignals( + IReadOnlyDictionary workflowState) + { + var results = new List(); + foreach (var frame in ReadFrames(workflowState)) + { + foreach (var branch in frame.Branches.Where(x => !x.Completed)) + { + results.AddRange(branch.PendingSignals.Select(ClonePendingSignal)); + } + } + + return results; + } + + public static int CountOutstandingTasks(IReadOnlyDictionary workflowState) + { + return ReadFrames(workflowState) + .SelectMany(x => x.Branches) + .Where(x => !x.Completed) + .Sum(x => x.ActiveTasks.Count); + } + + public static bool TryReadTaskContext( + IDictionary payload, + out string? coordinatorId, + out int branchIndex) + { + coordinatorId = ReadString(payload, ForkCoordinatorIdPayloadKey); + branchIndex = ReadInt32(payload, ForkBranchIndexPayloadKey); + return !string.IsNullOrWhiteSpace(coordinatorId) && branchIndex >= 0; + } + + public static IReadOnlyDictionary AttachSignalContext( + IReadOnlyDictionary resumeState, + string coordinatorId, + int branchIndex) + { + var payload = CloneJsonDictionary(resumeState); + payload[ForkCoordinatorIdPayloadKey] = JsonSerializer.SerializeToElement(coordinatorId); + payload[ForkBranchIndexPayloadKey] = JsonSerializer.SerializeToElement(branchIndex); + return payload; + } + + public static IReadOnlyCollection AttachSignalContext( + IReadOnlyCollection pendingSignals, + string coordinatorId, + int branchIndex) + { + return pendingSignals + .Select(x => x with + { + Payload = CloneJsonDictionary(x.Payload), + ResumeState = AttachSignalContext(x.ResumeState, coordinatorId, branchIndex), + }) + .ToArray(); + } + + public static IReadOnlyCollection AttachTaskContext( + IReadOnlyCollection tasks, + string coordinatorId, + int branchIndex) + { + return tasks + .Select(x => + { + var payload = CloneJsonDictionary(x.Payload); + payload[ForkCoordinatorIdPayloadKey] = JsonSerializer.SerializeToElement(coordinatorId); + payload[ForkBranchIndexPayloadKey] = JsonSerializer.SerializeToElement(branchIndex); + return x with { Payload = payload }; + }) + .ToArray(); + } + + public static IReadOnlyCollection CreateTaskFrames( + IReadOnlyCollection tasks) + { + return tasks + .Select(x => new CanonicalWorkflowForkBranchTaskFrame + { + TaskName = x.TaskName, + RuntimeTaskToken = TryReadTaskToken(x.Payload), + }) + .ToArray(); + } + + public static WorkflowBusinessReference? ResolveBusinessReference( + CanonicalWorkflowForkFrame frame) + { + var businessReference = frame.BaseBusinessReference; + foreach (var branch in frame.Branches.OrderBy(x => x.BranchIndex)) + { + if (branch.BusinessReference is not null) + { + businessReference = branch.BusinessReference; + } + } + + return businessReference; + } + + private static string? TryReadTaskToken(IReadOnlyDictionary payload) + { + if (!payload.TryGetValue(WorkflowRuntimePayloadKeys.RuntimeTaskTokenPayloadKey, out var element)) + { + return null; + } + + return element.ValueKind == JsonValueKind.String + ? element.GetString() + : element.ToString(); + } + + private static Dictionary CloneJsonDictionary(IReadOnlyDictionary values) + { + var clone = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in values) + { + clone[item.Key] = item.Value.Clone(); + } + + return clone; + } + + private static WorkflowPendingSignalPlan ClonePendingSignal(WorkflowPendingSignalPlan signal) + { + return signal with + { + Payload = CloneJsonDictionary(signal.Payload), + ResumeState = CloneJsonDictionary(signal.ResumeState), + }; + } + + private static string? ReadString(IDictionary payload, string key) + { + if (!payload.TryGetValue(key, out var value) || value is null) + { + return null; + } + + return value switch + { + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.String => jsonElement.GetString(), + JsonElement jsonElement => jsonElement.ToString(), + _ => value.ToString(), + }; + } + + private static int ReadInt32(IDictionary payload, string key) + { + if (!payload.TryGetValue(key, out var value) || value is null) + { + return -1; + } + + return value switch + { + int intValue => intValue, + long longValue when longValue is >= int.MinValue and <= int.MaxValue => (int)longValue, + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetInt32(out var intValue) => intValue, + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.String && int.TryParse(jsonElement.GetString(), out var parsed) => parsed, + string text when int.TryParse(text, out var parsed) => parsed, + _ => -1, + }; + } +} + +internal sealed record CanonicalWorkflowForkFrame +{ + public required string CoordinatorId { get; init; } + + public required JsonElement ResumePointer { get; init; } + + public required Dictionary BaseState { get; init; } + + public WorkflowBusinessReference? BaseBusinessReference { get; init; } + + public IReadOnlyCollection Branches { get; init; } = []; +} + +internal sealed record CanonicalWorkflowForkBranchFrame +{ + public required int BranchIndex { get; init; } + + public required Dictionary WorkflowState { get; init; } + + public WorkflowBusinessReference? BusinessReference { get; init; } + + public bool Completed { get; init; } + + public IReadOnlyCollection ActiveTasks { get; init; } = []; + + public IReadOnlyCollection PendingSignals { get; init; } = []; +} + +internal sealed record CanonicalWorkflowForkBranchTaskFrame +{ + public required string TaskName { get; init; } + + public string? RuntimeTaskToken { get; init; } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/ConfiguredWorkflowRuntimeOrchestrator.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/ConfiguredWorkflowRuntimeOrchestrator.cs new file mode 100644 index 000000000..1fb1cd709 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/ConfiguredWorkflowRuntimeOrchestrator.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; +using StellaOps.Workflow.Engine.Hosting; + +using Microsoft.Extensions.Options; + +namespace StellaOps.Workflow.Engine.Execution; + +public sealed class ConfiguredWorkflowRuntimeOrchestrator( + IEnumerable runtimeProviders, + IOptions runtimeOptions) : IWorkflowRuntimeOrchestrator +{ + private readonly IReadOnlyDictionary providersByName = runtimeProviders + .GroupBy(x => x.ProviderName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.Last(), StringComparer.OrdinalIgnoreCase); + + public Task StartAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + StartWorkflowRequest request, + object startRequest, + CancellationToken cancellationToken = default) + { + var provider = GetConfiguredProvider(); + return provider.StartAsync( + registration, + definition, + businessReference, + request, + startRequest, + cancellationToken); + } + + public Task CompleteAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default) + { + var provider = GetConfiguredProvider(); + return provider.CompleteAsync( + registration, + definition, + context, + cancellationToken); + } + + public Task ResumeAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowSignalExecutionContext context, + CancellationToken cancellationToken = default) + { + var provider = GetProvider(context.RuntimeState.RuntimeProvider); + return provider.ResumeAsync( + registration, + definition, + context, + cancellationToken); + } + + private IWorkflowRuntimeProvider GetConfiguredProvider() + { + var options = runtimeOptions.Value; + var providerName = string.IsNullOrWhiteSpace(options.DefaultProvider) + ? WorkflowRuntimeProviderNames.InProcess + : options.DefaultProvider; + + return GetProvider(providerName); + } + + private IWorkflowRuntimeProvider GetProvider(string? providerName) + { + var options = runtimeOptions.Value; + providerName = string.IsNullOrWhiteSpace(providerName) + ? WorkflowRuntimeProviderNames.InProcess + : providerName; + + if (options.EnabledProviders.Count > 0 + && !options.EnabledProviders.Contains(providerName, StringComparer.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Workflow runtime provider '{providerName}' is not enabled."); + } + + if (!providersByName.TryGetValue(providerName, out var provider)) + { + throw new InvalidOperationException( + $"Workflow runtime provider '{providerName}' is not registered."); + } + + return provider; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowEngineRuntimeSnapshot.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowEngineRuntimeSnapshot.cs new file mode 100644 index 000000000..881931483 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowEngineRuntimeSnapshot.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Execution; + +internal sealed record WorkflowEngineRuntimeSnapshot +{ + public long Version { get; init; } + public string? Status { get; init; } + public WorkflowBusinessReference? BusinessReference { get; init; } + public required IReadOnlyDictionary WorkflowState { get; init; } + public IReadOnlyCollection Waitings { get; init; } = []; + public WorkflowEngineWaitingSnapshot? Waiting { get; init; } +} + +internal sealed record WorkflowEngineWaitingSnapshot +{ + public string? Kind { get; init; } + public string? SignalType { get; init; } + public string? Token { get; init; } + public DateTime? UntilUtc { get; init; } + public IReadOnlyDictionary ResumeState { get; init; } = new Dictionary(); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowEngineRuntimeSnapshotBuilder.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowEngineRuntimeSnapshotBuilder.cs new file mode 100644 index 000000000..555e124b3 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowEngineRuntimeSnapshotBuilder.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Execution; + +internal static class WorkflowEngineRuntimeSnapshotBuilder +{ + private const int EngineSchemaVersion = 1; + + public static Dictionary Build( + string instanceStatus, + WorkflowBusinessReference? businessReference, + IReadOnlyDictionary workflowState, + IReadOnlyCollection tasks, + IReadOnlyCollection pendingSignals, + long version) + { + var firstTask = tasks.FirstOrDefault(); + var activeTaskToken = GetRuntimeTaskToken(firstTask); + var outstandingSignals = CollectOutstandingSignals(workflowState, pendingSignals); + var firstSignal = outstandingSignals.FirstOrDefault(); + var openTaskCount = Math.Max(tasks.Count, CanonicalWorkflowForkState.CountOutstandingTasks(workflowState)); + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["engineSchemaVersion"] = EngineSchemaVersion, + ["version"] = version, + ["status"] = instanceStatus, + ["businessReference"] = businessReference?.ToObjectDictionary(), + ["workflowState"] = ConvertToObjectDictionary(workflowState), + ["activeTask"] = firstTask is null + ? null + : new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["taskName"] = firstTask.TaskName, + ["taskType"] = firstTask.TaskType, + ["route"] = firstTask.Route, + ["token"] = activeTaskToken, + }, + ["waiting"] = firstTask is null + ? BuildSignalWaitingState(firstSignal) + : new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["kind"] = "TaskCompletion", + ["token"] = activeTaskToken, + ["untilUtc"] = null, + }, + ["waitings"] = outstandingSignals + .Select(BuildSignalWaitingState) + .Cast() + .ToArray(), + ["openTaskCount"] = openTaskCount, + ["pendingSignalCount"] = outstandingSignals.Count, + }; + } + + public static long ReadVersion(string? stateJson) + { + if (string.IsNullOrWhiteSpace(stateJson)) + { + return 0; + } + + try + { + using var document = JsonDocument.Parse(stateJson); + if (document.RootElement.TryGetProperty("version", out var versionElement) + && versionElement.TryGetInt64(out var version)) + { + return version; + } + } + catch (JsonException) + { + return 0; + } + + return 0; + } + + private static string? GetRuntimeTaskToken(WorkflowExecutionTaskPlan? task) + { + if (task is null + || !task.Payload.TryGetValue(WorkflowRuntimePayloadKeys.RuntimeTaskTokenPayloadKey, out var value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.String + ? value.GetString() + : value.ToString(); + } + + private static Dictionary ConvertToObjectDictionary( + IReadOnlyDictionary values) + { + return values.ToDictionary( + x => x.Key, + x => ConvertJsonElementToObject(x.Value), + StringComparer.OrdinalIgnoreCase); + } + + private static object? ConvertJsonElementToObject(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Object => JsonSerializer.Deserialize>(element.GetRawText()), + JsonValueKind.Array => JsonSerializer.Deserialize(element.GetRawText()), + JsonValueKind.String => element.GetString(), + JsonValueKind.Number when element.TryGetInt64(out var longValue) => longValue, + JsonValueKind.Number when element.TryGetDecimal(out var decimalValue) => decimalValue, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null or JsonValueKind.Undefined => null, + _ => element.ToString(), + }; + } + + private static IReadOnlyCollection CollectOutstandingSignals( + IReadOnlyDictionary workflowState, + IReadOnlyCollection pendingSignals) + { + var results = new List(); + var seenTokens = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var signal in pendingSignals) + { + results.Add(signal); + seenTokens.Add(signal.WaitingToken); + } + + foreach (var signal in CanonicalWorkflowForkState.CollectPendingSignals(workflowState)) + { + if (!seenTokens.Add(signal.WaitingToken)) + { + continue; + } + + results.Add(signal); + } + + return results; + } + + private static Dictionary? BuildSignalWaitingState(WorkflowPendingSignalPlan? signal) + { + if (signal is null) + { + return null; + } + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["kind"] = "Signal", + ["signalType"] = signal.SignalType, + ["token"] = signal.WaitingToken, + ["untilUtc"] = signal.DueAtUtc, + ["resumeState"] = ConvertToObjectDictionary(signal.ResumeState), + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowEngineRuntimeSnapshotParser.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowEngineRuntimeSnapshotParser.cs new file mode 100644 index 000000000..c7db6e12b --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowEngineRuntimeSnapshotParser.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Execution; + +internal static class WorkflowEngineRuntimeSnapshotParser +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + public static WorkflowEngineRuntimeSnapshot Parse(string? stateJson) + { + if (string.IsNullOrWhiteSpace(stateJson)) + { + return new WorkflowEngineRuntimeSnapshot + { + WorkflowState = new Dictionary(StringComparer.OrdinalIgnoreCase), + }; + } + + using var document = JsonDocument.Parse(stateJson); + var root = document.RootElement; + + return new WorkflowEngineRuntimeSnapshot + { + Version = ReadVersion(root), + Status = TryReadString(root, "status"), + BusinessReference = ReadBusinessReference(root), + WorkflowState = ReadJsonDictionary(root, "workflowState"), + Waitings = ReadWaitings(root), + Waiting = ReadWaiting(root), + }; + } + + private static long ReadVersion(JsonElement root) + { + return root.TryGetProperty("version", out var versionElement) + && versionElement.TryGetInt64(out var version) + ? version + : 0; + } + + private static WorkflowBusinessReference? ReadBusinessReference(JsonElement root) + { + if (!root.TryGetProperty("businessReference", out var businessReferenceElement) + || businessReferenceElement.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return null; + } + + return businessReferenceElement.Deserialize(SerializerOptions); + } + + private static WorkflowEngineWaitingSnapshot? ReadWaiting(JsonElement root) + { + var waitings = ReadWaitings(root); + if (waitings.Count > 0) + { + return waitings.First(); + } + + if (!root.TryGetProperty("waiting", out var waitingElement) + || waitingElement.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return null; + } + + return ReadWaitingSnapshot(waitingElement); + } + + private static IReadOnlyCollection ReadWaitings(JsonElement root) + { + if (root.TryGetProperty("waitings", out var waitingsElement) + && waitingsElement.ValueKind == JsonValueKind.Array) + { + var waitings = new List(); + foreach (var item in waitingsElement.EnumerateArray()) + { + waitings.Add(ReadWaitingSnapshot(item)); + } + + return waitings; + } + + if (!root.TryGetProperty("waiting", out var waitingElement) + || waitingElement.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return []; + } + + return [ReadWaitingSnapshot(waitingElement)]; + } + + private static WorkflowEngineWaitingSnapshot ReadWaitingSnapshot(JsonElement waitingElement) + { + return new WorkflowEngineWaitingSnapshot + { + Kind = TryReadString(waitingElement, "kind"), + SignalType = TryReadString(waitingElement, "signalType"), + Token = TryReadString(waitingElement, "token"), + UntilUtc = ReadNullableDateTime(waitingElement, "untilUtc"), + ResumeState = ReadJsonDictionary(waitingElement, "resumeState"), + }; + } + + private static IReadOnlyDictionary ReadJsonDictionary(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out var property) + || property.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return JsonSerializer.Deserialize>(property.GetRawText(), SerializerOptions) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private static DateTime? ReadNullableDateTime(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out var property) + || property.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return null; + } + + return property.ValueKind == JsonValueKind.String && property.TryGetDateTime(out var value) + ? value + : null; + } + + private static string? TryReadString(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out var property) + || property.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return null; + } + + return property.ValueKind == JsonValueKind.String + ? property.GetString() + : property.ToString(); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowSignalResumeState.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowSignalResumeState.cs new file mode 100644 index 000000000..14853c853 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Execution/WorkflowSignalResumeState.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Text.Json; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Execution; + +internal static class WorkflowSignalResumeState +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + public const string ContinuationsKey = "continuations"; + + public static void WriteContinuations( + IDictionary target, + IReadOnlyCollection continuations) + { + if (continuations.Count == 0) + { + return; + } + + target[ContinuationsKey] = JsonSerializer.SerializeToElement(continuations, SerializerOptions); + } + + public static IReadOnlyCollection ReadContinuations( + IReadOnlyDictionary source) + { + if (!source.TryGetValue(ContinuationsKey, out var element) + || element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return []; + } + + return JsonSerializer.Deserialize(element.GetRawText(), SerializerOptions) + ?? []; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Hosting/WorkflowAqOptions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Hosting/WorkflowAqOptions.cs new file mode 100644 index 000000000..197da3fa4 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Hosting/WorkflowAqOptions.cs @@ -0,0 +1,18 @@ +// TODO: This options class is also defined in StellaOps.Workflow.Signaling.OracleAq. +// The HostedServices (WorkflowSignalPumpHostedService, WorkflowSignalPumpWorker) depend on +// ConsumerName and MaxDeliveryAttempts from this class. Consider consolidating or +// extracting a shared signal pump options class that is backend-agnostic. +namespace StellaOps.Workflow.Engine.Hosting; + +public sealed class WorkflowAqOptions +{ + public const string SectionName = "WorkflowAq"; + + public string QueueOwner { get; set; } = string.Empty; + public string SignalQueueName { get; set; } = "WF_SIGNAL_Q"; + public string ScheduleQueueName { get; set; } = "WF_SCHEDULE_Q"; + public string DeadLetterQueueName { get; set; } = "WF_DLQ_Q"; + public string ConsumerName { get; set; } = "WORKFLOW_SERVICE"; + public int BlockingDequeueSeconds { get; set; } = 30; + public int MaxDeliveryAttempts { get; set; } = 10; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Hosting/WorkflowEngineOptions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Hosting/WorkflowEngineOptions.cs new file mode 100644 index 000000000..21788eb5c --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Hosting/WorkflowEngineOptions.cs @@ -0,0 +1,19 @@ +using System; + +namespace StellaOps.Workflow.Engine.Hosting; + +public sealed class WorkflowEngineOptions +{ + public const string SectionName = "WorkflowEngine"; + + public string NodeId { get; set; } = Environment.MachineName; + public int MaxConcurrentExecutions { get; set; } = Environment.ProcessorCount; + public int MaxConcurrentSignalHandlers { get; set; } = Environment.ProcessorCount; + /// + /// Maximum time in seconds for a single workflow execution operation (start, complete, resume). + /// Null means no engine-wide timeout (recommended for long-running business processes). + /// Default: null (disabled). + /// + public int? ExecutionTimeoutSeconds { get; set; } + public int GracefulShutdownTimeoutSeconds { get; set; } = 30; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Hosting/WorkflowRuntimeOptions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Hosting/WorkflowRuntimeOptions.cs new file mode 100644 index 000000000..0971bc84e --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Hosting/WorkflowRuntimeOptions.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Hosting; + +public sealed class WorkflowRuntimeOptions +{ + public const string SectionName = "WorkflowRuntime"; + + public string DefaultProvider { get; set; } = WorkflowRuntimeProviderNames.InProcess; + public List EnabledProviders { get; set; } = []; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Rendering/WorkflowRenderGraphCompiler.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Rendering/WorkflowRenderGraphCompiler.cs new file mode 100644 index 000000000..7b9e4b7c9 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Rendering/WorkflowRenderGraphCompiler.cs @@ -0,0 +1,804 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Rendering; + +public sealed class WorkflowRenderGraphCompiler : IWorkflowRenderGraphCompiler +{ + public WorkflowRenderGraph Compile(WorkflowRuntimeDefinition definition) + { + ArgumentNullException.ThrowIfNull(definition); + + return definition.CanonicalDefinition is null + ? CompileDescriptorOnly(definition) + : CompileCanonical(definition, definition.CanonicalDefinition); + } + + private static WorkflowRenderGraph CompileDescriptorOnly(WorkflowRuntimeDefinition definition) + { + var nodes = new List(); + var edges = new List(); + + nodes.Add(CreateNode("start", "Start", "Start", "start", semanticType: "Start", semanticKey: "start", width: 264, height: 132)); + var previousNodeId = "start"; + + foreach (var task in definition.Descriptor.Tasks.Select((task, index) => new { task, index })) + { + var nodeId = $"task/{task.index + 1}"; + nodes.Add(CreateNode( + nodeId, + task.task.TaskName, + "HumanTask", + "task", + semanticType: "Task", + semanticKey: task.task.TaskName, + route: task.task.Route, + taskType: task.task.TaskType)); + edges.Add(CreateEdge(previousNodeId, nodeId)); + previousNodeId = nodeId; + } + + nodes.Add(CreateNode("end", "End", "End", "end", semanticType: "End", semanticKey: "end", width: 264, height: 132)); + edges.Add(CreateEdge(previousNodeId, "end")); + + return new WorkflowRenderGraph + { + Id = $"{definition.Descriptor.WorkflowName}:{definition.Descriptor.WorkflowVersion}", + Nodes = nodes, + Edges = edges, + }; + } + + private static WorkflowRenderGraph CompileCanonical( + WorkflowRuntimeDefinition definition, + WorkflowCanonicalDefinition canonicalDefinition) + { + var compiler = new GraphBuilder(definition, canonicalDefinition); + compiler.Compile(); + return compiler.Build(); + } + + private static WorkflowRenderNode CreateNode( + string id, + string label, + string kind, + string iconKey, + string? semanticType = null, + string? semanticKey = null, + string? route = null, + string? taskType = null, + double width = 208, + double height = 88) + { + return new WorkflowRenderNode + { + Id = id, + Label = label, + Kind = kind, + IconKey = iconKey, + SemanticType = semanticType, + SemanticKey = semanticKey, + Route = route, + TaskType = taskType, + Width = width, + Height = height, + }; + } + + private static WorkflowRenderEdge CreateEdge( + string sourceNodeId, + string targetNodeId, + string? kind = null, + string? label = null) + { + return new WorkflowRenderEdge + { + Id = string.Empty, + SourceNodeId = sourceNodeId, + TargetNodeId = targetNodeId, + Kind = kind, + Label = label, + }; + } + + private sealed class GraphBuilder( + WorkflowRuntimeDefinition definition, + WorkflowCanonicalDefinition canonicalDefinition) + { + private readonly Dictionary tasksByName = definition.Descriptor.Tasks + .GroupBy(x => x.TaskName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.Last(), StringComparer.OrdinalIgnoreCase); + + private readonly List nodes = []; + private readonly List edges = []; + private int edgeCounter; + + public void Compile() + { + nodes.Add(CreateNode("start", "Start", "Start", "start", semanticType: "Start", semanticKey: "start", width: 264, height: 132)); + nodes.Add(CreateNode("end", "End", "End", "end", semanticType: "End", semanticKey: "end", width: 264, height: 132)); + + var exits = CompileSequence( + canonicalDefinition.Start.InitialSequence, + ["start"], + "start", + initialEdgeLabel: null); + + ConnectMany(exits, "end"); + } + + public WorkflowRenderGraph Build() + { + var simplified = SimplifyRenderGraph(nodes, edges); + return new WorkflowRenderGraph + { + Id = $"{canonicalDefinition.WorkflowName}:{canonicalDefinition.WorkflowVersion}", + Nodes = simplified.Nodes, + Edges = simplified.Edges + .Select((edge, index) => edge with { Id = $"edge/{index + 1}" }) + .ToArray(), + }; + } + + private IReadOnlyCollection CompileSequence( + WorkflowStepSequenceDeclaration sequence, + IReadOnlyCollection incomingNodeIds, + string path, + string? initialEdgeLabel) + { + var currentIncoming = incomingNodeIds.ToArray(); + var nextEdgeLabel = initialEdgeLabel; + + foreach (var item in sequence.Steps.Select((step, index) => new { step, index })) + { + currentIncoming = CompileStep( + item.step, + currentIncoming, + $"{path}/{item.index + 1}", + nextEdgeLabel) + .ToArray(); + nextEdgeLabel = null; + } + + return currentIncoming; + } + + private IReadOnlyCollection CompileStep( + WorkflowStepDeclaration step, + IReadOnlyCollection incomingNodeIds, + string path, + string? incomingEdgeLabel) + { + return step switch + { + WorkflowSetStateStepDeclaration setState => CompileLeaf( + path, + $"Set {setState.StateKey}", + "SetState", + "state", + incomingNodeIds, + incomingEdgeLabel, + semanticType: "State", + semanticKey: setState.StateKey), + WorkflowAssignBusinessReferenceStepDeclaration => CompileLeaf( + path, + "Assign Business Reference", + "BusinessReference", + "business-reference", + incomingNodeIds, + incomingEdgeLabel, + semanticType: "BusinessReference"), + WorkflowTransportCallStepDeclaration transport => CompileTransportCall( + transport, + incomingNodeIds, + path, + incomingEdgeLabel), + WorkflowDecisionStepDeclaration decision => CompileDecision( + decision, + incomingNodeIds, + path, + incomingEdgeLabel), + WorkflowActivateTaskStepDeclaration activateTask => CompileTask( + activateTask, + incomingNodeIds, + path, + incomingEdgeLabel), + WorkflowContinueWithWorkflowStepDeclaration continuation => CompileLeaf( + path, + continuation.StepName, + "ContinueWorkflow", + "continue", + incomingNodeIds, + incomingEdgeLabel, + semanticType: "ContinueWorkflow", + semanticKey: continuation.StepName), + WorkflowSubWorkflowStepDeclaration subWorkflow => CompileLeaf( + path, + subWorkflow.StepName, + "SubWorkflow", + "subworkflow", + incomingNodeIds, + incomingEdgeLabel, + semanticType: "SubWorkflow", + semanticKey: subWorkflow.StepName), + WorkflowRepeatStepDeclaration repeat => CompileRepeat( + repeat, + incomingNodeIds, + path, + incomingEdgeLabel), + WorkflowTimerStepDeclaration timer => CompileLeaf( + path, + timer.StepName, + "Timer", + "timer", + incomingNodeIds, + incomingEdgeLabel, + semanticType: "Timer", + semanticKey: timer.StepName), + WorkflowExternalSignalStepDeclaration signal => CompileLeaf( + path, + signal.StepName, + "Signal", + "signal", + incomingNodeIds, + incomingEdgeLabel, + semanticType: "Signal", + semanticKey: signal.StepName), + WorkflowForkStepDeclaration fork => CompileFork( + fork, + incomingNodeIds, + path, + incomingEdgeLabel), + WorkflowCompleteStepDeclaration => CompileComplete( + incomingNodeIds, + path, + incomingEdgeLabel), + _ => throw new NotSupportedException( + $"Workflow render graph compiler does not support step type '{step.GetType().FullName}'."), + }; + } + + private IReadOnlyCollection CompileLeaf( + string nodeId, + string label, + string kind, + string iconKey, + IReadOnlyCollection incomingNodeIds, + string? incomingEdgeLabel, + string? semanticType = null, + string? semanticKey = null, + string? route = null, + string? taskType = null) + { + nodes.Add(CreateNode( + nodeId, + label, + kind, + iconKey, + semanticType, + semanticKey, + route, + taskType)); + ConnectMany(incomingNodeIds, nodeId, label: incomingEdgeLabel); + return [nodeId]; + } + + private IReadOnlyCollection CompileTask( + WorkflowActivateTaskStepDeclaration activateTask, + IReadOnlyCollection incomingNodeIds, + string path, + string? incomingEdgeLabel) + { + tasksByName.TryGetValue(activateTask.TaskName, out var taskDescriptor); + + return CompileLeaf( + path, + activateTask.TaskName, + "HumanTask", + "task", + incomingNodeIds, + incomingEdgeLabel, + semanticType: "Task", + semanticKey: activateTask.TaskName, + route: taskDescriptor?.Route, + taskType: taskDescriptor?.TaskType); + } + + private IReadOnlyCollection CompileTransportCall( + WorkflowTransportCallStepDeclaration transport, + IReadOnlyCollection incomingNodeIds, + string path, + string? incomingEdgeLabel) + { + var nodeId = path; + nodes.Add(CreateNode( + nodeId, + transport.StepName, + "TransportCall", + "transport", + semanticType: "TransportCall", + semanticKey: transport.StepName)); + ConnectMany(incomingNodeIds, nodeId, label: incomingEdgeLabel); + + var exits = new List { nodeId }; + var hasSharedHandledBranch = AreEquivalentHandledBranches(transport.WhenFailure, transport.WhenTimeout); + if (hasSharedHandledBranch) + { + exits.AddRange(CompileSequence( + transport.WhenFailure!, + [nodeId], + $"{path}/handled", + "on failure / timeout")); + } + else + { + if (transport.WhenFailure is not null && transport.WhenFailure.Steps.Count > 0) + { + exits.AddRange(CompileSequence( + transport.WhenFailure, + [nodeId], + $"{path}/failure", + "on failure")); + } + + if (transport.WhenTimeout is not null && transport.WhenTimeout.Steps.Count > 0) + { + exits.AddRange(CompileSequence( + transport.WhenTimeout, + [nodeId], + $"{path}/timeout", + "on timeout")); + } + } + + return exits; + } + + private IReadOnlyCollection CompileDecision( + WorkflowDecisionStepDeclaration decision, + IReadOnlyCollection incomingNodeIds, + string path, + string? incomingEdgeLabel) + { + var nodeId = path; + nodes.Add(CreateNode( + nodeId, + decision.DecisionName, + "Decision", + "decision", + semanticType: "Decision", + semanticKey: decision.DecisionName, + width: 188, + height: 132)); + ConnectMany(incomingNodeIds, nodeId, label: incomingEdgeLabel); + + var exits = new List(); + if (decision.WhenTrue.Steps.Count > 0) + { + exits.AddRange(CompileSequence( + decision.WhenTrue, + [nodeId], + $"{path}/true", + FormatDecisionTrueLabel(decision.ConditionExpression))); + } + else + { + exits.Add(nodeId); + } + + if (decision.WhenElse.Steps.Count > 0) + { + exits.AddRange(CompileSequence( + decision.WhenElse, + [nodeId], + $"{path}/else", + "otherwise")); + } + else + { + exits.Add(nodeId); + } + + return exits.Distinct(StringComparer.Ordinal).ToArray(); + } + + private IReadOnlyCollection CompileFork( + WorkflowForkStepDeclaration fork, + IReadOnlyCollection incomingNodeIds, + string path, + string? incomingEdgeLabel) + { + var splitNodeId = $"{path}/split"; + var joinNodeId = $"{path}/join"; + nodes.Add(CreateNode( + splitNodeId, + fork.StepName, + "Fork", + "fork", + semanticType: "Fork", + semanticKey: fork.StepName, + width: 176, + height: 124)); + nodes.Add(CreateNode( + joinNodeId, + $"{fork.StepName} Join", + "Join", + "join", + semanticType: "Join", + semanticKey: fork.StepName, + width: 176, + height: 124)); + ConnectMany(incomingNodeIds, splitNodeId, label: incomingEdgeLabel); + + foreach (var item in fork.Branches.Select((branch, index) => new { branch, index })) + { + if (item.branch.Steps.Count == 0) + { + Connect(splitNodeId, joinNodeId, label: $"branch {item.index + 1}"); + continue; + } + + var exits = CompileSequence( + item.branch, + [splitNodeId], + $"{path}/branch-{item.index + 1}", + $"branch {item.index + 1}"); + ConnectMany(exits, joinNodeId); + } + + return [joinNodeId]; + } + + private IReadOnlyCollection CompileRepeat( + WorkflowRepeatStepDeclaration repeat, + IReadOnlyCollection incomingNodeIds, + string path, + string? incomingEdgeLabel) + { + var nodeId = path; + nodes.Add(CreateNode( + nodeId, + repeat.StepName, + "Repeat", + "repeat", + semanticType: "Repeat", + semanticKey: repeat.StepName)); + ConnectMany(incomingNodeIds, nodeId, label: incomingEdgeLabel); + + var bodyExits = repeat.Body.Steps.Count == 0 + ? Array.Empty() + : CompileSequence(repeat.Body, [nodeId], $"{path}/body", "body").ToArray(); + ConnectMany(bodyExits, nodeId, label: FormatRepeatLabel(repeat)); + return [nodeId]; + } + + private IReadOnlyCollection CompileComplete( + IReadOnlyCollection incomingNodeIds, + string path, + string? incomingEdgeLabel) + { + _ = path; + ConnectMany(incomingNodeIds, "end", label: incomingEdgeLabel); + return []; + } + + private void ConnectMany( + IReadOnlyCollection sourceNodeIds, + string targetNodeId, + string? kind = null, + string? label = null) + { + foreach (var sourceNodeId in sourceNodeIds) + { + Connect(sourceNodeId, targetNodeId, kind, label); + } + } + + private void Connect( + string sourceNodeId, + string targetNodeId, + string? kind = null, + string? label = null) + { + edges.Add(CreateEdge(sourceNodeId, targetNodeId, kind, label) with + { + Id = $"edge/{++edgeCounter}", + }); + } + + private static bool AreEquivalentHandledBranches( + WorkflowStepSequenceDeclaration? first, + WorkflowStepSequenceDeclaration? second) + { + if (first is null || second is null || first.Steps.Count == 0 || second.Steps.Count == 0) + { + return false; + } + + return JsonSerializer.Serialize(first) == JsonSerializer.Serialize(second); + } + } + + private static WorkflowRenderGraph SimplifyRenderGraph( + IReadOnlyCollection sourceNodes, + IReadOnlyCollection sourceEdges) + { + var nodes = sourceNodes.ToArray(); + var edges = sourceEdges.ToArray(); + var collapsed = CollapseStateSequences(nodes, edges); + var normalized = NormalizeGatewayBranchLabels(collapsed.Nodes, collapsed.Edges); + return new WorkflowRenderGraph + { + Id = string.Empty, + Nodes = normalized.Nodes, + Edges = normalized.Edges, + }; + } + + private static (IReadOnlyCollection Nodes, IReadOnlyCollection Edges) CollapseStateSequences( + IReadOnlyCollection sourceNodes, + IReadOnlyCollection sourceEdges) + { + var nodesById = sourceNodes.ToDictionary(x => x.Id, StringComparer.Ordinal); + var incomingByNodeId = sourceEdges + .GroupBy(x => x.TargetNodeId, StringComparer.Ordinal) + .ToDictionary(x => x.Key, x => x.ToArray(), StringComparer.Ordinal); + var outgoingByNodeId = sourceEdges + .GroupBy(x => x.SourceNodeId, StringComparer.Ordinal) + .ToDictionary(x => x.Key, x => x.ToArray(), StringComparer.Ordinal); + + var nodesToRemove = new HashSet(StringComparer.Ordinal); + var edgesToRemove = new HashSet(StringComparer.Ordinal); + var nodesToAdd = new List(); + var edgesToAdd = new List(); + + foreach (var node in sourceNodes) + { + if (!string.Equals(node.Kind, "SetState", StringComparison.Ordinal) + || nodesToRemove.Contains(node.Id)) + { + continue; + } + + var incoming = incomingByNodeId.GetValueOrDefault(node.Id, []); + if (incoming.Length == 1 + && nodesById.TryGetValue(incoming[0].SourceNodeId, out var incomingSource) + && string.Equals(incomingSource.Kind, "SetState", StringComparison.Ordinal) + && string.IsNullOrWhiteSpace(incoming[0].Label)) + { + continue; + } + + var chain = CollectStateSequence(node, nodesById, incomingByNodeId, outgoingByNodeId, nodesToRemove); + if (chain.Count < 2) + { + continue; + } + + var first = chain[0]; + var last = chain[^1]; + var mergedNodeId = $"{first.Id}/batched"; + var mergedNode = CreateNode( + mergedNodeId, + BuildStateBatchLabel(chain), + "SetState", + "state", + semanticType: "StateBatch", + semanticKey: string.Join(",", chain.Select(GetStateDisplayName)), + width: 224, + height: 104); + nodesToAdd.Add(mergedNode); + + foreach (var chainNode in chain) + { + nodesToRemove.Add(chainNode.Id); + } + + foreach (var internalEdge in sourceEdges.Where(edge => chain.Any(nodeItem => nodeItem.Id == edge.SourceNodeId) + || chain.Any(nodeItem => nodeItem.Id == edge.TargetNodeId))) + { + edgesToRemove.Add(internalEdge.Id); + } + + foreach (var incomingEdge in incomingByNodeId.GetValueOrDefault(first.Id, [])) + { + edgesToAdd.Add(incomingEdge with { TargetNodeId = mergedNodeId }); + } + + foreach (var outgoingEdge in outgoingByNodeId.GetValueOrDefault(last.Id, [])) + { + edgesToAdd.Add(outgoingEdge with { SourceNodeId = mergedNodeId }); + } + } + + var remainingNodes = sourceNodes.Where(node => !nodesToRemove.Contains(node.Id)).Concat(nodesToAdd).ToArray(); + var remainingEdges = sourceEdges.Where(edge => !edgesToRemove.Contains(edge.Id)).Concat(edgesToAdd).ToArray(); + return (remainingNodes, remainingEdges); + } + + private static IReadOnlyList CollectStateSequence( + WorkflowRenderNode startNode, + IReadOnlyDictionary nodesById, + IReadOnlyDictionary incomingByNodeId, + IReadOnlyDictionary outgoingByNodeId, + ISet nodesToRemove) + { + var chain = new List { startNode }; + var current = startNode; + + while (true) + { + var outgoing = outgoingByNodeId.GetValueOrDefault(current.Id, []); + if (outgoing.Length != 1 || !string.IsNullOrWhiteSpace(outgoing[0].Label)) + { + break; + } + + if (!nodesById.TryGetValue(outgoing[0].TargetNodeId, out var nextNode) + || !string.Equals(nextNode.Kind, "SetState", StringComparison.Ordinal) + || nodesToRemove.Contains(nextNode.Id)) + { + break; + } + + var nextIncoming = incomingByNodeId.GetValueOrDefault(nextNode.Id, []); + if (nextIncoming.Length != 1) + { + break; + } + + chain.Add(nextNode); + current = nextNode; + } + + return chain; + } + + private static string BuildStateBatchLabel(IReadOnlyList nodes) + { + var stateLines = nodes + .Select(GetStateDisplayName) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return $"Setting:\n{string.Join("\n", stateLines)}"; + } + + private static string GetStateDisplayName(WorkflowRenderNode node) + { + return string.IsNullOrWhiteSpace(node.SemanticKey) + ? node.Label.Replace("Set ", string.Empty, StringComparison.Ordinal) + : node.SemanticKey!; + } + + private static string FormatDecisionTrueLabel(WorkflowExpressionDefinition expression) + { + return $"when {ShortenExpression(FormatExpression(expression))}"; + } + + private static string FormatRepeatLabel(WorkflowRepeatStepDeclaration repeat) + { + if (repeat.ContinueWhileExpression is not null) + { + return $"repeat while {ShortenExpression(FormatExpression(repeat.ContinueWhileExpression))}"; + } + + return $"repeat up to {ShortenExpression(FormatExpression(repeat.MaxIterationsExpression))}"; + } + + private static string ShortenExpressionLegacy(string value, int maxLength = 42) + { + if (value.Length <= maxLength) + { + return value; + } + + return $"{value[..(maxLength - 1)]}…"; + } + + private static string FormatExpressionLegacy(WorkflowExpressionDefinition expression) + { + return expression switch + { + WorkflowNullExpressionDefinition => "null", + WorkflowStringExpressionDefinition stringExpression => $"\"{stringExpression.Value}\"", + WorkflowNumberExpressionDefinition numberExpression => numberExpression.Value, + WorkflowBooleanExpressionDefinition booleanExpression => booleanExpression.Value ? "true" : "false", + WorkflowPathExpressionDefinition pathExpression => pathExpression.Path, + WorkflowFunctionExpressionDefinition functionExpression => $"{functionExpression.FunctionName}({string.Join(", ", functionExpression.Arguments.Select(FormatExpression))})", + WorkflowGroupExpressionDefinition groupExpression => $"({FormatExpression(groupExpression.Expression)})", + WorkflowUnaryExpressionDefinition unaryExpression => $"{unaryExpression.Operator}{FormatExpression(unaryExpression.Operand)}", + WorkflowBinaryExpressionDefinition binaryExpression => $"{FormatExpression(binaryExpression.Left)} {binaryExpression.Operator} {FormatExpression(binaryExpression.Right)}", + WorkflowArrayExpressionDefinition arrayExpression => $"[{string.Join(", ", arrayExpression.Items.Take(3).Select(FormatExpression))}{(arrayExpression.Items.Count > 3 ? ", …" : string.Empty)}]", + WorkflowObjectExpressionDefinition objectExpression => $"{{{string.Join(", ", objectExpression.Properties.Take(2).Select(x => $"{x.Name}: {FormatExpression(x.Expression)}"))}{(objectExpression.Properties.Count > 2 ? ", …" : string.Empty)}}}", + _ => expression.GetType().Name, + }; + } + + private static string ShortenExpression(string value, int maxLength = 42) + { + if (value.Length <= maxLength) + { + return value; + } + + return $"{value[..(maxLength - 3)]}..."; + } + + private static string FormatExpression(WorkflowExpressionDefinition expression) + { + return expression switch + { + WorkflowNullExpressionDefinition => "null", + WorkflowStringExpressionDefinition stringExpression => $"\"{stringExpression.Value}\"", + WorkflowNumberExpressionDefinition numberExpression => numberExpression.Value, + WorkflowBooleanExpressionDefinition booleanExpression => booleanExpression.Value ? "true" : "false", + WorkflowPathExpressionDefinition pathExpression => pathExpression.Path, + WorkflowFunctionExpressionDefinition functionExpression => $"{functionExpression.FunctionName}({string.Join(", ", functionExpression.Arguments.Select(FormatExpression))})", + WorkflowGroupExpressionDefinition groupExpression => $"({FormatExpression(groupExpression.Expression)})", + WorkflowUnaryExpressionDefinition unaryExpression => $"{unaryExpression.Operator}{FormatExpression(unaryExpression.Operand)}", + WorkflowBinaryExpressionDefinition binaryExpression => $"{FormatExpression(binaryExpression.Left)} {binaryExpression.Operator} {FormatExpression(binaryExpression.Right)}", + WorkflowArrayExpressionDefinition arrayExpression => $"[{string.Join(", ", arrayExpression.Items.Take(3).Select(FormatExpression))}{(arrayExpression.Items.Count > 3 ? ", ..." : string.Empty)}]", + WorkflowObjectExpressionDefinition objectExpression => $"{{{string.Join(", ", objectExpression.Properties.Take(2).Select(x => $"{x.Name}: {FormatExpression(x.Expression)}"))}{(objectExpression.Properties.Count > 2 ? ", ..." : string.Empty)}}}", + _ => expression.GetType().Name, + }; + } + + private static (IReadOnlyCollection Nodes, IReadOnlyCollection Edges) NormalizeGatewayBranchLabels( + IReadOnlyCollection sourceNodes, + IReadOnlyCollection sourceEdges) + { + var nodesById = sourceNodes.ToDictionary(x => x.Id, StringComparer.Ordinal); + var normalizedEdges = new List(sourceEdges.Count); + + foreach (var edgeGroup in sourceEdges.GroupBy(x => x.SourceNodeId, StringComparer.Ordinal)) + { + if (!nodesById.TryGetValue(edgeGroup.Key, out var sourceNode) + || !string.Equals(sourceNode.Kind, "Decision", StringComparison.Ordinal)) + { + normalizedEdges.AddRange(edgeGroup); + continue; + } + + var groupEdges = edgeGroup.ToArray(); + var hasRecognizedBranchLabel = groupEdges.Any(edge => IsRecognizedGatewayLabel(edge.Label)); + var unlabeledCount = groupEdges.Count(edge => string.IsNullOrWhiteSpace(edge.Label)); + + foreach (var edge in groupEdges) + { + if (!string.IsNullOrWhiteSpace(edge.Label)) + { + normalizedEdges.Add(edge); + continue; + } + + var label = hasRecognizedBranchLabel + ? "default" + : unlabeledCount > 1 + ? "missing condition" + : "default"; + normalizedEdges.Add(edge with { Label = label }); + } + } + + return (sourceNodes.ToArray(), normalizedEdges.ToArray()); + } + + private static bool IsRecognizedGatewayLabel(string? label) + { + if (string.IsNullOrWhiteSpace(label)) + { + return false; + } + + var normalized = label.Trim().ToLowerInvariant(); + return normalized.StartsWith("when ", StringComparison.Ordinal) + || normalized.Contains("otherwise", StringComparison.Ordinal) + || normalized.Contains("default", StringComparison.Ordinal) + || normalized.Contains("missing condition", StringComparison.Ordinal); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Rendering/WorkflowRenderLayoutEngineResolver.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Rendering/WorkflowRenderLayoutEngineResolver.cs new file mode 100644 index 000000000..5dea7621c --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Rendering/WorkflowRenderLayoutEngineResolver.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.Options; + +namespace StellaOps.Workflow.Engine.Rendering; + +public sealed class WorkflowRenderLayoutEngineResolver( + IEnumerable engines, + IOptions options) + : IWorkflowRenderLayoutEngineResolver +{ + private readonly IReadOnlyDictionary enginesByName = + engines.ToDictionary(x => x.ProviderName, StringComparer.OrdinalIgnoreCase); + + public INamedWorkflowRenderGraphLayoutEngine Resolve(string? providerName = null) + { + var effectiveProvider = string.IsNullOrWhiteSpace(providerName) + ? options.Value.LayoutProvider + : providerName; + + if (string.IsNullOrWhiteSpace(effectiveProvider)) + { + effectiveProvider = WorkflowRenderLayoutProviderNames.ElkSharp; + } + + if (enginesByName.TryGetValue(effectiveProvider, out var engine)) + { + return engine; + } + + throw new InvalidOperationException( + $"Workflow render layout provider '{effectiveProvider}' is not registered. " + + $"Registered providers: {string.Join(", ", enginesByName.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))}."); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Scheduling/NullWorkflowScheduleBus.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Scheduling/NullWorkflowScheduleBus.cs new file mode 100644 index 000000000..fd1c8a23b --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Scheduling/NullWorkflowScheduleBus.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Scheduling; + +public sealed class NullWorkflowScheduleBus : IWorkflowScheduleBus +{ + public Task ScheduleAsync( + WorkflowSignalEnvelope envelope, + DateTime dueAtUtc, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException( + "Workflow scheduling is not configured. Register a backend-specific schedule bus before enabling the workflow engine."); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Scheduling/NullWorkflowSignalScheduler.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Scheduling/NullWorkflowSignalScheduler.cs new file mode 100644 index 000000000..f4348bbd2 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Scheduling/NullWorkflowSignalScheduler.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.Logging; + +namespace StellaOps.Workflow.Engine.Scheduling; + +public sealed class NullWorkflowSignalScheduler( + ILogger logger) : IWorkflowSignalScheduler +{ + public Task ScheduleAsync( + WorkflowSignalEnvelope envelope, + DateTime dueAtUtc, + CancellationToken cancellationToken = default) + { + logger.LogWarning( + "Signal scheduler not configured. Scheduled signal {SignalId} for instance {WorkflowInstanceId} due at {DueAtUtc} will not be persisted.", + envelope.SignalId, + envelope.WorkflowInstanceId, + dueAtUtc); + return Task.CompletedTask; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Scheduling/WorkflowScheduleBusBridge.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Scheduling/WorkflowScheduleBusBridge.cs new file mode 100644 index 000000000..4baab526b --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Scheduling/WorkflowScheduleBusBridge.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Scheduling; + +public sealed class WorkflowScheduleBusBridge( + IWorkflowSignalScheduler signalScheduler) : IWorkflowScheduleBus +{ + public Task ScheduleAsync( + WorkflowSignalEnvelope envelope, + DateTime dueAtUtc, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope); + return signalScheduler.ScheduleAsync(envelope, dueAtUtc, cancellationToken); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalBus.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalBus.cs new file mode 100644 index 000000000..a148e726f --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalBus.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Signaling; + +public sealed class NullWorkflowSignalBus : IWorkflowSignalBus +{ + public Task PublishAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException( + "Workflow signaling is not configured. Register a backend-specific signal bus before enabling the workflow engine."); + } + + public Task PublishDeadLetterAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException( + "Workflow signaling is not configured. Register a backend-specific signal bus before enabling the workflow engine."); + } + + public Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException( + "Workflow signaling is not configured. Register a backend-specific signal bus before enabling the workflow engine."); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalClaimStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalClaimStore.cs new file mode 100644 index 000000000..57165c502 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalClaimStore.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Signaling; + +public sealed class NullWorkflowSignalClaimStore : IWorkflowSignalClaimStore +{ + public Task TryClaimAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalDeadLetterStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalDeadLetterStore.cs new file mode 100644 index 000000000..92c62fa1a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalDeadLetterStore.cs @@ -0,0 +1,27 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Signaling; + +public sealed class NullWorkflowSignalDeadLetterStore : IWorkflowSignalDeadLetterStore +{ + public Task GetMessagesAsync( + WorkflowSignalDeadLettersGetRequest request, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException( + "Workflow dead-letter inspection is not configured. Register a backend-specific dead-letter store before enabling signal replay."); + } + + public Task ReplayAsync( + WorkflowSignalDeadLetterReplayRequest request, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException( + "Workflow dead-letter replay is not configured. Register a backend-specific dead-letter store before enabling signal replay."); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalDriver.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalDriver.cs new file mode 100644 index 000000000..a77ed4d07 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalDriver.cs @@ -0,0 +1,27 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Signaling; + +public sealed class NullWorkflowSignalDriver : IWorkflowSignalDriver +{ + public string DriverName => "None"; + + public WorkflowSignalDriverDispatchMode DispatchMode => WorkflowSignalDriverDispatchMode.NativeTransactional; + + public Task NotifySignalAvailableAsync( + WorkflowSignalWakeNotification notification, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalStore.cs new file mode 100644 index 000000000..e800262b6 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowSignalStore.cs @@ -0,0 +1,34 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.Logging; + +namespace StellaOps.Workflow.Engine.Signaling; + +public sealed class NullWorkflowSignalStore( + ILogger logger) : IWorkflowSignalStore +{ + public Task PublishAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + logger.LogWarning( + "Signal store not configured. Signal {SignalId} for instance {WorkflowInstanceId} will not be persisted.", + envelope.SignalId, + envelope.WorkflowInstanceId); + return Task.CompletedTask; + } + + public Task PublishDeadLetterAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + logger.LogWarning( + "Signal store not configured. Dead-letter signal {SignalId} for instance {WorkflowInstanceId} will not be persisted.", + envelope.SignalId, + envelope.WorkflowInstanceId); + return Task.CompletedTask; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowWakeOutbox.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowWakeOutbox.cs new file mode 100644 index 000000000..1db329cb4 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowWakeOutbox.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Signaling; + +public sealed class NullWorkflowWakeOutbox : IWorkflowWakeOutbox +{ + public Task EnqueueAsync( + WorkflowSignalWakeNotification notification, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowWakeOutboxReceiver.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowWakeOutboxReceiver.cs new file mode 100644 index 000000000..fe04ef2f4 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/NullWorkflowWakeOutboxReceiver.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Signaling; + +public sealed class NullWorkflowWakeOutboxReceiver : IWorkflowWakeOutboxReceiver +{ + public Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalBusBridge.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalBusBridge.cs new file mode 100644 index 000000000..1890c1fbe --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalBusBridge.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Signaling; + +public sealed class WorkflowSignalBusBridge( + IWorkflowSignalStore signalStore, + IWorkflowSignalDriver signalDriver, + IWorkflowWakeOutbox wakeOutbox, + IWorkflowMutationScopeAccessor mutationScopeAccessor) : IWorkflowSignalBus +{ + public async Task PublishAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope); + + await signalStore.PublishAsync(envelope, cancellationToken); + var notification = new WorkflowSignalWakeNotification + { + SignalId = envelope.SignalId, + WorkflowInstanceId = envelope.WorkflowInstanceId, + RuntimeProvider = envelope.RuntimeProvider, + SignalType = envelope.SignalType, + DueAtUtc = envelope.DueAtUtc, + }; + + if (signalDriver.DispatchMode == WorkflowSignalDriverDispatchMode.PostCommitNotification) + { + if (mutationScopeAccessor.Current is not null) + { + mutationScopeAccessor.Current.RegisterPostCommitAction( + ct => signalDriver.NotifySignalAvailableAsync(notification, ct)); + return; + } + + await signalDriver.NotifySignalAvailableAsync(notification, cancellationToken); + return; + } + + if (signalDriver.DispatchMode == WorkflowSignalDriverDispatchMode.WakeOutbox) + { + await wakeOutbox.EnqueueAsync(notification, cancellationToken); + return; + } + + await signalDriver.NotifySignalAvailableAsync(notification, cancellationToken); + } + + public Task PublishDeadLetterAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope); + return signalStore.PublishDeadLetterAsync(envelope, cancellationToken); + } + + public Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + return signalDriver.ReceiveAsync(consumerName, cancellationToken); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalCommandDispatcher.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalCommandDispatcher.cs new file mode 100644 index 000000000..251d1ac2a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalCommandDispatcher.cs @@ -0,0 +1,37 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; +using StellaOps.Workflow.Engine.Services; + +namespace StellaOps.Workflow.Engine.Signaling; + +public interface IWorkflowSignalCommandDispatcher +{ + Task DispatchStartWorkflowAsync( + StartWorkflowRequest request, + CancellationToken cancellationToken = default); + + Task DispatchResumeWorkflowAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default); +} + +public sealed class WorkflowSignalCommandDispatcher( + WorkflowRuntimeService workflowRuntimeService) : IWorkflowSignalCommandDispatcher +{ + public async Task DispatchStartWorkflowAsync( + StartWorkflowRequest request, + CancellationToken cancellationToken = default) + { + await workflowRuntimeService.StartWorkflowAsync(request, cancellationToken); + } + + public async Task DispatchResumeWorkflowAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + await workflowRuntimeService.ResumeSignalAsync(envelope, cancellationToken); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalEnvelopeSerializer.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalEnvelopeSerializer.cs new file mode 100644 index 000000000..932450a43 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalEnvelopeSerializer.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Signaling; + +public sealed class WorkflowSignalEnvelopeSerializer +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + public byte[] Serialize(WorkflowSignalEnvelope envelope) + { + ArgumentNullException.ThrowIfNull(envelope); + + var payload = new WorkflowSignalEnvelopeRecord + { + SignalId = envelope.SignalId, + WorkflowInstanceId = envelope.WorkflowInstanceId, + RuntimeProvider = envelope.RuntimeProvider, + SignalType = envelope.SignalType, + ExpectedVersion = envelope.ExpectedVersion, + WaitingToken = envelope.WaitingToken, + OccurredAtUtc = envelope.OccurredAtUtc, + DueAtUtc = envelope.DueAtUtc, + Payload = new Dictionary(envelope.Payload, StringComparer.OrdinalIgnoreCase), + }; + + return JsonSerializer.SerializeToUtf8Bytes(payload, SerializerOptions); + } + + public WorkflowSignalEnvelope Deserialize(byte[] payload) + { + ArgumentNullException.ThrowIfNull(payload); + + var envelope = JsonSerializer.Deserialize(payload, SerializerOptions) + ?? throw new InvalidOperationException("Workflow signal payload could not be deserialized."); + + return new WorkflowSignalEnvelope + { + SignalId = envelope.SignalId, + WorkflowInstanceId = envelope.WorkflowInstanceId, + RuntimeProvider = envelope.RuntimeProvider, + SignalType = envelope.SignalType, + ExpectedVersion = envelope.ExpectedVersion, + WaitingToken = envelope.WaitingToken, + OccurredAtUtc = envelope.OccurredAtUtc, + DueAtUtc = envelope.DueAtUtc, + Payload = envelope.Payload, + }; + } + + private sealed record WorkflowSignalEnvelopeRecord + { + public required string SignalId { get; init; } + public required string WorkflowInstanceId { get; init; } + public required string RuntimeProvider { get; init; } + public required string SignalType { get; init; } + public required long ExpectedVersion { get; init; } + public string? WaitingToken { get; init; } + public DateTime OccurredAtUtc { get; init; } + public DateTime? DueAtUtc { get; init; } + public Dictionary Payload { get; init; } = new(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalProcessor.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalProcessor.cs new file mode 100644 index 000000000..9bbe68794 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Engine/Signaling/WorkflowSignalProcessor.cs @@ -0,0 +1,49 @@ +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Signaling; + +public sealed class WorkflowSignalProcessor( + IWorkflowSignalCommandDispatcher commandDispatcher) : IWorkflowSignalProcessor +{ + public Task ProcessAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope); + + return envelope.SignalType switch + { + WorkflowSignalTypes.InternalContinue => ProcessInternalContinueAsync(envelope, cancellationToken), + WorkflowSignalTypes.TimerDue => commandDispatcher.DispatchResumeWorkflowAsync(envelope, cancellationToken), + WorkflowSignalTypes.RetryDue => commandDispatcher.DispatchResumeWorkflowAsync(envelope, cancellationToken), + WorkflowSignalTypes.ExternalSignal => commandDispatcher.DispatchResumeWorkflowAsync(envelope, cancellationToken), + WorkflowSignalTypes.SubWorkflowCompleted => commandDispatcher.DispatchResumeWorkflowAsync(envelope, cancellationToken), + _ => throw new NotSupportedException( + $"Workflow signal type '{envelope.SignalType}' is not supported by the current workflow engine processor."), + }; + } + + private Task ProcessInternalContinueAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken) + { + if (!envelope.Payload.TryGetValue(WorkflowSignalPayloadKeys.StartWorkflowRequestPayloadKey, out var payload)) + { + throw new InvalidOperationException( + $"Workflow signal '{WorkflowSignalTypes.InternalContinue}' is missing payload '{WorkflowSignalPayloadKeys.StartWorkflowRequestPayloadKey}'."); + } + + var request = payload.Deserialize(SerializerOptions) + ?? throw new InvalidOperationException("Workflow continuation payload could not be deserialized."); + + return commandDispatcher.DispatchStartWorkflowAsync(request, cancellationToken); + } + + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Exceptions/BaseResultException.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Exceptions/BaseResultException.cs new file mode 100644 index 000000000..f7eb09028 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Exceptions/BaseResultException.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace StellaOps.Workflow.Engine.Exceptions; + +// TODO: This is a simplified shim extracted from Ablera.Serdica.Common.Tools.Exceptions.BaseResultException. +// Replace with a proper StellaOps exception base type that integrates with the StellaOps error/translation pipeline. + +/// +/// A business-result exception carrying a message key and optional interpolation arguments. +/// Used throughout the workflow engine to signal well-known business errors. +/// +public class BaseResultException : Exception +{ + public int Code { get; set; } = 400; + public string MessageId { get; set; } + public IReadOnlyList MessageInterpolationArguments { get; set; } = []; + + public BaseResultException(string messageId) : base(messageId) + { + MessageId = messageId; + } + + public BaseResultException(string messageId, params object[] args) + : base(FormatMessage(messageId, args)) + { + MessageId = messageId; + MessageInterpolationArguments = args; + } + + public BaseResultException(int code, string messageId) : base(messageId) + { + Code = code; + MessageId = messageId; + } + + public BaseResultException(int code, string messageId, params object[] args) + : base(FormatMessage(messageId, args)) + { + Code = code; + MessageId = messageId; + MessageInterpolationArguments = args; + } + + public BaseResultException(Exception innerException, string messageId) + : base(messageId, innerException) + { + MessageId = messageId; + } + + public BaseResultException(Exception innerException, string messageId, params object[] args) + : base(FormatMessage(messageId, args), innerException) + { + MessageId = messageId; + MessageInterpolationArguments = args; + } + + private static string FormatMessage(string messageId, object[] args) + { + if (args.Length == 0) + { + return messageId; + } + + return $"{messageId}: {string.Join(", ", args)}"; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/HostedServices/WorkflowRetentionHostedService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/HostedServices/WorkflowRetentionHostedService.cs new file mode 100644 index 000000000..6023bcce5 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/HostedServices/WorkflowRetentionHostedService.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StellaOps.Workflow.Engine.HostedServices; + +public sealed class WorkflowRetentionHostedService( + IServiceScopeFactory serviceScopeFactory, + IOptions options, + ILogger logger) : BackgroundService +{ + private readonly Services.WorkflowRetentionHostedJobOptions jobOptions = options.Value; + private readonly string lockOwner = $"{Environment.MachineName}:{Guid.NewGuid():N}"; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!jobOptions.Enabled) + { + logger.LogInformation("{ServiceName} is disabled.", nameof(WorkflowRetentionHostedService)); + return; + } + + if (!jobOptions.RunOnStartup && jobOptions.InitialDelay > TimeSpan.Zero) + { + await Task.Delay(jobOptions.InitialDelay, stoppingToken); + } + + if (jobOptions.RunOnStartup) + { + await RunOnceAsync(stoppingToken); + } + + using var timer = new PeriodicTimer(jobOptions.Interval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await RunOnceAsync(stoppingToken); + } + } + + private async Task RunOnceAsync(CancellationToken cancellationToken) + { + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var lockService = scope.ServiceProvider.GetRequiredService(); + var acquiredOnUtc = DateTime.UtcNow; + var acquired = await lockService.TryAcquireAsync( + jobOptions.LockName, + lockOwner, + acquiredOnUtc, + jobOptions.LockLease, + cancellationToken); + + if (!acquired) + { + logger.LogInformation( + "{ServiceName} skipped because lock {LockName} is already held by another workflow service instance.", + nameof(WorkflowRetentionHostedService), + jobOptions.LockName); + return; + } + + try + { + var retentionService = scope.ServiceProvider.GetRequiredService(); + var result = await retentionService.RunAsync(acquiredOnUtc, cancellationToken); + logger.LogInformation( + "{ServiceName} completed. StaleInstances={StaleInstances}, StaleTasks={StaleTasks}, PurgedInstances={PurgedInstances}, PurgedTasks={PurgedTasks}, PurgedTaskEvents={PurgedTaskEvents}, PurgedRuntimeStates={PurgedRuntimeStates}", + nameof(WorkflowRetentionHostedService), + result.StaleInstancesMarked, + result.StaleTasksMarked, + result.PurgedInstances, + result.PurgedTasks, + result.PurgedTaskEvents, + result.PurgedRuntimeStates); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + logger.LogInformation("{ServiceName} is stopping during retention execution.", nameof(WorkflowRetentionHostedService)); + } + catch (Exception exception) + { + logger.LogError(exception, "{ServiceName} failed.", nameof(WorkflowRetentionHostedService)); + } + finally + { + await lockService.ReleaseAsync(jobOptions.LockName, lockOwner, cancellationToken); + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/HostedServices/WorkflowSignalPumpHostedService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/HostedServices/WorkflowSignalPumpHostedService.cs new file mode 100644 index 000000000..7a21ff033 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/HostedServices/WorkflowSignalPumpHostedService.cs @@ -0,0 +1,97 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Engine.Hosting; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StellaOps.Workflow.Engine.HostedServices; + +public sealed class WorkflowSignalPumpHostedService( + WorkflowSignalPumpWorker worker, + IOptions aqOptions, + IOptions engineOptions, + ILogger logger) : BackgroundService +{ + private readonly WorkflowAqOptions workflowAqOptions = aqOptions.Value; + private readonly WorkflowEngineOptions workflowEngineOptions = engineOptions.Value; + private Task[]? workerTasks; + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + var workerCount = Math.Max(1, workflowEngineOptions.MaxConcurrentSignalHandlers); + workerTasks = Enumerable + .Range(0, workerCount) + .Select(index => RunWorkerLoopAsync(index, workerCount, stoppingToken)) + .ToArray(); + + return Task.WhenAll(workerTasks); + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + logger.LogInformation( + "{ServiceName} is stopping. Waiting for {WorkerCount} worker(s) to drain.", + nameof(WorkflowSignalPumpHostedService), + workerTasks?.Length ?? 0); + + await base.StopAsync(cancellationToken); + + if (workerTasks is { Length: > 0 }) + { + var timeout = TimeSpan.FromSeconds( + Math.Max(1, workflowEngineOptions.GracefulShutdownTimeoutSeconds)); + var allWorkersTask = Task.WhenAll(workerTasks); + var completed = await Task.WhenAny(allWorkersTask, Task.Delay(timeout, CancellationToken.None)); + + if (completed != allWorkersTask) + { + logger.LogWarning( + "{ServiceName} graceful shutdown timed out after {TimeoutSeconds}s. {InFlightCount} worker(s) may still be in-flight.", + nameof(WorkflowSignalPumpHostedService), + workflowEngineOptions.GracefulShutdownTimeoutSeconds, + workerTasks.Count(t => !t.IsCompleted)); + } + else + { + logger.LogInformation( + "{ServiceName} all workers drained successfully.", + nameof(WorkflowSignalPumpHostedService)); + } + } + } + + private async Task RunWorkerLoopAsync( + int workerIndex, + int workerCount, + CancellationToken stoppingToken) + { + var consumerName = workerCount == 1 + ? workflowAqOptions.ConsumerName + : $"{workflowAqOptions.ConsumerName}-{workerIndex + 1}"; + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await worker.RunOnceAsync(consumerName, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + logger.LogError( + exception, + "{ServiceName} failed while processing workflow signals for consumer {ConsumerName}.", + nameof(WorkflowSignalPumpHostedService), + consumerName); + } + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/HostedServices/WorkflowSignalPumpWorker.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/HostedServices/WorkflowSignalPumpWorker.cs new file mode 100644 index 000000000..a8e7f0536 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/HostedServices/WorkflowSignalPumpWorker.cs @@ -0,0 +1,102 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Engine.Hosting; +using StellaOps.Workflow.Engine.Services; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StellaOps.Workflow.Engine.HostedServices; + +public sealed class WorkflowSignalPumpWorker( + IServiceScopeFactory serviceScopeFactory, + IOptions aqOptions, + WorkflowSignalPumpTelemetryService telemetryService, + ILogger logger) +{ + private readonly WorkflowAqOptions options = aqOptions.Value; + + public async Task RunOnceAsync( + string consumerName, + CancellationToken cancellationToken) + { + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var signalBus = scope.ServiceProvider.GetRequiredService(); + await using var lease = await signalBus.ReceiveAsync(consumerName, cancellationToken); + if (lease is null) + { + telemetryService.RecordEmptyPoll(consumerName); + return false; + } + + var processor = scope.ServiceProvider.GetRequiredService(); + var stopwatch = Stopwatch.StartNew(); + try + { + await processor.ProcessAsync(lease.Envelope, cancellationToken); + await lease.CompleteAsync(cancellationToken); + stopwatch.Stop(); + telemetryService.RecordProcessed(consumerName, lease.Envelope, lease.DeliveryCount, stopwatch.Elapsed); + logger.LogDebug( + "Workflow signal {SignalId} for instance {WorkflowInstanceId} processed successfully as {SignalType} in {DurationMs} ms on delivery {DeliveryCount}.", + lease.Envelope.SignalId, + lease.Envelope.WorkflowInstanceId, + lease.Envelope.SignalType, + (long)stopwatch.Elapsed.TotalMilliseconds, + lease.DeliveryCount); + return true; + } + catch (WorkflowRuntimeStateConcurrencyException concurrencyException) + { + stopwatch.Stop(); + await lease.CompleteAsync(CancellationToken.None); + telemetryService.RecordConcurrencySkip( + consumerName, + lease.Envelope, + lease.DeliveryCount, + stopwatch.Elapsed); + logger.LogDebug( + concurrencyException, + "Workflow signal {SignalId} for instance {WorkflowInstanceId} skipped due to version conflict (delivery {DeliveryCount}).", + lease.Envelope.SignalId, + lease.Envelope.WorkflowInstanceId, + lease.DeliveryCount); + return true; + } + catch (Exception exception) + { + stopwatch.Stop(); + if (lease.DeliveryCount >= Math.Max(1, options.MaxDeliveryAttempts)) + { + await lease.DeadLetterAsync(CancellationToken.None); + telemetryService.RecordDeadLetter( + consumerName, + lease.Envelope, + lease.DeliveryCount, + stopwatch.Elapsed, + exception.Message); + logger.LogError( + exception, + "Workflow signal {SignalId} for instance {WorkflowInstanceId} exceeded max delivery attempts ({DeliveryCount}) and was moved to the dead-letter queue.", + lease.Envelope.SignalId, + lease.Envelope.WorkflowInstanceId, + lease.DeliveryCount); + return true; + } + + telemetryService.RecordFailure( + consumerName, + lease.Envelope, + lease.DeliveryCount, + stopwatch.Elapsed, + exception); + await lease.AbandonAsync(CancellationToken.None); + throw; + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Projections/WorkflowProjectionStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Projections/WorkflowProjectionStore.cs new file mode 100644 index 000000000..b96be12b3 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Projections/WorkflowProjectionStore.cs @@ -0,0 +1,1072 @@ +using StellaOps.Workflow.DataStore.Oracle; +using StellaOps.Workflow.DataStore.Oracle.Entities; +using System; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +// TODO: Replace with StellaOps workflow DbContext +// TODO: Replace with StellaOps workflow entity models +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Engine.Constants; +using StellaOps.Workflow.Contracts; +using StellaOps.Workflow.Engine.Services; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Options; + +namespace StellaOps.Workflow.Engine.Projections; + +public sealed class WorkflowProjectionStore( + WorkflowDbContext dbContext, + WorkflowRoleResolutionService workflowRoleResolutionService, + IOptions retentionOptions) : IWorkflowProjectionStore +{ + private const string WorkflowInstancesSequence = "SRD_WFKLW.WF_INSTANCES_SEQ"; + private const string WorkflowTasksSequence = "SRD_WFKLW.WF_TASKS_SEQ"; + private const string WorkflowTaskEventsSequence = "SRD_WFKLW.WF_TASK_EVENTS_SEQ"; + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + private readonly WorkflowRetentionOptions retention = retentionOptions.Value; + + public async Task CreateWorkflowAsync( + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + WorkflowStartExecutionPlan executionPlan, + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var workflowRoles = workflowRoleResolutionService.NormalizeRoles(definition.WorkflowRoles); + var workflowInstanceId = $"wf-{Guid.NewGuid():N}"; + + var instance = new WorkflowInstanceProjection + { + Id = await GetNextWorkflowInstanceProjectionIdAsync(cancellationToken), + WorkflowInstanceId = workflowInstanceId, + WorkflowName = definition.WorkflowName, + WorkflowVersion = definition.WorkflowVersion, + BusinessReferenceKey = businessReference?.Key, + BusinessReferenceJson = SerializeBusinessReference(businessReference), + Status = executionPlan.InstanceStatus, + StateJson = Serialize(executionPlan.WorkflowState), + CreatedOnUtc = now, + CompletedOnUtc = ResolveCompletedOnUtc(executionPlan.InstanceStatus, now), + StaleAfterUtc = ResolveOpenStaleAfterUtc(executionPlan.InstanceStatus, now), + PurgeAfterUtc = ResolvePurgeAfterUtc(executionPlan.InstanceStatus, now), + }; + + await dbContext.WorkflowInstances.AddAsync(instance, cancellationToken); + await UpsertProjectionWorkflowInstancesAsync( + definition.WorkflowName, + definition.WorkflowVersion, + businessReference, + executionPlan.WorkflowState, + executionPlan.Tasks, + now, + cancellationToken); + + var nextTaskIds = await GetNextIdsAsync( + WorkflowTasksSequence, + executionPlan.Tasks.Count, + dbContext.WorkflowTasks.Select(x => x.Id), + cancellationToken); + var nextEventIds = await GetNextIdsAsync( + WorkflowTaskEventsSequence, + executionPlan.Tasks.Count, + dbContext.WorkflowTaskEvents.Select(x => x.Id), + cancellationToken); + var (taskRows, taskEvents) = CreateTaskRows( + definition.WorkflowName, + definition.WorkflowVersion, + workflowInstanceId, + businessReference, + workflowRoles, + executionPlan.Tasks, + now, + nextTaskIds, + nextEventIds); + + await dbContext.WorkflowTasks.AddRangeAsync(taskRows, cancellationToken); + await dbContext.WorkflowTaskEvents.AddRangeAsync(taskEvents, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + return new StartWorkflowResponse + { + WorkflowInstanceId = workflowInstanceId, + WorkflowName = definition.WorkflowName, + WorkflowVersion = definition.WorkflowVersion, + BusinessReference = businessReference, + }; + } + + public async Task> GetTasksAsync( + WorkflowTasksGetRequest request, + CancellationToken cancellationToken = default) + { + var query = dbContext.WorkflowTasks.AsNoTracking().AsQueryable(); + + if (!string.IsNullOrWhiteSpace(request.WorkflowName)) + { + query = query.Where(x => x.WorkflowName == request.WorkflowName); + } + + if (!string.IsNullOrWhiteSpace(request.WorkflowVersion)) + { + query = query.Where(x => x.WorkflowVersion == request.WorkflowVersion); + } + + if (!string.IsNullOrWhiteSpace(request.WorkflowInstanceId)) + { + query = query.Where(x => x.WorkflowInstanceId == request.WorkflowInstanceId); + } + + if (!string.IsNullOrWhiteSpace(request.BusinessReferenceKey)) + { + query = query.Where(x => x.BusinessReferenceKey == request.BusinessReferenceKey); + } + + if (!string.IsNullOrWhiteSpace(request.Assignee)) + { + query = query.Where(x => x.Assignee == request.Assignee); + } + + if (!string.IsNullOrWhiteSpace(request.Status)) + { + query = query.Where(x => x.Status == request.Status); + } + + var tasks = await query + .OrderBy(x => x.CreatedOnUtc) + .ToListAsync(cancellationToken); + + var summaries = tasks + .Select(MapTaskSummary) + .Where(x => x.BusinessReference.MatchesBusinessReferenceFilter(request.BusinessReferenceKey, request.BusinessReferenceParts)) + .ToArray(); + + if (request.CandidateRoles.Count == 0) + { + return summaries; + } + + return summaries + .Where(x => x.EffectiveRoles.Intersect(request.CandidateRoles, StringComparer.OrdinalIgnoreCase).Any()) + .ToArray(); + } + + public async Task GetTaskAsync( + string workflowTaskId, + CancellationToken cancellationToken = default) + { + var task = await dbContext.WorkflowTasks + .AsNoTracking() + .SingleOrDefaultAsync(x => x.WorkflowTaskId == workflowTaskId, cancellationToken); + + if (task is null) + { + return null; + } + + return MapTaskSummary(task); + } + + public async Task GetExecutionSnapshotAsync( + string workflowTaskId, + CancellationToken cancellationToken = default) + { + var task = await dbContext.WorkflowTasks + .AsNoTracking() + .SingleOrDefaultAsync(x => x.WorkflowTaskId == workflowTaskId, cancellationToken); + + if (task is null) + { + return null; + } + + var instance = await dbContext.WorkflowInstances + .AsNoTracking() + .SingleAsync(x => x.WorkflowInstanceId == task.WorkflowInstanceId, cancellationToken); + + return new WorkflowExecutionSnapshot + { + Task = MapTaskSummary(task), + WorkflowState = DeserializeJsonDictionary(instance.StateJson), + }; + } + + public async Task AssignTaskAsync( + string workflowTaskId, + string actorId, + string assignee, + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var task = await dbContext.WorkflowTasks + .SingleAsync(x => x.WorkflowTaskId == workflowTaskId, cancellationToken); + + task.Assignee = assignee; + task.Status = WorkflowTaskStatuses.Assigned; + task.StaleAfterUtc = now.AddDays(retention.OpenStaleAfterDays); + + await AddTaskEventAsync( + workflowTaskId, + WorkflowTaskEventTypes.Assigned, + actorId, + new { Assignee = assignee }, + now, + cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + return MapTaskSummary(task); + } + + public async Task AssignTaskRolesAsync( + string workflowTaskId, + string actorId, + IReadOnlyCollection targetRoles, + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var task = await dbContext.WorkflowTasks + .SingleAsync(x => x.WorkflowTaskId == workflowTaskId, cancellationToken); + + var workflowRoles = DeserializeStringArray(task.WorkflowRolesJson); + var taskRoles = DeserializeStringArray(task.TaskRolesJson); + var runtimeRoles = workflowRoleResolutionService.NormalizeRoles(targetRoles); + var effectiveRoles = workflowRoleResolutionService.ResolveEffectiveRoles(workflowRoles, taskRoles, runtimeRoles); + + task.Assignee = null; + task.Status = WorkflowTaskStatuses.Open; + task.RuntimeRolesJson = Serialize(runtimeRoles); + task.EffectiveRolesJson = Serialize(effectiveRoles); + task.StaleAfterUtc = now.AddDays(retention.OpenStaleAfterDays); + + await AddTaskEventAsync( + workflowTaskId, + WorkflowTaskEventTypes.Reassigned, + actorId, + new + { + RuntimeRoles = runtimeRoles, + EffectiveRoles = effectiveRoles, + }, + now, + cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + return MapTaskSummary(task); + } + + public async Task ReleaseTaskAsync( + string workflowTaskId, + string actorId, + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var task = await dbContext.WorkflowTasks + .SingleAsync(x => x.WorkflowTaskId == workflowTaskId, cancellationToken); + + task.Assignee = null; + task.Status = WorkflowTaskStatuses.Open; + task.StaleAfterUtc = now.AddDays(retention.OpenStaleAfterDays); + + await AddTaskEventAsync( + workflowTaskId, + WorkflowTaskEventTypes.Released, + actorId, + new { Released = true }, + now, + cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + return MapTaskSummary(task); + } + + public async Task ApplyTaskCompletionAsync( + string workflowTaskId, + string actorId, + IDictionary payload, + WorkflowTaskCompletionPlan completionPlan, + WorkflowBusinessReference? businessReference, + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var task = await dbContext.WorkflowTasks + .SingleAsync(x => x.WorkflowTaskId == workflowTaskId, cancellationToken); + var instance = await dbContext.WorkflowInstances + .SingleAsync(x => x.WorkflowInstanceId == task.WorkflowInstanceId, cancellationToken); + var updatedBusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference( + businessReference ?? DeserializeBusinessReference(task.BusinessReferenceKey, task.BusinessReferenceJson)); + var serializedBusinessReference = SerializeBusinessReference(updatedBusinessReference); + var businessReferenceKey = updatedBusinessReference?.Key; + + task.Assignee ??= actorId; + task.Status = WorkflowTaskStatuses.Completed; + task.CompletedOnUtc = now; + task.StaleAfterUtc = null; + task.PurgeAfterUtc = now.AddDays(retention.CompletedPurgeAfterDays); + + instance.StateJson = Serialize(completionPlan.WorkflowState); + instance.BusinessReferenceKey = businessReferenceKey; + instance.BusinessReferenceJson = serializedBusinessReference; + instance.Status = completionPlan.InstanceStatus; + instance.CompletedOnUtc = ResolveCompletedOnUtc(completionPlan.InstanceStatus, now); + instance.StaleAfterUtc = ResolveOpenStaleAfterUtc(completionPlan.InstanceStatus, now); + instance.PurgeAfterUtc = ResolvePurgeAfterUtc(completionPlan.InstanceStatus, now); + + task.BusinessReferenceKey = businessReferenceKey; + task.BusinessReferenceJson = serializedBusinessReference; + + var siblingTasks = await dbContext.WorkflowTasks + .Where(x => x.WorkflowInstanceId == task.WorkflowInstanceId && x.WorkflowTaskId != workflowTaskId) + .ToListAsync(cancellationToken); + foreach (var siblingTask in siblingTasks) + { + siblingTask.BusinessReferenceKey = businessReferenceKey; + siblingTask.BusinessReferenceJson = serializedBusinessReference; + } + + var nextEventIds = await GetNextIdsAsync( + WorkflowTaskEventsSequence, + 1 + completionPlan.NextTasks.Count, + dbContext.WorkflowTaskEvents.Select(x => x.Id), + cancellationToken); + var completedEvent = new WorkflowTaskEvent + { + Id = nextEventIds[0], + WorkflowTaskId = workflowTaskId, + EventType = WorkflowTaskEventTypes.Completed, + ActorId = actorId, + PayloadJson = Serialize(payload), + CreatedOnUtc = now, + }; + + await dbContext.WorkflowTaskEvents.AddAsync(completedEvent, cancellationToken); + var completedProjectionWorkflowInstanceId = TryReadProjectionWorkflowInstanceId(task.PayloadJson); + + if (completionPlan.NextTasks.Count > 0) + { + var workflowRoles = DeserializeStringArray(task.WorkflowRolesJson); + var nextTaskIds = await GetNextIdsAsync( + WorkflowTasksSequence, + completionPlan.NextTasks.Count, + dbContext.WorkflowTasks.Select(x => x.Id), + cancellationToken); + var (taskRows, taskEvents) = CreateTaskRows( + task.WorkflowName, + task.WorkflowVersion, + task.WorkflowInstanceId, + updatedBusinessReference, + workflowRoles, + completionPlan.NextTasks, + now, + nextTaskIds, + nextEventIds.Skip(1).ToArray()); + + await dbContext.WorkflowTasks.AddRangeAsync(taskRows, cancellationToken); + await dbContext.WorkflowTaskEvents.AddRangeAsync(taskEvents, cancellationToken); + } + + await UpsertProjectionWorkflowInstancesAsync( + instance.WorkflowName, + instance.WorkflowVersion, + updatedBusinessReference, + completionPlan.WorkflowState, + completionPlan.NextTasks, + now, + cancellationToken); + + if (!string.IsNullOrWhiteSpace(completedProjectionWorkflowInstanceId) + && !completionPlan.NextTasks.Any(x => + string.Equals( + TryReadProjectionWorkflowInstanceId(x.Payload), + completedProjectionWorkflowInstanceId, + StringComparison.OrdinalIgnoreCase))) + { + await CompleteProjectionWorkflowInstanceAsync( + completedProjectionWorkflowInstanceId, + task.WorkflowName, + task.WorkflowVersion, + updatedBusinessReference, + completionPlan.WorkflowState, + now, + cancellationToken); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return MapTaskSummary(task); + } + + public async Task ApplyRuntimeProgressAsync( + string workflowInstanceId, + WorkflowTaskCompletionPlan progressPlan, + WorkflowBusinessReference? businessReference, + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var instance = await dbContext.WorkflowInstances + .SingleAsync(x => x.WorkflowInstanceId == workflowInstanceId, cancellationToken); + var updatedBusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference( + businessReference ?? DeserializeBusinessReference(instance.BusinessReferenceKey, instance.BusinessReferenceJson)); + var serializedBusinessReference = SerializeBusinessReference(updatedBusinessReference); + var businessReferenceKey = updatedBusinessReference?.Key; + + instance.StateJson = Serialize(progressPlan.WorkflowState); + instance.BusinessReferenceKey = businessReferenceKey; + instance.BusinessReferenceJson = serializedBusinessReference; + instance.Status = progressPlan.InstanceStatus; + instance.CompletedOnUtc = ResolveCompletedOnUtc(progressPlan.InstanceStatus, now); + instance.StaleAfterUtc = ResolveOpenStaleAfterUtc(progressPlan.InstanceStatus, now); + instance.PurgeAfterUtc = ResolvePurgeAfterUtc(progressPlan.InstanceStatus, now); + + var existingTasks = await dbContext.WorkflowTasks + .Where(x => x.WorkflowInstanceId == workflowInstanceId) + .ToListAsync(cancellationToken); + foreach (var existingTask in existingTasks) + { + existingTask.BusinessReferenceKey = businessReferenceKey; + existingTask.BusinessReferenceJson = serializedBusinessReference; + } + + await UpsertProjectionWorkflowInstancesAsync( + instance.WorkflowName, + instance.WorkflowVersion, + updatedBusinessReference, + progressPlan.WorkflowState, + progressPlan.NextTasks, + now, + cancellationToken); + + if (progressPlan.NextTasks.Count > 0) + { + var workflowRoles = existingTasks.Count > 0 + ? DeserializeStringArray(existingTasks[0].WorkflowRolesJson) + : []; + var nextTaskIds = await GetNextIdsAsync( + WorkflowTasksSequence, + progressPlan.NextTasks.Count, + dbContext.WorkflowTasks.Select(x => x.Id), + cancellationToken); + var nextEventIds = await GetNextIdsAsync( + WorkflowTaskEventsSequence, + progressPlan.NextTasks.Count, + dbContext.WorkflowTaskEvents.Select(x => x.Id), + cancellationToken); + var (taskRows, taskEvents) = CreateTaskRows( + instance.WorkflowName, + instance.WorkflowVersion, + workflowInstanceId, + updatedBusinessReference, + workflowRoles, + progressPlan.NextTasks, + now, + nextTaskIds, + nextEventIds); + + await dbContext.WorkflowTasks.AddRangeAsync(taskRows, cancellationToken); + await dbContext.WorkflowTaskEvents.AddRangeAsync(taskEvents, cancellationToken); + } + + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task> GetInstancesAsync( + WorkflowInstancesGetRequest request, + CancellationToken cancellationToken = default) + { + var query = dbContext.WorkflowInstances.AsNoTracking().AsQueryable(); + + if (!string.IsNullOrWhiteSpace(request.WorkflowName)) + { + query = query.Where(x => x.WorkflowName == request.WorkflowName); + } + + if (!string.IsNullOrWhiteSpace(request.WorkflowVersion)) + { + query = query.Where(x => x.WorkflowVersion == request.WorkflowVersion); + } + + if (!string.IsNullOrWhiteSpace(request.BusinessReferenceKey)) + { + query = query.Where(x => x.BusinessReferenceKey == request.BusinessReferenceKey); + } + + if (!string.IsNullOrWhiteSpace(request.Status)) + { + query = query.Where(x => x.Status == request.Status); + } + + var instances = await query + .OrderByDescending(x => x.CreatedOnUtc) + .Select(x => new WorkflowInstanceSummary + { + WorkflowInstanceId = x.WorkflowInstanceId, + WorkflowName = x.WorkflowName, + WorkflowVersion = x.WorkflowVersion, + BusinessReference = DeserializeBusinessReference(x.BusinessReferenceKey, x.BusinessReferenceJson), + Status = x.Status, + CreatedOnUtc = x.CreatedOnUtc, + CompletedOnUtc = x.CompletedOnUtc, + }) + .ToArrayAsync(cancellationToken); + + return instances + .Where(x => x.BusinessReference.MatchesBusinessReferenceFilter(request.BusinessReferenceKey, request.BusinessReferenceParts)) + .ToArray(); + } + + public async Task GetInstanceAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default) + { + return await dbContext.WorkflowInstances + .AsNoTracking() + .Where(x => x.WorkflowInstanceId == workflowInstanceId) + .Select(x => new WorkflowInstanceSummary + { + WorkflowInstanceId = x.WorkflowInstanceId, + WorkflowName = x.WorkflowName, + WorkflowVersion = x.WorkflowVersion, + BusinessReference = DeserializeBusinessReference(x.BusinessReferenceKey, x.BusinessReferenceJson), + Status = x.Status, + CreatedOnUtc = x.CreatedOnUtc, + CompletedOnUtc = x.CompletedOnUtc, + }) + .SingleOrDefaultAsync(cancellationToken); + } + + public async Task GetInstanceDetailsAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default) + { + var instanceEntity = await dbContext.WorkflowInstances + .AsNoTracking() + .SingleOrDefaultAsync(x => x.WorkflowInstanceId == workflowInstanceId, cancellationToken); + + if (instanceEntity is null) + { + return null; + } + + var taskEntities = await dbContext.WorkflowTasks + .AsNoTracking() + .Where(x => x.WorkflowInstanceId == workflowInstanceId) + .OrderBy(x => x.CreatedOnUtc) + .ToListAsync(cancellationToken); + if (taskEntities.Count == 0) + { + var projectionPayloadMarker = BuildProjectionWorkflowInstancePayloadMarker(workflowInstanceId); + taskEntities = await dbContext.WorkflowTasks + .AsNoTracking() + .Where(x => + x.WorkflowName == instanceEntity.WorkflowName + && x.WorkflowVersion == instanceEntity.WorkflowVersion + && x.PayloadJson.Contains(projectionPayloadMarker)) + .OrderBy(x => x.CreatedOnUtc) + .ToListAsync(cancellationToken); + } + var taskLookup = taskEntities.ToDictionary(x => x.WorkflowTaskId, StringComparer.OrdinalIgnoreCase); + var taskIds = taskLookup.Keys.ToArray(); + + var taskEventEntities = taskIds.Length == 0 + ? [] + : await dbContext.WorkflowTaskEvents + .AsNoTracking() + .Where(x => taskIds.Contains(x.WorkflowTaskId)) + .OrderBy(x => x.CreatedOnUtc) + .ToArrayAsync(cancellationToken); + var taskEvents = taskEventEntities + .Select(x => new WorkflowTaskEventSummary + { + WorkflowTaskId = x.WorkflowTaskId, + TaskName = taskLookup.TryGetValue(x.WorkflowTaskId, out var task) ? task.TaskName : null, + EventType = x.EventType, + ActorId = x.ActorId, + Payload = DeserializeObjectDictionary(x.PayloadJson), + CreatedOnUtc = x.CreatedOnUtc, + }) + .ToArray(); + + return new WorkflowInstanceProjectionDetails + { + Instance = new WorkflowInstanceSummary + { + WorkflowInstanceId = instanceEntity.WorkflowInstanceId, + WorkflowName = instanceEntity.WorkflowName, + WorkflowVersion = instanceEntity.WorkflowVersion, + BusinessReference = DeserializeBusinessReference(instanceEntity.BusinessReferenceKey, instanceEntity.BusinessReferenceJson), + Status = instanceEntity.Status, + CreatedOnUtc = instanceEntity.CreatedOnUtc, + CompletedOnUtc = instanceEntity.CompletedOnUtc, + }, + WorkflowState = DeserializeObjectDictionary(instanceEntity.StateJson), + Tasks = taskEntities.Select(MapTaskSummary).ToArray(), + TaskEvents = taskEvents, + }; + } + + private async Task AddTaskEventAsync( + string workflowTaskId, + string eventType, + string? actorId, + object payload, + DateTime createdOnUtc, + CancellationToken cancellationToken) + { + var entity = new WorkflowTaskEvent + { + Id = (await GetNextIdsAsync( + WorkflowTaskEventsSequence, + 1, + dbContext.WorkflowTaskEvents.Select(x => x.Id), + cancellationToken))[0], + WorkflowTaskId = workflowTaskId, + EventType = eventType, + ActorId = actorId, + PayloadJson = Serialize(payload), + CreatedOnUtc = createdOnUtc, + }; + + await dbContext.WorkflowTaskEvents.AddAsync(entity, cancellationToken); + } + + private (List TaskRows, List TaskEvents) CreateTaskRows( + string workflowName, + string workflowVersion, + string workflowInstanceId, + WorkflowBusinessReference? businessReference, + IReadOnlyCollection workflowRoles, + IReadOnlyCollection taskPlans, + DateTime createdOnUtc, + IReadOnlyList taskIds, + IReadOnlyList eventIds) + { + var taskRows = new List(); + var taskEvents = new List(); + var taskIndex = 0; + var eventIndex = 0; + + foreach (var taskPlan in taskPlans) + { + var normalizedWorkflowRoles = workflowRoleResolutionService.NormalizeRoles( + taskPlan.WorkflowRoles.Count > 0 ? taskPlan.WorkflowRoles : workflowRoles); + var taskRoles = workflowRoleResolutionService.NormalizeRoles(taskPlan.TaskRoles); + var runtimeRoles = workflowRoleResolutionService.NormalizeRoles(taskPlan.RuntimeRoles); + var effectiveRoles = workflowRoleResolutionService.ResolveEffectiveRoles(normalizedWorkflowRoles, taskRoles, runtimeRoles); + var workflowTaskId = $"wft-{Guid.NewGuid():N}"; + + taskRows.Add(new WorkflowTaskProjection + { + Id = taskIds[taskIndex++], + WorkflowTaskId = workflowTaskId, + WorkflowInstanceId = workflowInstanceId, + WorkflowName = taskPlan.WorkflowName ?? workflowName, + WorkflowVersion = taskPlan.WorkflowVersion ?? workflowVersion, + TaskName = taskPlan.TaskName, + TaskType = taskPlan.TaskType, + Route = taskPlan.Route, + BusinessReferenceKey = businessReference?.Key, + BusinessReferenceJson = SerializeBusinessReference(businessReference), + Status = WorkflowTaskStatuses.Open, + WorkflowRolesJson = Serialize(normalizedWorkflowRoles), + TaskRolesJson = Serialize(taskRoles), + RuntimeRolesJson = Serialize(runtimeRoles), + EffectiveRolesJson = Serialize(effectiveRoles), + PayloadJson = Serialize(taskPlan.Payload), + CreatedOnUtc = createdOnUtc, + StaleAfterUtc = createdOnUtc.AddDays(retention.OpenStaleAfterDays), + }); + + taskEvents.Add(new WorkflowTaskEvent + { + Id = eventIds[eventIndex++], + WorkflowTaskId = workflowTaskId, + EventType = WorkflowTaskEventTypes.Created, + PayloadJson = Serialize(new + { + taskPlan.TaskName, + taskPlan.TaskType, + taskPlan.Route, + Payload = ToPublicTaskPayload(taskPlan.Payload), + }), + CreatedOnUtc = createdOnUtc, + }); + } + + return (taskRows, taskEvents); + } + + private static WorkflowTaskSummary MapTaskSummary(WorkflowTaskProjection task) + { + return new WorkflowTaskSummary + { + WorkflowTaskId = task.WorkflowTaskId, + WorkflowInstanceId = task.WorkflowInstanceId, + WorkflowName = task.WorkflowName, + WorkflowVersion = task.WorkflowVersion, + TaskName = task.TaskName, + TaskType = task.TaskType, + Route = task.Route, + BusinessReference = DeserializeBusinessReference(task.BusinessReferenceKey, task.BusinessReferenceJson), + Assignee = task.Assignee, + Status = task.Status, + WorkflowRoles = DeserializeStringArray(task.WorkflowRolesJson), + TaskRoles = DeserializeStringArray(task.TaskRolesJson), + RuntimeRoles = DeserializeStringArray(task.RuntimeRolesJson), + EffectiveRoles = DeserializeStringArray(task.EffectiveRolesJson), + Payload = DeserializePublicTaskPayload(task.PayloadJson), + CreatedOnUtc = task.CreatedOnUtc, + CompletedOnUtc = task.CompletedOnUtc, + StaleAfterUtc = task.StaleAfterUtc, + PurgeAfterUtc = task.PurgeAfterUtc, + }; + } + + private static DateTime? ResolveCompletedOnUtc(string instanceStatus, DateTime now) + { + return string.Equals(instanceStatus, WorkflowInstanceStatuses.Completed, StringComparison.OrdinalIgnoreCase) + ? now + : null; + } + + private DateTime? ResolveOpenStaleAfterUtc(string instanceStatus, DateTime now) + { + return string.Equals(instanceStatus, WorkflowInstanceStatuses.Completed, StringComparison.OrdinalIgnoreCase) + ? null + : now.AddDays(retention.OpenStaleAfterDays); + } + + private DateTime? ResolvePurgeAfterUtc(string instanceStatus, DateTime now) + { + return string.Equals(instanceStatus, WorkflowInstanceStatuses.Completed, StringComparison.OrdinalIgnoreCase) + ? now.AddDays(retention.CompletedPurgeAfterDays) + : null; + } + + private static string Serialize(object value) + { + return JsonSerializer.Serialize(value, SerializerOptions); + } + + private static string? SerializeBusinessReference(WorkflowBusinessReference? businessReference) + { + var normalizedReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + return normalizedReference is null ? null : Serialize(normalizedReference); + } + + private static IReadOnlyCollection DeserializeStringArray(string value) + { + return JsonSerializer.Deserialize(value, SerializerOptions) ?? []; + } + + private static IReadOnlyDictionary DeserializeJsonDictionary(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new Dictionary(); + } + + return JsonSerializer.Deserialize>(value, SerializerOptions) + ?? new Dictionary(); + } + + private static IDictionary DeserializeObjectDictionary(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new Dictionary(); + } + + return JsonSerializer.Deserialize>(value, SerializerOptions) + ?? new Dictionary(); + } + + private static IDictionary DeserializePublicTaskPayload(string value) + { + var payload = DeserializeObjectDictionary(value); + payload.Remove(WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey); + return payload; + } + + private static WorkflowBusinessReference? DeserializeBusinessReference(string? key, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference + { + Key = key, + }); + } + + var businessReference = JsonSerializer.Deserialize(value, SerializerOptions); + if (businessReference is null) + { + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference + { + Key = key, + }); + } + + if (string.IsNullOrWhiteSpace(businessReference.Key) && !string.IsNullOrWhiteSpace(key)) + { + businessReference = businessReference with { Key = key }; + } + + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + } + + private async Task UpsertProjectionWorkflowInstancesAsync( + string rootWorkflowName, + string rootWorkflowVersion, + WorkflowBusinessReference? businessReference, + IReadOnlyDictionary workflowState, + IReadOnlyCollection taskPlans, + DateTime now, + CancellationToken cancellationToken) + { + var projectionTaskGroups = taskPlans + .Select(taskPlan => new + { + TaskPlan = taskPlan, + ProjectionWorkflowInstanceId = TryReadProjectionWorkflowInstanceId(taskPlan.Payload), + }) + .Where(x => !string.IsNullOrWhiteSpace(x.ProjectionWorkflowInstanceId)) + .GroupBy(x => x.ProjectionWorkflowInstanceId!, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (projectionTaskGroups.Length == 0) + { + return; + } + + var projectionIds = projectionTaskGroups.Select(x => x.Key).ToArray(); + var existingProjectionInstances = await dbContext.WorkflowInstances + .Where(x => projectionIds.Contains(x.WorkflowInstanceId)) + .ToDictionaryAsync(x => x.WorkflowInstanceId, StringComparer.OrdinalIgnoreCase, cancellationToken); + + foreach (var projectionTaskGroup in projectionTaskGroups) + { + var firstTaskPlan = projectionTaskGroup.First().TaskPlan; + var workflowName = firstTaskPlan.WorkflowName ?? rootWorkflowName; + var workflowVersion = firstTaskPlan.WorkflowVersion ?? rootWorkflowVersion; + var normalizedBusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + + if (!existingProjectionInstances.TryGetValue(projectionTaskGroup.Key, out var projectionInstance)) + { + projectionInstance = new WorkflowInstanceProjection + { + Id = await GetNextWorkflowInstanceProjectionIdAsync(cancellationToken), + WorkflowInstanceId = projectionTaskGroup.Key, + CreatedOnUtc = now, + }; + existingProjectionInstances[projectionTaskGroup.Key] = projectionInstance; + await dbContext.WorkflowInstances.AddAsync(projectionInstance, cancellationToken); + } + + projectionInstance.WorkflowName = workflowName; + projectionInstance.WorkflowVersion = workflowVersion; + projectionInstance.BusinessReferenceKey = normalizedBusinessReference?.Key; + projectionInstance.BusinessReferenceJson = SerializeBusinessReference(normalizedBusinessReference); + projectionInstance.Status = WorkflowInstanceStatuses.Open; + projectionInstance.StateJson = Serialize(workflowState); + projectionInstance.CompletedOnUtc = null; + projectionInstance.StaleAfterUtc = ResolveOpenStaleAfterUtc(WorkflowInstanceStatuses.Open, now); + projectionInstance.PurgeAfterUtc = null; + } + } + + private async Task CompleteProjectionWorkflowInstanceAsync( + string projectionWorkflowInstanceId, + string workflowName, + string workflowVersion, + WorkflowBusinessReference? businessReference, + IReadOnlyDictionary workflowState, + DateTime now, + CancellationToken cancellationToken) + { + var projectionInstance = await dbContext.WorkflowInstances + .SingleOrDefaultAsync(x => x.WorkflowInstanceId == projectionWorkflowInstanceId, cancellationToken); + var normalizedBusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference); + + if (projectionInstance is null) + { + projectionInstance = new WorkflowInstanceProjection + { + Id = await GetNextWorkflowInstanceProjectionIdAsync(cancellationToken), + WorkflowInstanceId = projectionWorkflowInstanceId, + CreatedOnUtc = now, + }; + await dbContext.WorkflowInstances.AddAsync(projectionInstance, cancellationToken); + } + + projectionInstance.WorkflowName = workflowName; + projectionInstance.WorkflowVersion = workflowVersion; + projectionInstance.BusinessReferenceKey = normalizedBusinessReference?.Key; + projectionInstance.BusinessReferenceJson = SerializeBusinessReference(normalizedBusinessReference); + projectionInstance.Status = WorkflowInstanceStatuses.Completed; + projectionInstance.StateJson = Serialize(workflowState); + projectionInstance.CompletedOnUtc = now; + projectionInstance.StaleAfterUtc = null; + projectionInstance.PurgeAfterUtc = ResolvePurgeAfterUtc(WorkflowInstanceStatuses.Completed, now); + } + + private static IReadOnlyDictionary ToPublicTaskPayload( + IReadOnlyDictionary payload) + { + return payload + .Where(x => !string.Equals( + x.Key, + WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey, + StringComparison.OrdinalIgnoreCase)) + .ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase); + } + + private static string? TryReadProjectionWorkflowInstanceId(IReadOnlyDictionary payload) + { + if (!payload.TryGetValue(WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey, out var value) + || value.ValueKind != JsonValueKind.String) + { + return null; + } + + return value.GetString(); + } + + private static string? TryReadProjectionWorkflowInstanceId(string payloadJson) + { + if (string.IsNullOrWhiteSpace(payloadJson)) + { + return null; + } + + var payload = JsonSerializer.Deserialize>(payloadJson, SerializerOptions); + if (payload is null) + { + return null; + } + + return TryReadProjectionWorkflowInstanceId(payload); + } + + private static string BuildProjectionWorkflowInstancePayloadMarker(string projectionWorkflowInstanceId) + { + return $"\"{WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey}\":\"{projectionWorkflowInstanceId}\""; + } + + private async Task GetNextWorkflowInstanceProjectionIdAsync(CancellationToken cancellationToken) + { + var nextId = await GetNextIdAsync( + WorkflowInstancesSequence, + dbContext.WorkflowInstances.Select(x => x.Id), + cancellationToken); + var nextLocalId = dbContext.WorkflowInstances.Local.Count == 0 + ? 1M + : dbContext.WorkflowInstances.Local.Max(x => x.Id) + 1M; + + return Math.Max(nextId, nextLocalId); + } + + private async Task> GetNextIdsAsync( + string sequenceName, + int count, + IQueryable query, + CancellationToken cancellationToken) + { + if (count <= 0) + { + return []; + } + + if (string.Equals(dbContext.Database.ProviderName, "Oracle.EntityFrameworkCore", StringComparison.Ordinal)) + { + var values = new decimal[count]; + for (var index = 0; index < count; index++) + { + values[index] = await GetNextOracleSequenceValueAsync(sequenceName, cancellationToken); + } + + return values; + } + + var firstValue = await GetNextIdAsync(sequenceName, query, cancellationToken); + return Enumerable.Range(0, count) + .Select(index => firstValue + index) + .ToArray(); + } + + private async Task GetNextIdAsync( + string sequenceName, + IQueryable query, + CancellationToken cancellationToken) + { + if (string.Equals(dbContext.Database.ProviderName, "Oracle.EntityFrameworkCore", StringComparison.Ordinal)) + { + return await GetNextOracleSequenceValueAsync(sequenceName, cancellationToken); + } + + var hasRows = await query.AnyAsync(cancellationToken); + + if (!hasRows) + { + return 1M; + } + + var currentMax = await query.MaxAsync(cancellationToken); + return currentMax + 1M; + } + + private async Task GetNextOracleSequenceValueAsync( + string sequenceName, + CancellationToken cancellationToken) + { + var connection = dbContext.Database.GetDbConnection(); + var shouldCloseConnection = connection.State != ConnectionState.Open; + if (shouldCloseConnection) + { + await connection.OpenAsync(cancellationToken); + } + + try + { + using var command = connection.CreateCommand(); + command.CommandText = BuildOracleSequenceQuery(sequenceName); + command.Transaction = dbContext.Database.CurrentTransaction?.GetDbTransaction(); + + var result = await command.ExecuteScalarAsync(cancellationToken) + ?? throw new InvalidOperationException($"Oracle sequence {sequenceName} did not return a value."); + + return result switch + { + decimal value => value, + long value => value, + int value => value, + short value => value, + byte value => value, + string text => decimal.Parse(text, CultureInfo.InvariantCulture), + _ => Convert.ToDecimal(result, CultureInfo.InvariantCulture), + }; + } + finally + { + if (shouldCloseConnection) + { + await connection.CloseAsync(); + } + } + } + + private static string BuildOracleSequenceQuery(string sequenceName) + { + return sequenceName switch + { + WorkflowInstancesSequence => "SELECT SRD_WFKLW.WF_INSTANCES_SEQ.NEXTVAL FROM DUAL", + WorkflowTasksSequence => "SELECT SRD_WFKLW.WF_TASKS_SEQ.NEXTVAL FROM DUAL", + WorkflowTaskEventsSequence => "SELECT SRD_WFKLW.WF_TASK_EVENTS_SEQ.NEXTVAL FROM DUAL", + _ => throw new InvalidOperationException($"Unsupported Oracle sequence {sequenceName}.") + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/DeclarativeWorkflowExecutionHandler.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/DeclarativeWorkflowExecutionHandler.cs new file mode 100644 index 000000000..8862b4b4e --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/DeclarativeWorkflowExecutionHandler.cs @@ -0,0 +1,1617 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Engine.Exceptions; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Engine.Constants; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class DeclarativeWorkflowExecutionHandler( + IWorkflowMicroserviceTransport microserviceTransport, + IWorkflowLegacyRabbitTransport legacyRabbitTransport, + IWorkflowGraphqlTransport graphqlTransport, + IWorkflowHttpTransport httpTransport, + IWorkflowFunctionRuntime workflowFunctionRuntime, + IWorkflowExecutionHandlerCatalog workflowExecutionHandlerCatalog, + IWorkflowRegistrationCatalog workflowRegistrationCatalog) + : IWorkflowExecutionHandler, IDeclarativeWorkflowResumeHandler, IWorkflowSignalResumableExecutionHandler + where TWorkflow : class, IDeclarativeWorkflow, new() + where TStartRequest : class +{ + private const string WorkflowCompletedStatus = "Completed"; + private const string WorkflowOpenStatus = "Open"; + private const string WorkflowNamePayloadKey = "workflowName"; + private const string WorkflowVersionPayloadKey = "workflowVersion"; + private const string ResumePointerResumeStateKey = "resumePointer"; + private const string ResultKeyResumeStateKey = "resultKey"; + private const string SubWorkflowFramesStateKey = "__serdica.subWorkflowFrames"; + private const string ProjectionWorkflowInstanceIdStateKey = "__serdica.projectionWorkflowInstanceId"; + private static readonly TWorkflow Workflow = new(); + private readonly WorkflowInlineStepServices inlineStepServices = + new(microserviceTransport, legacyRabbitTransport, graphqlTransport, httpTransport); + + public Task StartAsync( + WorkflowStartExecutionContext context, + CancellationToken cancellationToken = default) + { + var startRequest = context.GetRequiredStartRequest(); + var workflowState = Workflow.Spec.InitializeStateWithRuntime(startRequest, workflowFunctionRuntime); + var executionContext = new WorkflowSpecExecutionContext( + Workflow.WorkflowName, + startRequest, + workflowState, + context.Payload, + context.BusinessReference, + workflowFunctionRuntime); + + if (!string.IsNullOrWhiteSpace(Workflow.Spec.InitialTaskName)) + { + var initialTask = BuildTaskPlan( + Workflow.Spec.GetRequiredTask(Workflow.Spec.InitialTaskName), + executionContext, + []); + + return Task.FromResult(new WorkflowStartExecutionPlan + { + InstanceStatus = WorkflowOpenStatus, + BusinessReference = executionContext.BusinessReference, + WorkflowState = workflowState, + Tasks = [initialTask], + }); + } + + return StartFromSequenceAsync( + workflowState, + executionContext, + WorkflowExecutionLocation.InitialSequence(), + cancellationToken); + } + + public async Task CompleteTaskAsync( + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default) + { + var workflowState = context.WorkflowState.CloneJson(); + var executionContext = new WorkflowSpecExecutionContext( + Workflow.WorkflowName, + null, + workflowState, + context.Payload, + context.CurrentTask.BusinessReference, + workflowFunctionRuntime); + var task = Workflow.Spec.GetRequiredTask(context.CurrentTask.TaskName); + var continuations = new List(); + var terminalResult = await ExecuteSequenceAsync( + task.OnComplete, + executionContext, + WorkflowExecutionLocation.TaskOnComplete(task.TaskName), + continuations, + cancellationToken); + + if (terminalResult is null) + { + throw new InvalidOperationException( + $"Declarative workflow '{Workflow.WorkflowName}' did not reach a terminal step for task '{task.TaskName}'."); + } + + return await FinalizeNestedCompletionAsync( + BuildCompletionPlan(workflowState, terminalResult), + cancellationToken); + } + + Task IDeclarativeWorkflowResumeHandler.ResumeAsync( + WorkflowResumeExecutionContext context, + CancellationToken cancellationToken) + { + return ResumeNestedAsync(context, cancellationToken); + } + + Task IWorkflowSignalResumableExecutionHandler.ResumeSignalAsync( + WorkflowSignalResumeContext context, + CancellationToken cancellationToken) + { + return ResumeSignalAsync(context, cancellationToken); + } + + private async Task ResumeNestedAsync( + WorkflowResumeExecutionContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.ResumePointer is not WorkflowResumePointer resumePointer) + { + throw new InvalidOperationException( + $"Declarative workflow '{Workflow.WorkflowName}' cannot resume with a non-declarative resume pointer."); + } + + var workflowState = context.WorkflowState.CloneJson(); + var publicSubWorkflowState = StripInternalState(context.SubWorkflowState); + MergeSubWorkflowState(workflowState, publicSubWorkflowState); + + if (!string.IsNullOrWhiteSpace(context.ResultKey)) + { + workflowState.Assign(context.ResultKey, publicSubWorkflowState); + } + + var executionContext = new WorkflowSpecExecutionContext( + Workflow.WorkflowName, + null, + workflowState, + new Dictionary(StringComparer.OrdinalIgnoreCase), + context.BusinessReference, + workflowFunctionRuntime); + var continuations = new List(context.Continuations); + var terminalResult = await ResumeAsync( + resumePointer, + executionContext, + continuations, + cancellationToken); + + return await FinalizeNestedCompletionAsync( + BuildCompletionPlan( + workflowState, + terminalResult ?? new WorkflowExecutionTerminalResult( + WorkflowCompletedStatus, + executionContext.BusinessReference, + [], + [], + continuations.ToArray())), + cancellationToken); + } + + private async Task StartFromSequenceAsync( + Dictionary workflowState, + WorkflowSpecExecutionContext executionContext, + WorkflowExecutionLocation location, + CancellationToken cancellationToken) + { + var continuations = new List(); + var terminalResult = await ExecuteSequenceAsync( + Workflow.Spec.InitialSequence, + executionContext, + location, + continuations, + cancellationToken); + + if (terminalResult is null) + { + throw new InvalidOperationException( + $"Declarative workflow '{Workflow.WorkflowName}' did not reach a terminal start step."); + } + + return new WorkflowStartExecutionPlan + { + InstanceStatus = terminalResult.InstanceStatus, + BusinessReference = terminalResult.BusinessReference, + WorkflowState = workflowState, + Tasks = terminalResult.NextTasks, + PendingSignals = terminalResult.PendingSignals, + Continuations = terminalResult.Continuations, + }; + } + + private async Task ExecuteSequenceAsync( + WorkflowStepSequence sequence, + WorkflowSpecExecutionContext context, + WorkflowExecutionLocation location, + List continuations, + CancellationToken cancellationToken, + int startIndex = 0) + { + var steps = sequence.Steps.ToArray(); + for (var index = startIndex; index < steps.Length; index++) + { + var step = steps[index]; + + switch (step) + { + case WorkflowStateAssignmentStepDefinition assignment: + ApplyStateAssignment(context, assignment); + break; + case WorkflowBusinessReferenceAssignmentStepDefinition businessReferenceAssignment: + context.SetBusinessReference(businessReferenceAssignment.BusinessReferenceFactory(context)); + break; + case WorkflowMicroserviceCallStepDefinition microserviceCall: + { + var result = await ExecuteMicroserviceAsync( + context, + microserviceCall, + location, + continuations, + cancellationToken); + if (result is not null) + { + return result; + } + + break; + } + case WorkflowLegacyRabbitCallStepDefinition legacyRabbitCall: + { + var result = await ExecuteLegacyRabbitAsync( + context, + legacyRabbitCall, + location, + continuations, + cancellationToken); + if (result is not null) + { + return result; + } + + break; + } + case WorkflowGraphqlCallStepDefinition graphqlCall: + { + var result = await ExecuteGraphqlAsync( + context, + graphqlCall, + location, + continuations, + cancellationToken); + if (result is not null) + { + return result; + } + + break; + } + case WorkflowHttpCallStepDefinition httpCall: + { + var result = await ExecuteHttpAsync( + context, + httpCall, + location, + continuations, + cancellationToken); + if (result is not null) + { + return result; + } + + break; + } + case WorkflowDecisionStepDefinition decision: + { + var isTrue = EvaluateCondition(context, decision.Condition); + var branch = isTrue + ? decision.WhenTrue + : decision.WhenFalse; + var branchResult = await ExecuteSequenceAsync( + branch, + context, + location.EnterBranch( + GetRequiredStepId(step), + isTrue ? WorkflowResumeBranchKind.True : WorkflowResumeBranchKind.False), + continuations, + cancellationToken); + if (branchResult is not null) + { + return branchResult; + } + + break; + } + case WorkflowConditionalStepDefinition conditional: + { + var isTrue = conditional.Condition.Evaluate(context); + var branch = isTrue + ? conditional.WhenTrue + : conditional.WhenElse; + var branchResult = await ExecuteSequenceAsync( + branch, + context, + location.EnterBranch( + GetRequiredStepId(step), + isTrue ? WorkflowResumeBranchKind.True : WorkflowResumeBranchKind.Else), + continuations, + cancellationToken); + if (branchResult is not null) + { + return branchResult; + } + + break; + } + case WorkflowActivateTaskStepDefinition activateTask: + { + var nextTask = BuildTaskPlan( + Workflow.Spec.GetRequiredTask(activateTask.TaskName), + context, + activateTask.RuntimeRolesFactory(context)); + return new WorkflowExecutionTerminalResult( + WorkflowOpenStatus, + context.BusinessReference, + [nextTask], + [], + continuations.ToArray()); + } + case WorkflowContinueWithStepDefinition continueWithStep: + continuations.Add(new WorkflowContinuationPlan + { + Request = continueWithStep.StartWorkflowRequestFactory(context), + }); + return new WorkflowExecutionTerminalResult( + WorkflowCompletedStatus, + context.BusinessReference, + [], + [], + continuations.ToArray()); + case WorkflowSubWorkflowStepDefinition subWorkflowStep: + { + var subWorkflowResult = await ExecuteSubWorkflowAsync( + context, + subWorkflowStep, + location.CreateResumePointer(index + 1), + continuations, + cancellationToken); + if (subWorkflowResult is not null) + { + return subWorkflowResult; + } + + break; + } + case WorkflowRepeatStepDefinition repeatStep: + { + var repeatResult = await ExecuteRepeatAsync( + context, + repeatStep, + location, + continuations, + cancellationToken); + if (repeatResult is not null) + { + return repeatResult; + } + + break; + } + case WorkflowInlineStepDefinition inlineStep: + { + var result = await ExecuteInlineAsync( + context, + inlineStep, + location, + continuations, + cancellationToken); + if (result is not null) + { + return result; + } + + break; + } + case WorkflowTimerStepDefinition timerStep: + return BuildTimerWaitResult(context, timerStep, location.CreateResumePointer(index + 1), continuations); + case WorkflowExternalSignalStepDefinition externalSignalStep: + return BuildExternalSignalWaitResult( + context, + externalSignalStep, + location.CreateResumePointer(index + 1), + continuations); + case WorkflowForkStepDefinition: + throw new NotSupportedException( + $"Declarative forks are not yet supported by the runtime interpreter for workflow '{Workflow.WorkflowName}'."); + case WorkflowCompleteStepDefinition: + return new WorkflowExecutionTerminalResult( + WorkflowCompletedStatus, + context.BusinessReference, + [], + [], + continuations.ToArray()); + default: + throw new InvalidOperationException( + $"Workflow step type '{step.GetType().FullName}' is not supported by the declarative interpreter."); + } + } + + return null; + } + + private static void ApplyStateAssignment( + WorkflowSpecExecutionContext context, + WorkflowStateAssignmentStepDefinition assignment) + { + var value = assignment.ValueFactory(context); + if (assignment.OnlyWhenHasValue && !HasValue(value)) + { + return; + } + + context.WorkflowState.Assign(assignment.Key, value); + } + + private async Task ResumeAsync( + WorkflowResumePointer pointer, + WorkflowSpecExecutionContext context, + List continuations, + CancellationToken cancellationToken) + { + return await ResumeAsync( + ResolveEntryPointSequence(pointer), + pointer, + context, + continuations, + cancellationToken, + 0); + } + + private async Task ResumeAsync( + WorkflowStepSequence sequence, + WorkflowResumePointer pointer, + WorkflowSpecExecutionContext context, + List continuations, + CancellationToken cancellationToken, + int branchDepth) + { + if (branchDepth >= pointer.BranchPath.Count) + { + return await ExecuteSequenceAsync( + sequence, + context, + new WorkflowExecutionLocation + { + EntryPointKind = pointer.EntryPointKind, + TaskName = pointer.TaskName, + BranchPath = pointer.BranchPath, + }, + continuations, + cancellationToken, + pointer.NextStepIndex); + } + + var branchPointer = pointer.BranchPath.ElementAt(branchDepth); + var steps = sequence.Steps.ToArray(); + var stepIndex = Array.FindIndex(steps, x => string.Equals(x.StepId, branchPointer.StepId, StringComparison.Ordinal)); + if (stepIndex < 0) + { + throw new InvalidOperationException( + $"Workflow '{Workflow.WorkflowName}' cannot resolve branch step '{branchPointer.StepId}' for resume."); + } + + var branchResult = await ResumeAsync( + ResolveNestedSequence(steps[stepIndex], branchPointer), + pointer, + context, + continuations, + cancellationToken, + branchDepth + 1); + + if (branchResult is not null) + { + return branchResult; + } + + if (steps[stepIndex] is WorkflowRepeatStepDefinition repeatStep + && branchPointer.BranchKind == WorkflowResumeBranchKind.Repeat) + { + return await ContinueRepeatAfterBodyAsync( + repeatStep, + branchPointer.BranchIndex ?? 0, + sequence, + pointer, + stepIndex, + context, + continuations, + cancellationToken); + } + + return await ExecuteSequenceAsync( + sequence, + context, + new WorkflowExecutionLocation + { + EntryPointKind = pointer.EntryPointKind, + TaskName = pointer.TaskName, + BranchPath = pointer.BranchPath.Take(branchDepth).ToArray(), + }, + continuations, + cancellationToken, + stepIndex + 1); + } + + private async Task ExecuteSubWorkflowAsync( + WorkflowSpecExecutionContext context, + WorkflowSubWorkflowStepDefinition step, + WorkflowResumePointer resumePointer, + List continuations, + CancellationToken cancellationToken) + { + var childRequest = step.StartWorkflowRequestFactory(context); + var childRegistration = workflowRegistrationCatalog.GetRegistration( + childRequest.WorkflowName, + childRequest.WorkflowVersion) + ?? throw new BaseResultException( + MessageKeys.WorkflowDefinitionNotFound, + childRequest.WorkflowName); + var childWorkflowVersion = childRequest.WorkflowVersion ?? childRegistration.Definition.WorkflowVersion; + var childHandler = workflowExecutionHandlerCatalog.GetHandler( + childRequest.WorkflowName, + childWorkflowVersion) + ?? throw new InvalidOperationException( + $"Workflow '{childRequest.WorkflowName}' does not have an execution handler."); + + if (childHandler is not IDeclarativeWorkflowResumeHandler) + { + throw new NotSupportedException( + $"Workflow '{childRequest.WorkflowName}' cannot be used as a synchronous sub workflow because it is not declarative."); + } + + var childStartRequest = childRegistration.BindStartRequest(childRequest.Payload); + var childBusinessReference = childRequest.BusinessReference + ?? childRegistration.ExtractBusinessReference(childStartRequest); + + var childPlan = await childHandler.StartAsync(new WorkflowStartExecutionContext + { + Registration = childRegistration, + Definition = childRegistration.Definition, + BusinessReference = childBusinessReference, + StartRequest = childStartRequest, + Payload = SerializePayload(childRequest.Payload), + }, cancellationToken); + + continuations.AddRange(childPlan.Continuations); + + if (childPlan.Tasks.Count > 0) + { + var childState = childPlan.WorkflowState.CloneJson(); + var projectionWorkflowInstanceId = EnsureProjectionWorkflowInstanceId(childState); + var frames = ReadSubWorkflowFrames(childState); + frames.Add(new WorkflowSubWorkflowFrame + { + WorkflowName = Workflow.WorkflowName, + WorkflowVersion = Workflow.WorkflowVersion, + BusinessReference = context.BusinessReference, + WorkflowState = context.WorkflowState.CloneJson(), + ResumePointer = resumePointer, + ResultKey = step.ResultKey, + }); + WriteSubWorkflowFrames(childState, frames); + ReplaceWorkflowState(context.WorkflowState, childState); + context.SetBusinessReference(childPlan.BusinessReference ?? childBusinessReference); + + return new WorkflowExecutionTerminalResult( + WorkflowOpenStatus, + context.BusinessReference, + AttachProjectionWorkflowInstanceId(childPlan.Tasks, projectionWorkflowInstanceId), + childPlan.PendingSignals, + continuations.ToArray()); + } + + if (!string.Equals(childPlan.InstanceStatus, WorkflowCompletedStatus, StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException( + $"Workflow '{childRequest.WorkflowName}' returned status '{childPlan.InstanceStatus}' without user tasks. This runtime path is not supported."); + } + + ApplyCompletedSubWorkflowState( + context, + step.ResultKey, + childPlan.WorkflowState, + childPlan.BusinessReference ?? childBusinessReference); + + return null; + } + + private async Task ExecuteMicroserviceAsync( + WorkflowSpecExecutionContext context, + WorkflowMicroserviceCallStepDefinition call, + WorkflowExecutionLocation location, + List continuations, + CancellationToken cancellationToken) + { + WorkflowMicroserviceResponse response; + try + { + response = await microserviceTransport.ExecuteAsync(new WorkflowMicroserviceRequest + { + MicroserviceName = call.MicroserviceName, + Command = call.Command, + Payload = call.PayloadFactory(context), + }, cancellationToken); + } + catch (TimeoutException) + { + var timeoutResult = await ExecuteTimeoutBranchAsync( + call, + call.FailureHandlers, + context, + location, + continuations, + cancellationToken); + if (timeoutResult is null && !HasTimeoutHandlingConfigured(call.FailureHandlers)) + { + throw; + } + + return timeoutResult; + } + + if (!response.Succeeded) + { + var failureResult = await ExecuteFailureBranchAsync( + call, + call.FailureHandlers, + context, + location, + continuations, + cancellationToken); + if (failureResult is null && !HasFailureHandlingConfigured(call.FailureHandlers)) + { + throw new BaseResultException( + MessageKeys.WorkflowTransportFailed, + call.MicroserviceName, + call.Command, + response.Error ?? "Transport request failed."); + } + + return failureResult; + } + + if (!string.IsNullOrWhiteSpace(call.ResultKey)) + { + context.SetResult(call.ResultKey, SerializeToJsonElement(response.Payload)); + } + + return null; + } + + private async Task ExecuteLegacyRabbitAsync( + WorkflowSpecExecutionContext context, + WorkflowLegacyRabbitCallStepDefinition call, + WorkflowExecutionLocation location, + List continuations, + CancellationToken cancellationToken) + { + WorkflowMicroserviceResponse response; + try + { + response = await legacyRabbitTransport.ExecuteAsync(new WorkflowLegacyRabbitRequest + { + Command = call.Command, + Mode = call.Mode, + Payload = call.PayloadFactory(context), + }, cancellationToken); + } + catch (TimeoutException) + { + var timeoutResult = await ExecuteTimeoutBranchAsync( + call, + call.FailureHandlers, + context, + location, + continuations, + cancellationToken); + if (timeoutResult is null && !HasTimeoutHandlingConfigured(call.FailureHandlers)) + { + throw; + } + + return timeoutResult; + } + + if (!response.Succeeded) + { + var failureResult = await ExecuteFailureBranchAsync( + call, + call.FailureHandlers, + context, + location, + continuations, + cancellationToken); + if (failureResult is null && !HasFailureHandlingConfigured(call.FailureHandlers)) + { + throw new BaseResultException( + MessageKeys.WorkflowTransportFailed, + "legacy-rabbit", + call.Command, + response.Error ?? "Transport request failed."); + } + + return failureResult; + } + + if (!string.IsNullOrWhiteSpace(call.ResultKey)) + { + context.SetResult(call.ResultKey, SerializeToJsonElement(response.Payload)); + } + + return null; + } + + private async Task ExecuteGraphqlAsync( + WorkflowSpecExecutionContext context, + WorkflowGraphqlCallStepDefinition call, + WorkflowExecutionLocation location, + List continuations, + CancellationToken cancellationToken) + { + WorkflowGraphqlResponse response; + try + { + response = await graphqlTransport.ExecuteAsync(new WorkflowGraphqlRequest + { + Target = call.Target, + Query = call.Query, + OperationName = call.OperationName, + Variables = call.VariablesFactory(context), + }, cancellationToken); + } + catch (TimeoutException) + { + var timeoutResult = await ExecuteTimeoutBranchAsync( + call, + call.FailureHandlers, + context, + location, + continuations, + cancellationToken); + if (timeoutResult is null && !HasTimeoutHandlingConfigured(call.FailureHandlers)) + { + throw; + } + + return timeoutResult; + } + + if (!response.Succeeded) + { + var failureResult = await ExecuteFailureBranchAsync( + call, + call.FailureHandlers, + context, + location, + continuations, + cancellationToken); + if (failureResult is null && !HasFailureHandlingConfigured(call.FailureHandlers)) + { + throw new BaseResultException( + MessageKeys.WorkflowTransportFailed, + call.Target, + call.OperationName ?? "graphql", + response.Error ?? "GraphQL request failed."); + } + + return failureResult; + } + + if (!string.IsNullOrWhiteSpace(call.ResultKey)) + { + context.SetResult(call.ResultKey, DeserializeJsonPayload(response.JsonPayload)); + } + + return null; + } + + private async Task ExecuteHttpAsync( + WorkflowSpecExecutionContext context, + WorkflowHttpCallStepDefinition call, + WorkflowExecutionLocation location, + List continuations, + CancellationToken cancellationToken) + { + WorkflowHttpResponse response; + try + { + response = await httpTransport.ExecuteAsync(new WorkflowHttpRequest + { + Target = call.Target, + Method = call.Method, + Path = call.Path, + Payload = call.PayloadFactory(context), + }, cancellationToken); + } + catch (TimeoutException) + { + var timeoutResult = await ExecuteTimeoutBranchAsync( + call, + call.FailureHandlers, + context, + location, + continuations, + cancellationToken); + if (timeoutResult is null && !HasTimeoutHandlingConfigured(call.FailureHandlers)) + { + throw; + } + + return timeoutResult; + } + + if (!response.Succeeded) + { + var failureResult = await ExecuteFailureBranchAsync( + call, + call.FailureHandlers, + context, + location, + continuations, + cancellationToken); + if (failureResult is null && !HasFailureHandlingConfigured(call.FailureHandlers)) + { + throw new BaseResultException( + MessageKeys.WorkflowTransportFailed, + call.Target, + $"{call.Method} {call.Path}", + response.Error ?? "HTTP request failed."); + } + + return failureResult; + } + + if (!string.IsNullOrWhiteSpace(call.ResultKey)) + { + context.SetResult(call.ResultKey, DeserializeJsonPayload(response.JsonPayload)); + } + + return null; + } + + private async Task ExecuteInlineAsync( + WorkflowSpecExecutionContext context, + WorkflowInlineStepDefinition step, + WorkflowExecutionLocation location, + List continuations, + CancellationToken cancellationToken) + { + try + { + await step.ExecuteAsync(context, inlineStepServices, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (TimeoutException) + { + var timeoutResult = await ExecuteTimeoutBranchAsync( + step, + step.FailureHandlers, + context, + location, + continuations, + cancellationToken); + if (timeoutResult is null && !HasTimeoutHandlingConfigured(step.FailureHandlers)) + { + throw; + } + + return timeoutResult; + } + catch (Exception) + { + var failureResult = await ExecuteFailureBranchAsync( + step, + step.FailureHandlers, + context, + location, + continuations, + cancellationToken); + if (failureResult is null && !HasFailureHandlingConfigured(step.FailureHandlers)) + { + throw; + } + + return failureResult; + } + + return null; + } + + private async Task ExecuteRepeatAsync( + WorkflowSpecExecutionContext context, + WorkflowRepeatStepDefinition step, + WorkflowExecutionLocation location, + List continuations, + CancellationToken cancellationToken) + { + var maxIterations = step.MaxIterationsFactory(context); + if (maxIterations <= 0) + { + return null; + } + + for (var iteration = 1; iteration <= maxIterations; iteration++) + { + ApplyRepeatIterationState(context, step, iteration); + + var bodyResult = await ExecuteSequenceAsync( + step.Body, + context, + location.EnterBranch(GetRequiredStepId(step), WorkflowResumeBranchKind.Repeat, iteration), + continuations, + cancellationToken); + if (bodyResult is not null) + { + return bodyResult; + } + + if (!ShouldContinueRepeat(context, step, iteration, maxIterations)) + { + break; + } + } + + return null; + } + + private async Task ContinueRepeatAfterBodyAsync( + WorkflowRepeatStepDefinition step, + int completedIteration, + WorkflowStepSequence sequence, + WorkflowResumePointer pointer, + int stepIndex, + WorkflowSpecExecutionContext context, + List continuations, + CancellationToken cancellationToken) + { + var maxIterations = step.MaxIterationsFactory(context); + if (completedIteration > 0) + { + ApplyRepeatIterationState(context, step, completedIteration); + } + + if (ShouldContinueRepeat(context, step, completedIteration, maxIterations)) + { + for (var iteration = completedIteration + 1; iteration <= maxIterations; iteration++) + { + ApplyRepeatIterationState(context, step, iteration); + + var bodyResult = await ExecuteSequenceAsync( + step.Body, + context, + new WorkflowExecutionLocation + { + EntryPointKind = pointer.EntryPointKind, + TaskName = pointer.TaskName, + BranchPath = pointer.BranchPath.Take(pointer.BranchPath.Count - 1) + .Append(new WorkflowResumeBranchPointer + { + StepId = GetRequiredStepId(step), + BranchKind = WorkflowResumeBranchKind.Repeat, + BranchIndex = iteration, + }) + .ToArray(), + }, + continuations, + cancellationToken); + if (bodyResult is not null) + { + return bodyResult; + } + + if (!ShouldContinueRepeat(context, step, iteration, maxIterations)) + { + break; + } + } + } + + return await ExecuteSequenceAsync( + sequence, + context, + new WorkflowExecutionLocation + { + EntryPointKind = pointer.EntryPointKind, + TaskName = pointer.TaskName, + BranchPath = pointer.BranchPath.Take(pointer.BranchPath.Count - 1).ToArray(), + }, + continuations, + cancellationToken, + stepIndex + 1); + } + + private async Task ExecuteFailureBranchAsync( + WorkflowStepDefinition step, + WorkflowFailureHandlers? failureHandlers, + WorkflowSpecExecutionContext context, + WorkflowExecutionLocation location, + List continuations, + CancellationToken cancellationToken) + { + if (failureHandlers is null || !failureHandlers.HasFailureBranch) + { + return null; + } + + return await ExecuteSequenceAsync( + failureHandlers.WhenFailure, + context, + location.EnterBranch(GetRequiredStepId(step), WorkflowResumeBranchKind.Failure), + continuations, + cancellationToken); + } + + private async Task ExecuteTimeoutBranchAsync( + WorkflowStepDefinition step, + WorkflowFailureHandlers? failureHandlers, + WorkflowSpecExecutionContext context, + WorkflowExecutionLocation location, + List continuations, + CancellationToken cancellationToken) + { + if (failureHandlers is null) + { + return null; + } + + if (failureHandlers.HasTimeoutBranch) + { + return await ExecuteSequenceAsync( + failureHandlers.WhenTimeout, + context, + location.EnterBranch(GetRequiredStepId(step), WorkflowResumeBranchKind.Timeout), + continuations, + cancellationToken); + } + + return await ExecuteFailureBranchAsync( + step, + failureHandlers, + context, + location, + continuations, + cancellationToken); + } + + private static bool HasFailureHandlingConfigured(WorkflowFailureHandlers? failureHandlers) + { + return failureHandlers is not null && failureHandlers.HasFailureBranch; + } + + private static bool HasTimeoutHandlingConfigured(WorkflowFailureHandlers? failureHandlers) + { + return failureHandlers is not null && (failureHandlers.HasTimeoutBranch || failureHandlers.HasFailureBranch); + } + + private async Task FinalizeNestedCompletionAsync( + WorkflowTaskCompletionPlan completionPlan, + CancellationToken cancellationToken) + { + if (!string.Equals(completionPlan.InstanceStatus, WorkflowCompletedStatus, StringComparison.OrdinalIgnoreCase)) + { + return completionPlan; + } + + var frames = ReadSubWorkflowFrames(completionPlan.WorkflowState); + if (frames.Count == 0) + { + return completionPlan; + } + + var frame = frames[^1]; + frames.RemoveAt(frames.Count - 1); + + var parentHandler = workflowExecutionHandlerCatalog.GetHandler(frame.WorkflowName, frame.WorkflowVersion) + as IDeclarativeWorkflowResumeHandler + ?? throw new NotSupportedException( + $"Workflow '{frame.WorkflowName}' cannot resume from a synchronous sub workflow."); + + var parentState = frame.WorkflowState.CloneJson(); + WriteSubWorkflowFrames(parentState, frames); + + return await parentHandler.ResumeAsync(new WorkflowResumeExecutionContext + { + WorkflowState = parentState, + BusinessReference = completionPlan.BusinessReference ?? frame.BusinessReference, + ResumePointer = frame.ResumePointer, + SubWorkflowState = completionPlan.WorkflowState, + ResultKey = frame.ResultKey, + Continuations = completionPlan.Continuations, + }, cancellationToken); + } + + private static WorkflowTaskCompletionPlan BuildCompletionPlan( + Dictionary workflowState, + WorkflowExecutionTerminalResult terminalResult) + { + return new WorkflowTaskCompletionPlan + { + InstanceStatus = terminalResult.InstanceStatus, + BusinessReference = terminalResult.BusinessReference, + WorkflowState = workflowState, + NextTasks = terminalResult.NextTasks, + PendingSignals = terminalResult.PendingSignals, + Continuations = terminalResult.Continuations, + }; + } + + private static WorkflowExecutionTaskPlan BuildTaskPlan( + WorkflowHumanTaskDefinition task, + WorkflowSpecExecutionContext context, + IReadOnlyCollection runtimeRoles) + { + var payload = task.BuildPayload(context) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!payload.ContainsKey(WorkflowNamePayloadKey)) + { + payload[WorkflowNamePayloadKey] = Workflow.WorkflowName.AsJsonElement(); + } + + if (!payload.ContainsKey(WorkflowVersionPayloadKey)) + { + payload[WorkflowVersionPayloadKey] = Workflow.WorkflowVersion.AsJsonElement(); + } + + if (!payload.ContainsKey(WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey) + && TryReadProjectionWorkflowInstanceId(context.WorkflowState, out var projectionWorkflowInstanceId)) + { + payload[WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey] = + JsonSerializer.SerializeToElement(projectionWorkflowInstanceId); + } + + return new WorkflowExecutionTaskPlan + { + WorkflowName = Workflow.WorkflowName, + WorkflowVersion = Workflow.WorkflowVersion, + WorkflowRoles = Workflow.WorkflowRoles, + TaskName = task.Descriptor.TaskName, + TaskType = task.Descriptor.TaskType, + Route = task.ResolveRoute(context), + TaskRoles = task.Descriptor.TaskRoles, + RuntimeRoles = runtimeRoles, + Payload = payload, + }; + } + + private static string EnsureProjectionWorkflowInstanceId(Dictionary workflowState) + { + if (TryReadProjectionWorkflowInstanceId(workflowState, out var existingProjectionWorkflowInstanceId)) + { + return existingProjectionWorkflowInstanceId!; + } + + var projectionWorkflowInstanceId = $"swf-{Guid.NewGuid():N}"; + workflowState[ProjectionWorkflowInstanceIdStateKey] = JsonSerializer.SerializeToElement(projectionWorkflowInstanceId); + return projectionWorkflowInstanceId; + } + + private static bool TryReadProjectionWorkflowInstanceId( + IReadOnlyDictionary workflowState, + out string? projectionWorkflowInstanceId) + { + projectionWorkflowInstanceId = null; + if (!workflowState.TryGetValue(ProjectionWorkflowInstanceIdStateKey, out var projectionWorkflowInstanceIdElement) + || projectionWorkflowInstanceIdElement.ValueKind != JsonValueKind.String) + { + return false; + } + + projectionWorkflowInstanceId = projectionWorkflowInstanceIdElement.GetString(); + return !string.IsNullOrWhiteSpace(projectionWorkflowInstanceId); + } + + private static IReadOnlyCollection AttachProjectionWorkflowInstanceId( + IReadOnlyCollection taskPlans, + string projectionWorkflowInstanceId) + { + return taskPlans + .Select(taskPlan => + { + if (taskPlan.Payload.ContainsKey(WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey)) + { + return taskPlan; + } + + var payload = new Dictionary(taskPlan.Payload, StringComparer.OrdinalIgnoreCase) + { + [WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey] = + JsonSerializer.SerializeToElement(projectionWorkflowInstanceId), + }; + return taskPlan with { Payload = payload }; + }) + .ToArray(); + } + + private static WorkflowStepSequence ResolveEntryPointSequence(WorkflowResumePointer pointer) + { + return pointer.EntryPointKind switch + { + WorkflowResumeEntryPointKind.InitialSequence => Workflow.Spec.InitialSequence, + WorkflowResumeEntryPointKind.TaskOnComplete when !string.IsNullOrWhiteSpace(pointer.TaskName) => + Workflow.Spec.GetRequiredTask(pointer.TaskName).OnComplete, + _ => throw new InvalidOperationException( + $"Workflow '{Workflow.WorkflowName}' cannot resolve resume entry point '{pointer.EntryPointKind}'."), + }; + } + + private static WorkflowStepSequence ResolveNestedSequence( + WorkflowStepDefinition step, + WorkflowResumeBranchPointer pointer) + { + if (TryResolveFailureHandlerSequence(step, pointer, out var failureHandlerSequence)) + { + return failureHandlerSequence; + } + + return step switch + { + WorkflowDecisionStepDefinition decision when pointer.BranchKind == WorkflowResumeBranchKind.True => + decision.WhenTrue, + WorkflowDecisionStepDefinition decision when pointer.BranchKind == WorkflowResumeBranchKind.False => + decision.WhenFalse, + WorkflowConditionalStepDefinition conditional when pointer.BranchKind == WorkflowResumeBranchKind.True => + conditional.WhenTrue, + WorkflowConditionalStepDefinition conditional when pointer.BranchKind == WorkflowResumeBranchKind.Else => + conditional.WhenElse, + WorkflowRepeatStepDefinition repeat when pointer.BranchKind == WorkflowResumeBranchKind.Repeat => + repeat.Body, + WorkflowForkStepDefinition fork + when pointer.BranchKind == WorkflowResumeBranchKind.Fork && pointer.BranchIndex is not null => + fork.Branches.ElementAt(pointer.BranchIndex.Value), + _ => throw new InvalidOperationException( + $"Workflow '{Workflow.WorkflowName}' cannot resolve nested branch '{pointer.BranchKind}' for step '{step.StepId}'."), + }; + } + + private static bool TryResolveFailureHandlerSequence( + WorkflowStepDefinition step, + WorkflowResumeBranchPointer pointer, + out WorkflowStepSequence sequence) + { + var failureHandlers = step switch + { + WorkflowMicroserviceCallStepDefinition microserviceCall => microserviceCall.FailureHandlers, + WorkflowLegacyRabbitCallStepDefinition legacyRabbitCall => legacyRabbitCall.FailureHandlers, + WorkflowGraphqlCallStepDefinition graphqlCall => graphqlCall.FailureHandlers, + WorkflowHttpCallStepDefinition httpCall => httpCall.FailureHandlers, + WorkflowInlineStepDefinition inlineStep => inlineStep.FailureHandlers, + _ => null, + }; + + if (failureHandlers is not null) + { + if (pointer.BranchKind == WorkflowResumeBranchKind.Failure && failureHandlers.HasFailureBranch) + { + sequence = failureHandlers.WhenFailure; + return true; + } + + if (pointer.BranchKind == WorkflowResumeBranchKind.Timeout && failureHandlers.HasTimeoutBranch) + { + sequence = failureHandlers.WhenTimeout; + return true; + } + } + + sequence = WorkflowStepSequence.Empty; + return false; + } + + private static void ApplyCompletedSubWorkflowState( + WorkflowSpecExecutionContext context, + string? resultKey, + IReadOnlyDictionary childWorkflowState, + WorkflowBusinessReference? childBusinessReference) + { + var publicSubWorkflowState = StripInternalState(childWorkflowState); + MergeSubWorkflowState(context.WorkflowState, publicSubWorkflowState); + + if (!string.IsNullOrWhiteSpace(resultKey)) + { + context.WorkflowState.Assign(resultKey, publicSubWorkflowState); + } + + context.SetBusinessReference(childBusinessReference); + } + + private static void MergeSubWorkflowState( + Dictionary target, + IReadOnlyDictionary source) + { + foreach (var item in source) + { + target[item.Key] = item.Value.Clone(); + } + } + + private static Dictionary StripInternalState( + IReadOnlyDictionary state) + { + var filtered = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in state) + { + if (string.Equals(item.Key, SubWorkflowFramesStateKey, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals(item.Key, ProjectionWorkflowInstanceIdStateKey, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + filtered[item.Key] = item.Value.Clone(); + } + + return filtered; + } + + private static List ReadSubWorkflowFrames( + IReadOnlyDictionary workflowState) + { + if (!workflowState.TryGetValue(SubWorkflowFramesStateKey, out var value) + || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return []; + } + + return JsonSerializer.Deserialize>(value.GetRawText()) ?? []; + } + + private static void WriteSubWorkflowFrames( + Dictionary workflowState, + IReadOnlyCollection frames) + { + if (frames.Count == 0) + { + workflowState.Remove(SubWorkflowFramesStateKey); + return; + } + + workflowState[SubWorkflowFramesStateKey] = JsonSerializer.SerializeToElement(frames); + } + + private static void ReplaceWorkflowState( + Dictionary target, + IReadOnlyDictionary replacement) + { + target.Clear(); + foreach (var item in replacement) + { + target[item.Key] = item.Value.Clone(); + } + } + + private static string GetRequiredStepId(WorkflowStepDefinition step) + { + return step.StepId + ?? throw new InvalidOperationException( + $"Workflow '{Workflow.WorkflowName}' contains a step without a deterministic step id."); + } + + private static bool EvaluateCondition( + WorkflowSpecExecutionContext context, + WorkflowValueEqualsConditionDefinition condition) + { + var source = condition.Source == WorkflowValueSource.Payload + ? context.Payload + : context.WorkflowState; + + if (!source.TryGetValue(condition.Key, out var actualValue)) + { + return condition.ExpectedValue is null; + } + + if (condition.ExpectedValue is null) + { + return actualValue.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined; + } + + if (condition.ExpectedValue is string expectedText) + { + return WorkflowJsonExtensions.TryGet(actualValue, out string? actualText) + && string.Equals(actualText, expectedText, StringComparison.OrdinalIgnoreCase); + } + + return WorkflowJsonExtensions.TryGet(actualValue, condition.ExpectedValue.GetType(), out var convertedValue) + && Equals(convertedValue, condition.ExpectedValue); + } + + private static bool HasValue(object? value) + { + return value switch + { + null => false, + string text => !string.IsNullOrWhiteSpace(text), + _ => true, + }; + } + + private static void ApplyRepeatIterationState( + WorkflowSpecExecutionContext context, + WorkflowRepeatStepDefinition step, + int iteration) + { + if (!string.IsNullOrWhiteSpace(step.IterationStateKey)) + { + context.WorkflowState.Assign(step.IterationStateKey, iteration); + } + } + + private static bool ShouldContinueRepeat( + WorkflowSpecExecutionContext context, + WorkflowRepeatStepDefinition step, + int iteration, + int maxIterations) + { + if (iteration >= maxIterations) + { + return false; + } + + return step.ContinueWhileEvaluator?.Invoke(context) ?? false; + } + + private static JsonElement SerializeToJsonElement(object? payload) + { + return payload is JsonElement jsonElement + ? jsonElement.Clone() + : JsonSerializer.SerializeToElement(payload); + } + + private static IReadOnlyDictionary SerializePayload(IDictionary payload) + { + return payload.ToDictionary( + x => x.Key, + x => JsonSerializer.SerializeToElement(x.Value), + StringComparer.OrdinalIgnoreCase); + } + + private static JsonElement DeserializeJsonPayload(string? jsonPayload) + { + if (string.IsNullOrWhiteSpace(jsonPayload)) + { + return JsonSerializer.SerializeToElement(new Dictionary()); + } + + return JsonSerializer.Deserialize(jsonPayload); + } + + private static WorkflowExecutionTerminalResult BuildTimerWaitResult( + WorkflowSpecExecutionContext context, + WorkflowTimerStepDefinition step, + WorkflowResumePointer resumePointer, + IReadOnlyCollection continuations) + { + var delay = step.DelayFactory(context); + if (delay < TimeSpan.Zero) + { + delay = TimeSpan.Zero; + } + + var waitingToken = $"timer-{GetRequiredStepId(step)}-{Guid.NewGuid():N}"; + return new WorkflowExecutionTerminalResult( + WorkflowOpenStatus, + context.BusinessReference, + [], + [ + new WorkflowPendingSignalPlan + { + SignalType = WorkflowSignalTypes.TimerDue, + WaitingToken = waitingToken, + DueAtUtc = DateTime.UtcNow.Add(delay), + ResumeState = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [ResumePointerResumeStateKey] = JsonSerializer.SerializeToElement(resumePointer), + }, + }, + ], + continuations.ToArray()); + } + + private static WorkflowExecutionTerminalResult BuildExternalSignalWaitResult( + WorkflowSpecExecutionContext context, + WorkflowExternalSignalStepDefinition step, + WorkflowResumePointer resumePointer, + IReadOnlyCollection continuations) + { + var signalName = step.SignalNameFactory(context); + if (string.IsNullOrWhiteSpace(signalName)) + { + throw new InvalidOperationException( + $"Workflow '{Workflow.WorkflowName}' external signal step '{step.StepName}' resolved an empty signal name."); + } + + var waitingToken = $"signal-{GetRequiredStepId(step)}-{Guid.NewGuid():N}"; + var resumeState = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [ResumePointerResumeStateKey] = JsonSerializer.SerializeToElement(resumePointer), + [WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey] = JsonSerializer.SerializeToElement(signalName), + }; + + if (!string.IsNullOrWhiteSpace(step.ResultKey)) + { + resumeState[ResultKeyResumeStateKey] = JsonSerializer.SerializeToElement(step.ResultKey); + } + + return new WorkflowExecutionTerminalResult( + WorkflowOpenStatus, + context.BusinessReference, + [], + [ + new WorkflowPendingSignalPlan + { + SignalType = WorkflowSignalTypes.ExternalSignal, + WaitingToken = waitingToken, + AutoDispatch = false, + ResumeState = resumeState, + }, + ], + continuations.ToArray()); + } + + private async Task ResumeSignalAsync( + WorkflowSignalResumeContext context, + CancellationToken cancellationToken) + { + var isTimerSignal = string.Equals(context.Signal.SignalType, WorkflowSignalTypes.TimerDue, StringComparison.OrdinalIgnoreCase); + var isExternalSignal = string.Equals(context.Signal.SignalType, WorkflowSignalTypes.ExternalSignal, StringComparison.OrdinalIgnoreCase); + if (!isTimerSignal && !isExternalSignal) + { + throw new NotSupportedException( + $"Declarative workflow '{Workflow.WorkflowName}' does not support signal type '{context.Signal.SignalType}'."); + } + + if (!context.ResumeState.TryGetValue(ResumePointerResumeStateKey, out var resumePointerElement)) + { + throw new InvalidOperationException( + $"Declarative workflow '{Workflow.WorkflowName}' signal resume is missing resume pointer state."); + } + + var resumePointer = JsonSerializer.Deserialize(resumePointerElement.GetRawText()) + ?? throw new InvalidOperationException( + $"Declarative workflow '{Workflow.WorkflowName}' signal resume pointer could not be deserialized."); + var workflowState = context.WorkflowState.CloneJson(); + var executionContext = new WorkflowSpecExecutionContext( + Workflow.WorkflowName, + null, + workflowState, + context.Signal.Payload, + context.BusinessReference, + workflowFunctionRuntime); + if (isExternalSignal + && TryReadResumeStateString(context.ResumeState, ResultKeyResumeStateKey, out var resultKey)) + { + executionContext.SetResult(resultKey!, SerializeToJsonElement(context.Signal.Payload)); + } + + var continuations = new List(context.Continuations); + var terminalResult = await ResumeAsync( + resumePointer, + executionContext, + continuations, + cancellationToken); + + return BuildCompletionPlan( + workflowState, + terminalResult ?? new WorkflowExecutionTerminalResult( + WorkflowCompletedStatus, + executionContext.BusinessReference, + [], + [], + continuations.ToArray())); + } + + private static bool TryReadResumeStateString( + IReadOnlyDictionary resumeState, + string key, + out string? value) + { + value = null; + if (!resumeState.TryGetValue(key, out var element)) + { + return false; + } + + value = element.ValueKind == JsonValueKind.String + ? element.GetString() + : element.ToString(); + return !string.IsNullOrWhiteSpace(value); + } + + private sealed record WorkflowExecutionTerminalResult( + string InstanceStatus, + WorkflowBusinessReference? BusinessReference, + IReadOnlyCollection NextTasks, + IReadOnlyCollection PendingSignals, + IReadOnlyCollection Continuations); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/DeclarativeWorkflowSubWorkflowRuntime.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/DeclarativeWorkflowSubWorkflowRuntime.cs new file mode 100644 index 000000000..d1d040132 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/DeclarativeWorkflowSubWorkflowRuntime.cs @@ -0,0 +1,125 @@ +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.Services; + +internal enum WorkflowResumeEntryPointKind +{ + InitialSequence = 1, + TaskOnComplete = 2, +} + +internal enum WorkflowResumeBranchKind +{ + True = 1, + False = 2, + Else = 3, + Failure = 4, + Timeout = 5, + Fork = 6, + Repeat = 7, +} + +internal sealed record WorkflowResumeBranchPointer +{ + public required string StepId { get; init; } + public required WorkflowResumeBranchKind BranchKind { get; init; } + public int? BranchIndex { get; init; } +} + +internal sealed record WorkflowResumePointer +{ + public required WorkflowResumeEntryPointKind EntryPointKind { get; init; } + public string? TaskName { get; init; } + public IReadOnlyCollection BranchPath { get; init; } = []; + public required int NextStepIndex { get; init; } +} + +internal sealed record WorkflowExecutionLocation +{ + public required WorkflowResumeEntryPointKind EntryPointKind { get; init; } + public string? TaskName { get; init; } + public IReadOnlyCollection BranchPath { get; init; } = []; + + public static WorkflowExecutionLocation InitialSequence() + { + return new WorkflowExecutionLocation + { + EntryPointKind = WorkflowResumeEntryPointKind.InitialSequence, + }; + } + + public static WorkflowExecutionLocation TaskOnComplete(string taskName) + { + return new WorkflowExecutionLocation + { + EntryPointKind = WorkflowResumeEntryPointKind.TaskOnComplete, + TaskName = taskName, + }; + } + + public WorkflowExecutionLocation EnterBranch( + string stepId, + WorkflowResumeBranchKind branchKind, + int? branchIndex = null) + { + var path = new List(BranchPath.Count + 1); + path.AddRange(BranchPath); + path.Add(new WorkflowResumeBranchPointer + { + StepId = stepId, + BranchKind = branchKind, + BranchIndex = branchIndex, + }); + + return new WorkflowExecutionLocation + { + EntryPointKind = EntryPointKind, + TaskName = TaskName, + BranchPath = path, + }; + } + + public WorkflowResumePointer CreateResumePointer(int nextStepIndex) + { + return new WorkflowResumePointer + { + EntryPointKind = EntryPointKind, + TaskName = TaskName, + BranchPath = BranchPath, + NextStepIndex = nextStepIndex, + }; + } +} + +internal sealed record WorkflowSubWorkflowFrame +{ + public required string WorkflowName { get; init; } + public required string WorkflowVersion { get; init; } + public WorkflowBusinessReference? BusinessReference { get; init; } + public required Dictionary WorkflowState { get; init; } + public required WorkflowResumePointer ResumePointer { get; init; } + public string? ResultKey { get; init; } +} + +internal sealed record WorkflowResumeExecutionContext +{ + public required Dictionary WorkflowState { get; init; } + public WorkflowBusinessReference? BusinessReference { get; init; } + public required object ResumePointer { get; init; } + public required IReadOnlyDictionary SubWorkflowState { get; init; } + public string? ResultKey { get; init; } + public IReadOnlyCollection Continuations { get; init; } = []; +} + +internal interface IDeclarativeWorkflowResumeHandler +{ + Task ResumeAsync( + WorkflowResumeExecutionContext context, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/DefaultWorkflowAssignmentPermissionEvaluator.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/DefaultWorkflowAssignmentPermissionEvaluator.cs new file mode 100644 index 000000000..bc3477c69 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/DefaultWorkflowAssignmentPermissionEvaluator.cs @@ -0,0 +1,43 @@ +using System; +using System.Linq; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class DefaultWorkflowAssignmentPermissionEvaluator : IWorkflowAssignmentPermissionEvaluator +{ + public WorkflowAssignmentPermissionDecision Evaluate(WorkflowAssignmentPermissionContext context) + { + var actorCanWorkTask = context.ActorRoles.Intersect(context.EffectiveRoles, StringComparer.OrdinalIgnoreCase).Any(); + + return context.Action switch + { + WorkflowTaskAction.AssignSelf => new WorkflowAssignmentPermissionDecision + { + Allowed = actorCanWorkTask, + Reason = actorCanWorkTask ? null : "Actor is not in the effective roles for this task.", + }, + WorkflowTaskAction.AssignOther => new WorkflowAssignmentPermissionDecision + { + Allowed = false, + Reason = "Assigning to another user requires a permissions plugin.", + }, + WorkflowTaskAction.Release => new WorkflowAssignmentPermissionDecision + { + Allowed = context.CurrentAssignee == context.ActorId, + Reason = context.CurrentAssignee == context.ActorId ? null : "Only the current assignee can release without a plugin override.", + }, + WorkflowTaskAction.Complete => new WorkflowAssignmentPermissionDecision + { + Allowed = context.CurrentAssignee == context.ActorId || actorCanWorkTask, + Reason = null, + }, + _ => new WorkflowAssignmentPermissionDecision + { + Allowed = false, + Reason = "Unsupported task action.", + }, + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/EntityFrameworkWorkflowMutationCoordinator.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/EntityFrameworkWorkflowMutationCoordinator.cs new file mode 100644 index 000000000..cec34bad8 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/EntityFrameworkWorkflowMutationCoordinator.cs @@ -0,0 +1,63 @@ +using StellaOps.Workflow.DataStore.Oracle; +using System; +using System.Threading; +using System.Threading.Tasks; + +// TODO: Replace WorkflowDbContext with StellaOps workflow DbContext +using Microsoft.EntityFrameworkCore; +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class EntityFrameworkWorkflowMutationCoordinator( + WorkflowDbContext workflowDbContext, + IWorkflowMutationScopeAccessor scopeAccessor) : IWorkflowMutationCoordinator +{ + public async Task BeginAsync(CancellationToken cancellationToken = default) + { + if (scopeAccessor.Current is not null) + { + return new DelegatingMutationScope(scopeAccessor.Current); + } + + if (workflowDbContext.Database.CurrentTransaction is not null) + { + var currentScope = new WorkflowMutationTransactionScope(null, scopeAccessor, ownsScope: true); + scopeAccessor.Current = currentScope; + return currentScope; + } + + if (string.Equals( + workflowDbContext.Database.ProviderName, + "Microsoft.EntityFrameworkCore.InMemory", + StringComparison.Ordinal)) + { + var currentScope = new WorkflowMutationTransactionScope(null, scopeAccessor, ownsScope: true); + scopeAccessor.Current = currentScope; + return currentScope; + } + + var transaction = await workflowDbContext.Database.BeginTransactionAsync(cancellationToken); + var scope = new WorkflowMutationTransactionScope(transaction, scopeAccessor, ownsScope: true); + scopeAccessor.Current = scope; + return scope; + } + + private sealed class DelegatingMutationScope(IWorkflowMutationScope inner) : IWorkflowMutationScope + { + public void RegisterPostCommitAction(Func action) + { + inner.RegisterPostCommitAction(action); + } + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/EntityFrameworkWorkflowProjectionRetentionStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/EntityFrameworkWorkflowProjectionRetentionStore.cs new file mode 100644 index 000000000..69753d20a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/EntityFrameworkWorkflowProjectionRetentionStore.cs @@ -0,0 +1,102 @@ +using StellaOps.Workflow.DataStore.Oracle; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +// TODO: Replace with StellaOps workflow DbContext +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Engine.Constants; + +using Microsoft.EntityFrameworkCore; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class EntityFrameworkWorkflowProjectionRetentionStore( + WorkflowDbContext dbContext) : IWorkflowProjectionRetentionStore +{ + public async Task RunAsync( + DateTime nowUtc, + CancellationToken cancellationToken = default) + { + var staleInstances = await dbContext.WorkflowInstances + .Where(x => + x.Status == WorkflowInstanceStatuses.Open + && x.StaleAfterUtc != null + && x.StaleAfterUtc <= nowUtc) + .ToListAsync(cancellationToken); + + foreach (var instance in staleInstances) + { + instance.Status = WorkflowInstanceStatuses.Stale; + } + + var staleTasks = await dbContext.WorkflowTasks + .Where(x => + (x.Status == WorkflowTaskStatuses.Open || x.Status == WorkflowTaskStatuses.Assigned) + && x.StaleAfterUtc != null + && x.StaleAfterUtc <= nowUtc) + .ToListAsync(cancellationToken); + + foreach (var task in staleTasks) + { + task.Status = WorkflowTaskStatuses.Stale; + } + + var expiredInstanceIds = await dbContext.WorkflowInstances + .Where(x => x.PurgeAfterUtc != null && x.PurgeAfterUtc <= nowUtc) + .Select(x => x.WorkflowInstanceId) + .ToArrayAsync(cancellationToken); + + var tasksToPurge = await dbContext.WorkflowTasks + .Where(x => + expiredInstanceIds.Contains(x.WorkflowInstanceId) + || (x.PurgeAfterUtc != null && x.PurgeAfterUtc <= nowUtc)) + .ToListAsync(cancellationToken); + + var taskIdsToPurge = tasksToPurge + .Select(x => x.WorkflowTaskId) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var taskEventsToPurge = taskIdsToPurge.Length == 0 + ? [] + : await dbContext.WorkflowTaskEvents + .Where(x => taskIdsToPurge.Contains(x.WorkflowTaskId)) + .ToListAsync(cancellationToken); + + var instancesToPurge = expiredInstanceIds.Length == 0 + ? [] + : await dbContext.WorkflowInstances + .Where(x => expiredInstanceIds.Contains(x.WorkflowInstanceId)) + .ToListAsync(cancellationToken); + + if (taskEventsToPurge.Count > 0) + { + dbContext.WorkflowTaskEvents.RemoveRange(taskEventsToPurge); + } + + if (tasksToPurge.Count > 0) + { + dbContext.WorkflowTasks.RemoveRange(tasksToPurge); + } + + if (instancesToPurge.Count > 0) + { + dbContext.WorkflowInstances.RemoveRange(instancesToPurge); + } + + await dbContext.SaveChangesAsync(cancellationToken); + + return new WorkflowProjectionRetentionBatch + { + StaleWorkflowInstanceIds = staleInstances.Select(x => x.WorkflowInstanceId).ToArray(), + StaleInstancesMarked = staleInstances.Count, + StaleTasksMarked = staleTasks.Count, + PurgedWorkflowInstanceIds = expiredInstanceIds, + PurgedInstances = instancesToPurge.Count, + PurgedTasks = tasksToPurge.Count, + PurgedTaskEvents = taskEventsToPurge.Count, + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/GenericAssignmentPermissionEvaluator.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/GenericAssignmentPermissionEvaluator.cs new file mode 100644 index 000000000..d45e2c2eb --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/GenericAssignmentPermissionEvaluator.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.Options; + +namespace StellaOps.Workflow.Engine.Services; + +/// +/// Options for the generic assignment permission evaluator. +/// +public sealed class GenericAssignmentPermissionOptions +{ + public const string SectionName = "GenericAssignmentPermissions"; + + /// + /// Roles that are considered admin roles for workflow task assignment. + /// Admin users can assign tasks to others, assign roles, and release tasks. + /// + public List AdminRoles { get; set; } = []; +} + +/// +/// A permission evaluator that recognizes configured admin roles. +/// Admin users receive full assignment permissions (AssignOther, AssignRoles, Release) +/// in addition to standard permissions. +/// +public sealed class GenericAssignmentPermissionEvaluator( + IOptions options) : IWorkflowAssignmentPermissionEvaluator +{ + private readonly GenericAssignmentPermissionOptions settings = options.Value; + + public WorkflowAssignmentPermissionDecision Evaluate(WorkflowAssignmentPermissionContext context) + { + var actorCanWorkTask = context.ActorRoles.Intersect(context.EffectiveRoles, StringComparer.OrdinalIgnoreCase).Any(); + var isAdmin = settings.AdminRoles.Count > 0 + && context.ActorRoles.Intersect(settings.AdminRoles, StringComparer.OrdinalIgnoreCase).Any(); + + return context.Action switch + { + WorkflowTaskAction.AssignSelf => new WorkflowAssignmentPermissionDecision + { + Allowed = actorCanWorkTask, + Reason = actorCanWorkTask ? null : "Actor is not in the effective roles for this task.", + }, + WorkflowTaskAction.AssignOther => new WorkflowAssignmentPermissionDecision + { + Allowed = isAdmin, + Reason = isAdmin ? null : "Only admin roles can assign tasks to other users.", + }, + WorkflowTaskAction.AssignRoles => new WorkflowAssignmentPermissionDecision + { + Allowed = isAdmin, + Reason = isAdmin ? null : "Only admin roles can assign role groups.", + }, + WorkflowTaskAction.Release => new WorkflowAssignmentPermissionDecision + { + Allowed = isAdmin + || string.Equals(context.CurrentAssignee, context.ActorId, StringComparison.OrdinalIgnoreCase), + Reason = isAdmin + || string.Equals(context.CurrentAssignee, context.ActorId, StringComparison.OrdinalIgnoreCase) + ? null + : "Only the current assignee or admin can release this task.", + }, + WorkflowTaskAction.Complete => new WorkflowAssignmentPermissionDecision + { + Allowed = actorCanWorkTask || isAdmin, + Reason = actorCanWorkTask || isAdmin ? null : "Actor cannot complete this task.", + }, + _ => new WorkflowAssignmentPermissionDecision + { + Allowed = false, + Reason = $"Action '{context.Action}' is not supported.", + }, + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/InMemoryWorkflowRuntimeStateStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/InMemoryWorkflowRuntimeStateStore.cs new file mode 100644 index 000000000..770d1827b --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/InMemoryWorkflowRuntimeStateStore.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Engine.Constants; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class InMemoryWorkflowRuntimeStateStore : IWorkflowRuntimeStateStore +{ + private readonly ConcurrentDictionary states = new(StringComparer.OrdinalIgnoreCase); + + public Task UpsertAsync( + WorkflowRuntimeStateRecord state, + CancellationToken cancellationToken = default) + { + states.AddOrUpdate( + state.WorkflowInstanceId, + _ => ValidateInsert(state), + (_, current) => ValidateUpdate(current, state)); + return Task.CompletedTask; + } + + public Task GetAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default) + { + states.TryGetValue(workflowInstanceId, out var state); + return Task.FromResult(state); + } + + public Task> GetManyAsync( + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken = default) + { + if (workflowInstanceIds.Count == 0) + { + return Task.FromResult>([]); + } + + var results = workflowInstanceIds + .Where(x => states.ContainsKey(x)) + .Select(x => states[x]) + .ToArray(); + + return Task.FromResult>(results); + } + + public Task MarkStaleAsync( + IReadOnlyCollection workflowInstanceIds, + DateTime updatedOnUtc, + CancellationToken cancellationToken = default) + { + var updated = 0; + + foreach (var workflowInstanceId in workflowInstanceIds) + { + if (!states.TryGetValue(workflowInstanceId, out var current)) + { + continue; + } + + states[workflowInstanceId] = current with + { + RuntimeStatus = WorkflowInstanceStatuses.Stale, + LastUpdatedOnUtc = updatedOnUtc, + StaleAfterUtc = null, + }; + updated++; + } + + return Task.FromResult(updated); + } + + public Task DeleteAsync( + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken = default) + { + var deleted = 0; + + foreach (var workflowInstanceId in workflowInstanceIds) + { + if (states.TryRemove(workflowInstanceId, out _)) + { + deleted++; + } + } + + return Task.FromResult(deleted); + } + + private static WorkflowRuntimeStateRecord ValidateInsert(WorkflowRuntimeStateRecord state) + { + if (state.Version > 1) + { + throw new WorkflowRuntimeStateConcurrencyException(state.WorkflowInstanceId, state.Version, 0); + } + + return state; + } + + private static WorkflowRuntimeStateRecord ValidateUpdate( + WorkflowRuntimeStateRecord current, + WorkflowRuntimeStateRecord state) + { + if (state.Version > 0 && current.Version + 1 != state.Version) + { + throw new WorkflowRuntimeStateConcurrencyException( + state.WorkflowInstanceId, + state.Version, + current.Version); + } + + return state; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/InProcessWorkflowRuntimeOrchestrator.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/InProcessWorkflowRuntimeOrchestrator.cs new file mode 100644 index 000000000..7cf02294e --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/InProcessWorkflowRuntimeOrchestrator.cs @@ -0,0 +1,124 @@ +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.Engine.Constants; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class InProcessWorkflowRuntimeOrchestrator( + IWorkflowExecutionHandlerCatalog workflowExecutionHandlerCatalog) : IWorkflowRuntimeProvider +{ + public const string ProviderName = WorkflowRuntimeProviderNames.InProcess; + + string IWorkflowRuntimeProvider.ProviderName => ProviderName; + + public async Task StartAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + StartWorkflowRequest request, + object startRequest, + CancellationToken cancellationToken = default) + { + var handler = workflowExecutionHandlerCatalog.GetHandler(definition.WorkflowName, definition.WorkflowVersion); + if (handler is null) + { + return new WorkflowRuntimeExecutionResult + { + RuntimeProvider = ProviderName, + RuntimeStatus = WorkflowInstanceStatuses.Open, + InstanceStatus = WorkflowInstanceStatuses.Open, + BusinessReference = businessReference, + WorkflowState = ConvertToJsonDictionary(request.Payload), + RuntimeState = ConvertToJsonDictionary(request.Payload), + Tasks = definition.Tasks.Select(x => new WorkflowExecutionTaskPlan + { + TaskName = x.TaskName, + TaskType = x.TaskType, + Route = x.Route, + TaskRoles = x.TaskRoles, + }).ToArray(), + }; + } + + var executionPlan = await handler.StartAsync(new WorkflowStartExecutionContext + { + Registration = registration, + Definition = definition, + BusinessReference = businessReference, + StartRequest = startRequest, + Payload = ConvertToJsonDictionary(request.Payload), + }, cancellationToken); + + return new WorkflowRuntimeExecutionResult + { + RuntimeProvider = ProviderName, + RuntimeStatus = executionPlan.InstanceStatus, + InstanceStatus = executionPlan.InstanceStatus, + BusinessReference = executionPlan.BusinessReference ?? businessReference, + WorkflowState = executionPlan.WorkflowState, + RuntimeState = executionPlan.WorkflowState, + Tasks = executionPlan.Tasks, + Continuations = executionPlan.Continuations, + }; + } + + public async Task CompleteAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default) + { + var handler = workflowExecutionHandlerCatalog.GetHandler(definition.WorkflowName, definition.WorkflowVersion); + if (handler is null) + { + return new WorkflowRuntimeExecutionResult + { + RuntimeProvider = ProviderName, + RuntimeInstanceId = context.WorkflowInstanceId, + RuntimeStatus = WorkflowInstanceStatuses.Completed, + InstanceStatus = WorkflowInstanceStatuses.Completed, + BusinessReference = context.CurrentTask.BusinessReference, + WorkflowState = context.WorkflowState, + RuntimeState = context.WorkflowState, + }; + } + + var completionPlan = await handler.CompleteTaskAsync(context, cancellationToken); + + return new WorkflowRuntimeExecutionResult + { + RuntimeProvider = ProviderName, + RuntimeInstanceId = context.WorkflowInstanceId, + RuntimeStatus = completionPlan.InstanceStatus, + InstanceStatus = completionPlan.InstanceStatus, + BusinessReference = completionPlan.BusinessReference ?? context.CurrentTask.BusinessReference, + WorkflowState = completionPlan.WorkflowState, + RuntimeState = completionPlan.WorkflowState, + Tasks = completionPlan.NextTasks, + Continuations = completionPlan.Continuations, + }; + } + + public Task ResumeAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowSignalExecutionContext context, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("The in-process workflow runtime does not support signal-driven resume."); + } + + private static IReadOnlyDictionary ConvertToJsonDictionary(IDictionary values) + { + return values.ToDictionary( + x => x.Key, + x => JsonSerializer.SerializeToElement(x.Value)); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowDefinitionStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowDefinitionStore.cs new file mode 100644 index 000000000..2b44898c4 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowDefinitionStore.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.Logging; + +namespace StellaOps.Workflow.Engine.Services; + +/// +/// Default no-op definition store. Logs warnings when operations are attempted +/// without a backend-specific definition store plugin. +/// +public sealed class NullWorkflowDefinitionStore( + ILogger logger) : IWorkflowDefinitionStore +{ + public Task GetActiveAsync(string workflowName, CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } + + public Task GetAsync(string workflowName, string version, CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } + + public Task> GetVersionsAsync(string workflowName, CancellationToken cancellationToken = default) + { + return Task.FromResult>(Array.Empty()); + } + + public Task> GetAllActiveAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult>(Array.Empty()); + } + + public Task UpsertAsync(WorkflowDefinitionRecord record, CancellationToken cancellationToken = default) + { + logger.LogWarning( + "Definition store not configured. Definition {WorkflowName} v{Version} will not be persisted.", + record.WorkflowName, + record.WorkflowVersion); + return Task.CompletedTask; + } + + public Task ActivateAsync(string workflowName, string version, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task FindByHashAsync(string workflowName, string contentHash, CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowGraphqlTransport.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowGraphqlTransport.cs new file mode 100644 index 000000000..0d89ab88d --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowGraphqlTransport.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class NullWorkflowGraphqlTransport : IWorkflowGraphqlTransport +{ + public Task ExecuteAsync( + WorkflowGraphqlRequest request, + CancellationToken cancellationToken = default) + => Task.FromResult(new WorkflowGraphqlResponse + { + Succeeded = false, + Error = $"GraphQL transport plugin is not configured for target '{request.Target}'.", + }); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowHttpTransport.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowHttpTransport.cs new file mode 100644 index 000000000..11f232ce1 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowHttpTransport.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class NullWorkflowHttpTransport : IWorkflowHttpTransport +{ + public Task ExecuteAsync( + WorkflowHttpRequest request, + CancellationToken cancellationToken = default) + => Task.FromResult(new WorkflowHttpResponse + { + Succeeded = false, + Error = $"HTTP transport plugin is not configured for '{request.Method} {request.Target}:{request.Path}'.", + }); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowLegacyRabbitTransport.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowLegacyRabbitTransport.cs new file mode 100644 index 000000000..b4de1f491 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowLegacyRabbitTransport.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class NullWorkflowLegacyRabbitTransport : IWorkflowLegacyRabbitTransport +{ + public Task ExecuteAsync( + WorkflowLegacyRabbitRequest request, + CancellationToken cancellationToken = default) + => Task.FromResult(new WorkflowMicroserviceResponse + { + Succeeded = false, + Error = $"Legacy Rabbit transport plugin is not configured for '{request.Command}'.", + }); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowMicroserviceTransport.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowMicroserviceTransport.cs new file mode 100644 index 000000000..bc5efd89c --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowMicroserviceTransport.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class NullWorkflowMicroserviceTransport : IWorkflowMicroserviceTransport +{ + public Task ExecuteAsync( + WorkflowMicroserviceRequest request, + CancellationToken cancellationToken = default) + => Task.FromResult(new WorkflowMicroserviceResponse + { + Succeeded = false, + Error = $"Microservice transport plugin is not configured for '{request.MicroserviceName}.{request.Command}'.", + }); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowRabbitTransport.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowRabbitTransport.cs new file mode 100644 index 000000000..5b8fbd6b0 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/NullWorkflowRabbitTransport.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class NullWorkflowRabbitTransport : IWorkflowRabbitTransport +{ + public Task ExecuteAsync( + WorkflowRabbitRequest request, + CancellationToken cancellationToken = default) + => Task.FromResult(new WorkflowRabbitResponse + { + Succeeded = false, + Error = $"Rabbit transport plugin is not configured for '{request.Exchange}:{request.RoutingKey}'.", + }); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowCanonicalDefinitionService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowCanonicalDefinitionService.cs new file mode 100644 index 000000000..71fff7e43 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowCanonicalDefinitionService.cs @@ -0,0 +1,59 @@ +using System.Linq; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class WorkflowCanonicalDefinitionService( + IWorkflowModuleCatalog workflowModuleCatalog, + IWorkflowFunctionCatalog workflowFunctionCatalog) +{ + public WorkflowCanonicalSchemaGetResponse GetSchema() + { + return new WorkflowCanonicalSchemaGetResponse + { + SchemaVersion = WorkflowCanonicalDefinitionSchema.Version1, + SchemaJson = WorkflowCanonicalJsonSchema.GetSchemaJson(), + }; + } + + public WorkflowCanonicalValidateResponse Validate(WorkflowCanonicalValidateRequest request) + { + var installedModules = workflowModuleCatalog.GetInstalledModules(); + var result = WorkflowCanonicalImportValidator.Validate( + request.CanonicalDefinitionJson, + installedModules, + workflowFunctionCatalog); + + return new WorkflowCanonicalValidateResponse + { + IsValid = result.Succeeded, + WorkflowName = result.Definition?.WorkflowName, + WorkflowVersion = result.Definition?.WorkflowVersion, + SchemaIssues = result.SchemaErrors.Select(MapIssue).ToArray(), + SemanticIssues = result.SemanticErrors.Select(MapIssue).ToArray(), + ModuleIssues = result.ModuleErrors.Select(MapIssue).ToArray(), + InstalledModules = installedModules.Select(MapModule).ToArray(), + }; + } + + private static WorkflowCanonicalValidationIssue MapIssue(WorkflowCanonicalValidationError issue) + { + return new WorkflowCanonicalValidationIssue + { + Code = issue.Code, + Path = issue.Path, + Message = issue.Message, + }; + } + + private static WorkflowModuleInfo MapModule(WorkflowInstalledModule module) + { + return new WorkflowModuleInfo + { + ModuleName = module.ModuleName, + Version = module.Version, + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowCoreServiceCollectionExtensions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowCoreServiceCollectionExtensions.cs new file mode 100644 index 000000000..6a049aedd --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowCoreServiceCollectionExtensions.cs @@ -0,0 +1,157 @@ +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Engine.Authorization; +using StellaOps.Workflow.Engine.Definitions; +using StellaOps.Workflow.Engine.Execution; +using StellaOps.Workflow.Engine.Hosting; +using StellaOps.Workflow.Engine.HostedServices; +using StellaOps.Workflow.Engine.Projections; +using StellaOps.Workflow.Engine.Rendering; +using StellaOps.Workflow.Engine.Scheduling; +using StellaOps.Workflow.Engine.Signaling; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace StellaOps.Workflow.Engine.Services; + +/// +/// Registers core workflow engine services WITHOUT Serdica-specific dependencies. +/// This is the generic DI wiring extracted from the Serdica WorkflowServiceCollectionExtensions. +/// Platform-specific integrations (DbContext, plugins, health checks, auth) must be registered separately. +/// +public static class WorkflowCoreServiceCollectionExtensions +{ + /// + /// Adds all core workflow engine services to the service collection. + /// Does NOT register: DbContext, Plugin loading, Serdica auth, health checks, or rendering layout engines. + /// Those must be registered by the host application or by additional extension methods. + /// + public static IServiceCollection AddWorkflowEngineCoreServices( + this IServiceCollection services, + IConfiguration configuration) + { + // Options binding + services.Configure(configuration.GetSection(WorkflowRuntimeOptions.SectionName)); + services.Configure(configuration.GetSection(WorkflowEngineOptions.SectionName)); + services.Configure(configuration.GetSection(WorkflowRenderingOptions.SectionName)); + services.Configure(configuration.GetSection(WorkflowRetentionOptions.SectionName)); + services.Configure(configuration.GetSection(WorkflowRetentionHostedJobOptions.SectionName)); + + // Default runtime provider configuration + ConfigureRuntimeProviderDefault(services, configuration, WorkflowRuntimeProviderNames.InProcess); + + // Module and function catalogs + services.AddWorkflowModuleCatalog(); + services.AddWorkflowModule("workflow.dsl.core", "1.0.0"); + services.AddWorkflowModule("workflow.functions.core", "1.0.0"); + services.AddWorkflowFunctionProvider(); + + // Registration and definition catalogs + services.AddSingleton(); + services.AddSingleton(); + + // Runtime state stores (in-memory defaults, can be overridden) + services.TryAddSingleton(); + services.TryAddSingleton(); + + // Runtime definition store + services.TryAddSingleton(); + + // Scoped projection and mutation services + // Default EF-based implementations require a WorkflowDbContext to be registered. + // Host applications may override these with backend-specific implementations + // (e.g., PostgresWorkflowProjectionStore, MongoWorkflowMutationCoordinator). + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + // Execution handler catalogs + services.AddScoped(); + services.TryAddScoped(); + + // Runtime providers + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddScoped(); + + // Signal infrastructure (null defaults, replaced by backend-specific implementations) + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + // Definition store (null default, replaced by backend-specific implementation) + services.TryAddScoped(); + services.AddScoped(); + + // Signal pump telemetry + services.TryAddSingleton(); + services.TryAddSingleton(); + + // Assignment permissions + services.Configure( + configuration.GetSection(GenericAssignmentPermissionOptions.SectionName)); + services.TryAddScoped(); + + // Transport null defaults (replaced by platform-specific transport plugins) + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + // Rendering (layout engine resolver and graph compiler; layout engine implementations must be registered separately) + services.TryAddSingleton(); + services.TryAddSingleton(); + + // Core application services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + /// + /// Registers the hosted services for signal pump and retention background processing. + /// Call this only when running as the workflow engine service host. + /// + public static IServiceCollection AddWorkflowEngineHostedServices( + this IServiceCollection services) + { + services.AddHostedService(); + services.AddHostedService(); + return services; + } + + private static void ConfigureRuntimeProviderDefault( + IServiceCollection services, + IConfiguration configuration, + string providerName) + { + var configuredProvider = configuration + .GetSection(WorkflowRuntimeOptions.SectionName)[nameof(WorkflowRuntimeOptions.DefaultProvider)]; + if (!string.IsNullOrWhiteSpace(configuredProvider)) + { + return; + } + + services.PostConfigure(options => options.DefaultProvider = providerName); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDefinitionCatalog.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDefinitionCatalog.cs new file mode 100644 index 000000000..657c7b6f8 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDefinitionCatalog.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class WorkflowDefinitionCatalog(IWorkflowRegistrationCatalog workflowRegistrationCatalog) : IWorkflowDefinitionCatalog +{ + public IReadOnlyCollection GetDefinitions() + => workflowRegistrationCatalog + .GetRegistrations() + .Select(x => x.Definition with + { + WorkflowRoles = x.Definition.WorkflowRoles.ToArray(), + Tasks = x.Definition.Tasks.ToArray(), + }) + .ToArray(); + + public WorkflowDefinitionDescriptor? GetDefinition(string workflowName, string? workflowVersion = null) + { + var registration = workflowRegistrationCatalog.GetRegistration(workflowName, workflowVersion); + if (registration is null) + { + return null; + } + + return registration.Definition with + { + WorkflowRoles = registration.Definition.WorkflowRoles.ToArray(), + Tasks = registration.Definition.Tasks.ToArray(), + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDefinitionDeploymentService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDefinitionDeploymentService.cs new file mode 100644 index 000000000..e5be0c856 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDefinitionDeploymentService.cs @@ -0,0 +1,338 @@ +using System; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Engine.Exceptions; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Engine.Constants; +using StellaOps.Workflow.Contracts; + +using Microsoft.Extensions.Logging; + +namespace StellaOps.Workflow.Engine.Services; + +/// +/// Handles import, export, versioning, and activation of canonical workflow definitions. +/// +public sealed class WorkflowDefinitionDeploymentService( + IWorkflowDefinitionStore definitionStore, + WorkflowCanonicalDefinitionService canonicalDefinitionService, + ILogger logger) +{ + /// + /// Imports a canonical definition, applying the versioning algorithm: + /// 1. Hash content, check for duplicate + /// 2. If same hash exists, skip (no-op) + /// 3. If base version exists with different hash, increment build iteration + /// 4. If base version is new, import as-is + /// 5. Mark imported version as active + /// + public async Task ImportAsync( + WorkflowDefinitionImportRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + // 1. Validate the canonical definition + var validation = canonicalDefinitionService.Validate(new WorkflowCanonicalValidateRequest + { + CanonicalDefinitionJson = request.CanonicalDefinitionJson, + }); + if (!validation.IsValid) + { + return new WorkflowDefinitionImportResponse + { + WorkflowName = validation.WorkflowName ?? "unknown", + Version = validation.WorkflowVersion ?? "0.0.0", + ContentHash = string.Empty, + WasImported = false, + WasNewVersion = false, + IsActive = false, + ValidationIssues = validation.SchemaIssues + .Concat(validation.SemanticIssues) + .Concat(validation.ModuleIssues) + .ToArray(), + }; + } + + var workflowName = validation.WorkflowName!; + var baseVersion = validation.WorkflowVersion!; + + // 2. Compute content hash + var contentHash = WorkflowContentHasher.ComputeHash(request.CanonicalDefinitionJson); + + // 3. Check if exact hash already exists + var existingByHash = await definitionStore.FindByHashAsync(workflowName, contentHash, cancellationToken); + if (existingByHash is not null) + { + logger.LogInformation( + "Definition import skipped: {WorkflowName} v{Version} content hash {ContentHash} already exists.", + workflowName, + existingByHash.WorkflowVersion, + contentHash); + + return new WorkflowDefinitionImportResponse + { + WorkflowName = workflowName, + Version = existingByHash.WorkflowVersion, + ContentHash = contentHash, + WasImported = false, + WasNewVersion = false, + IsActive = existingByHash.IsActive, + }; + } + + // 4. Determine version with build iteration + var existingVersions = await definitionStore.GetVersionsAsync(workflowName, cancellationToken); + var matchingBaseVersions = existingVersions + .Where(v => string.Equals(v.BaseVersion, baseVersion, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + int buildIteration; + bool wasNewVersion; + if (matchingBaseVersions.Length == 0) + { + buildIteration = 0; + wasNewVersion = true; + } + else + { + buildIteration = matchingBaseVersions.Max(v => v.BuildIteration) + 1; + wasNewVersion = false; + } + + var fullVersion = WorkflowVersioning.FormatVersion(baseVersion, buildIteration); + + // 5. Parse display name from the definition + string? displayName = null; + try + { + using var doc = JsonDocument.Parse(request.CanonicalDefinitionJson); + if (doc.RootElement.TryGetProperty("displayName", out var dn) && dn.ValueKind == JsonValueKind.String) + { + displayName = dn.GetString(); + } + } + catch + { + // Display name extraction is best-effort + } + + // 6. Build and store the record + var now = DateTime.UtcNow; + var record = new WorkflowDefinitionRecord + { + WorkflowName = workflowName, + WorkflowVersion = fullVersion, + BaseVersion = baseVersion, + BuildIteration = buildIteration, + ContentHash = contentHash, + CanonicalDefinitionJson = request.CanonicalDefinitionJson, + DisplayName = displayName, + IsActive = true, + RenderingSvg = DecodeBase64(request.RenderingSvgBase64), + RenderingJson = DecodeBase64(request.RenderingJsonBase64), + RenderingPng = DecodeBase64(request.RenderingPngBase64), + CreatedOnUtc = now, + ActivatedOnUtc = now, + ImportedBy = request.ImportedBy, + }; + + await definitionStore.UpsertAsync(record, cancellationToken); + await definitionStore.ActivateAsync(workflowName, fullVersion, cancellationToken); + + logger.LogInformation( + "Definition imported: {WorkflowName} v{Version} (base={BaseVersion}, iteration={BuildIteration}, hash={ContentHash}, newVersion={WasNewVersion}).", + workflowName, + fullVersion, + baseVersion, + buildIteration, + contentHash, + wasNewVersion); + + return new WorkflowDefinitionImportResponse + { + WorkflowName = workflowName, + Version = fullVersion, + ContentHash = contentHash, + WasImported = true, + WasNewVersion = wasNewVersion, + IsActive = true, + }; + } + + /// + /// Exports a canonical definition with optional rendering assets. + /// + public async Task ExportAsync( + WorkflowDefinitionExportRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var record = string.IsNullOrWhiteSpace(request.Version) + ? await definitionStore.GetActiveAsync(request.WorkflowName, cancellationToken) + : await definitionStore.GetAsync(request.WorkflowName, request.Version, cancellationToken); + + if (record is null) + { + throw new BaseResultException(MessageKeys.WorkflowDefinitionNotFound, request.WorkflowName); + } + + return new WorkflowDefinitionExportResponse + { + WorkflowName = record.WorkflowName, + Version = record.WorkflowVersion, + ContentHash = record.ContentHash, + IsActive = record.IsActive, + CanonicalDefinitionJson = record.CanonicalDefinitionJson, + RenderingSvgBase64 = request.IncludeRendering ? EncodeBase64(record.RenderingSvg) : null, + RenderingJsonBase64 = request.IncludeRendering ? EncodeBase64(record.RenderingJson) : null, + RenderingPngBase64 = request.IncludeRendering ? EncodeBase64(record.RenderingPng) : null, + }; + } + + /// + /// Lists all versions of a workflow definition. + /// + public async Task GetVersionsAsync( + WorkflowDefinitionVersionsGetRequest request, + CancellationToken cancellationToken = default) + { + var versions = await definitionStore.GetVersionsAsync(request.WorkflowName, cancellationToken); + + return new WorkflowDefinitionVersionsGetResponse + { + Versions = versions.Select(v => new WorkflowDefinitionVersionSummary + { + WorkflowName = v.WorkflowName, + Version = v.WorkflowVersion, + BaseVersion = v.BaseVersion, + BuildIteration = v.BuildIteration, + ContentHash = v.ContentHash, + IsActive = v.IsActive, + DisplayName = v.DisplayName, + CreatedOnUtc = v.CreatedOnUtc, + ActivatedOnUtc = v.ActivatedOnUtc, + ImportedBy = v.ImportedBy, + }).ToArray(), + }; + } + + /// + /// Activates a specific version of a workflow definition. + /// + public async Task ActivateAsync( + WorkflowDefinitionActivateRequest request, + CancellationToken cancellationToken = default) + { + var record = await definitionStore.GetAsync(request.WorkflowName, request.Version, cancellationToken); + if (record is null) + { + throw new BaseResultException(MessageKeys.WorkflowDefinitionNotFound, request.WorkflowName); + } + + await definitionStore.ActivateAsync(request.WorkflowName, request.Version, cancellationToken); + + logger.LogInformation( + "Definition activated: {WorkflowName} v{Version}.", + request.WorkflowName, + request.Version); + + return new WorkflowDefinitionActivateResponse + { + WorkflowName = request.WorkflowName, + Version = request.Version, + Activated = true, + }; + } + + /// + /// Gets a single definition by name (and optional version) with optional rendering assets. + /// + public async Task GetDefinitionByIdAsync( + WorkflowDefinitionByIdRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var record = string.IsNullOrWhiteSpace(request.Version) + ? await definitionStore.GetActiveAsync(request.WorkflowName, cancellationToken) + : await definitionStore.GetAsync(request.WorkflowName, request.Version, cancellationToken); + + if (record is null) + { + throw new BaseResultException(MessageKeys.WorkflowDefinitionNotFound, request.WorkflowName); + } + + return new WorkflowDefinitionByIdResponse + { + WorkflowName = record.WorkflowName, + Version = record.WorkflowVersion, + DisplayName = record.DisplayName, + CanonicalDefinitionJson = record.CanonicalDefinitionJson, + ContentHash = record.ContentHash, + IsActive = record.IsActive, + RenderingSvgBase64 = request.IncludeRendering ? EncodeBase64(record.RenderingSvg) : null, + RenderingJsonBase64 = request.IncludeRendering ? EncodeBase64(record.RenderingJson) : null, + RenderingPngBase64 = request.IncludeRendering ? EncodeBase64(record.RenderingPng) : null, + }; + } + + /// + /// Renders a workflow definition in the requested format (SVG, PNG, or JSON render graph). + /// + public async Task RenderAsync( + WorkflowRenderingRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var record = string.IsNullOrWhiteSpace(request.Version) + ? await definitionStore.GetActiveAsync(request.WorkflowName, cancellationToken) + : await definitionStore.GetAsync(request.WorkflowName, request.Version, cancellationToken); + + if (record is null) + { + throw new BaseResultException(MessageKeys.WorkflowDefinitionNotFound, request.WorkflowName); + } + + var format = (request.Format ?? "svg").ToLowerInvariant(); + byte[]? content = format switch + { + "svg" => record.RenderingSvg, + "png" => record.RenderingPng, + "json" => record.RenderingJson, + _ => record.RenderingSvg, + }; + + var contentType = format switch + { + "svg" => "image/svg+xml", + "png" => "image/png", + "json" => "application/json", + _ => "image/svg+xml", + }; + + return new WorkflowRenderingResponse + { + WorkflowName = record.WorkflowName, + Version = record.WorkflowVersion, + Format = format, + ContentBase64 = EncodeBase64(content), + ContentType = contentType, + }; + } + + private static byte[]? DecodeBase64(string? base64) + { + return string.IsNullOrWhiteSpace(base64) ? null : Convert.FromBase64String(base64); + } + + private static string? EncodeBase64(byte[]? data) + { + return data is null or { Length: 0 } ? null : Convert.ToBase64String(data); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDefinitionRenderService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDefinitionRenderService.cs new file mode 100644 index 000000000..d52f1c502 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDefinitionRenderService.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class WorkflowDefinitionRenderService( + IWorkflowRuntimeDefinitionStore runtimeDefinitionStore, + IWorkflowRenderGraphCompiler renderGraphCompiler, + IWorkflowRenderLayoutEngineResolver renderLayoutEngineResolver) +{ + public async Task<(string ProviderName, WorkflowRenderLayoutResult Layout)> RenderDefinitionAsync( + string workflowName, + string? workflowVersion = null, + string? layoutProvider = null, + WorkflowRenderLayoutRequest? layoutRequest = null, + CancellationToken cancellationToken = default) + { + var definition = runtimeDefinitionStore.GetRequiredDefinition(workflowName, workflowVersion); + var graph = renderGraphCompiler.Compile(definition); + var engine = renderLayoutEngineResolver.Resolve(layoutProvider); + var layout = await engine.LayoutAsync(graph, layoutRequest, cancellationToken); + return (engine.ProviderName, layout); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDiagramService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDiagramService.cs new file mode 100644 index 000000000..59d11eb9c --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowDiagramService.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Engine.Exceptions; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Engine.Constants; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class WorkflowDiagramService( + IWorkflowDefinitionCatalog workflowDefinitionCatalog, + WorkflowRuntimeService workflowRuntimeService, + WorkflowDefinitionRenderService definitionRenderService) +{ + public async Task GetDiagramAsync( + WorkflowDiagramGetRequest request, + CancellationToken cancellationToken = default) + { + var definition = workflowDefinitionCatalog.GetDefinition(request.WorkflowName, request.WorkflowVersion) + ?? throw new BaseResultException(MessageKeys.WorkflowDefinitionNotFound, request.WorkflowName); + WorkflowInstanceSummary? instance = null; + IReadOnlyCollection tasks = []; + + if (!string.IsNullOrWhiteSpace(request.WorkflowInstanceId)) + { + instance = (await workflowRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = request.WorkflowInstanceId, + }, cancellationToken)).Instance; + + if (!string.Equals(instance.WorkflowName, definition.WorkflowName, StringComparison.OrdinalIgnoreCase) + || instance.WorkflowVersion != definition.WorkflowVersion) + { + throw new BaseResultException(MessageKeys.WorkflowInstanceNotFound, request.WorkflowInstanceId); + } + + tasks = (await workflowRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = request.WorkflowInstanceId, + }, cancellationToken)).Tasks; + } + + var layoutRequest = ResolveLayoutRequest(request); + + var render = await definitionRenderService.RenderDefinitionAsync( + request.WorkflowName, + request.WorkflowVersion, + request.LayoutProvider, + layoutRequest, + cancellationToken); + + var nodes = render.Layout.Nodes + .Select(node => + { + var matchingTasks = ResolveMatchingTasks(node, definition.Tasks, tasks); + var activeTask = matchingTasks.LastOrDefault(x => + string.Equals(x.Status, WorkflowTaskStatuses.Open, StringComparison.OrdinalIgnoreCase) + || string.Equals(x.Status, WorkflowTaskStatuses.Assigned, StringComparison.OrdinalIgnoreCase) + || string.Equals(x.Status, WorkflowTaskStatuses.Stale, StringComparison.OrdinalIgnoreCase)); + var latestTask = activeTask ?? matchingTasks.LastOrDefault(); + var descriptorTask = definition.Tasks.FirstOrDefault(x => + string.Equals(x.TaskName, node.SemanticKey, StringComparison.OrdinalIgnoreCase)); + + return new WorkflowDiagramNode + { + Id = node.Id, + Label = node.Label, + NodeType = node.Kind, + IconKey = node.IconKey, + SemanticType = node.SemanticType, + SemanticKey = node.SemanticKey, + Route = node.Route, + TaskType = node.TaskType, + X = node.X, + Y = node.Y, + Width = node.Width, + Height = node.Height, + WorkflowRoles = definition.WorkflowRoles.ToArray(), + TaskRoles = descriptorTask?.TaskRoles?.ToArray() ?? [], + NodeStatus = ResolveNodeStatus(instance, node, matchingTasks), + WorkflowTaskId = latestTask?.WorkflowTaskId, + Assignee = latestTask?.Assignee, + EffectiveRoles = activeTask?.EffectiveRoles ?? [], + VisitCount = matchingTasks.Count, + }; + }) + .ToArray(); + + var edges = render.Layout.Edges + .Select(edge => new WorkflowDiagramEdge + { + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Label = edge.Label, + Sections = edge.Sections.Select(section => new WorkflowDiagramEdgeSection + { + StartPoint = new WorkflowDiagramPoint + { + X = section.StartPoint.X, + Y = section.StartPoint.Y, + }, + EndPoint = new WorkflowDiagramPoint + { + X = section.EndPoint.X, + Y = section.EndPoint.Y, + }, + BendPoints = section.BendPoints.Select(point => new WorkflowDiagramPoint + { + X = point.X, + Y = point.Y, + }).ToArray(), + }).ToArray(), + }) + .ToArray(); + + return new WorkflowDiagramGetResponse + { + WorkflowName = definition.WorkflowName, + WorkflowVersion = definition.WorkflowVersion, + DisplayName = definition.DisplayName, + LayoutProvider = render.ProviderName, + LayoutEffort = layoutRequest.Effort.ToString(), + LayoutOrderingIterations = layoutRequest.OrderingIterations, + LayoutPlacementIterations = layoutRequest.PlacementIterations, + WorkflowInstanceId = instance?.WorkflowInstanceId, + InstanceStatus = instance?.Status, + RuntimeProvider = instance?.RuntimeProvider, + RuntimeStatus = instance?.RuntimeStatus, + Nodes = nodes, + Edges = edges, + }; + } + + private static WorkflowRenderLayoutRequest ResolveLayoutRequest(WorkflowDiagramGetRequest request) + { + var effort = WorkflowRenderLayoutEffort.Best; + if (!string.IsNullOrWhiteSpace(request.LayoutEffort) + && Enum.TryParse(request.LayoutEffort, ignoreCase: true, out var parsedEffort)) + { + effort = parsedEffort; + } + + return new WorkflowRenderLayoutRequest + { + Effort = effort, + OrderingIterations = request.LayoutOrderingIterations, + PlacementIterations = request.LayoutPlacementIterations, + }; + } + + private static IReadOnlyCollection ResolveMatchingTasks( + WorkflowRenderPositionedNode node, + IReadOnlyCollection descriptorTasks, + IReadOnlyCollection runtimeTasks) + { + if (!string.Equals(node.SemanticType, "Task", StringComparison.OrdinalIgnoreCase) + || string.IsNullOrWhiteSpace(node.SemanticKey)) + { + return []; + } + + var descriptorTask = descriptorTasks.FirstOrDefault(x => + string.Equals(x.TaskName, node.SemanticKey, StringComparison.OrdinalIgnoreCase)); + if (descriptorTask is null) + { + return []; + } + + return runtimeTasks + .Where(x => + string.Equals(x.TaskName, descriptorTask.TaskName, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.TaskType, descriptorTask.TaskType, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.Route, descriptorTask.Route, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.CreatedOnUtc) + .ToArray(); + } + + private static string? ResolveNodeStatus( + WorkflowInstanceSummary? instance, + WorkflowRenderPositionedNode node, + IReadOnlyCollection tasks) + { + if (instance is null) + { + return null; + } + + if (string.Equals(node.SemanticType, "Start", StringComparison.OrdinalIgnoreCase)) + { + return WorkflowTaskStatuses.Completed; + } + + if (string.Equals(node.SemanticType, "End", StringComparison.OrdinalIgnoreCase)) + { + return string.Equals(instance.Status, WorkflowInstanceStatuses.Completed, StringComparison.OrdinalIgnoreCase) + ? WorkflowInstanceStatuses.Completed + : "Pending"; + } + + if (!string.Equals(node.SemanticType, "Task", StringComparison.OrdinalIgnoreCase)) + { + return string.Equals(instance.Status, WorkflowInstanceStatuses.Completed, StringComparison.OrdinalIgnoreCase) + ? WorkflowInstanceStatuses.Completed + : null; + } + + if (tasks.Count == 0) + { + return "Pending"; + } + + if (tasks.Any(x => string.Equals(x.Status, WorkflowTaskStatuses.Assigned, StringComparison.OrdinalIgnoreCase))) + { + return WorkflowTaskStatuses.Assigned; + } + + if (tasks.Any(x => string.Equals(x.Status, WorkflowTaskStatuses.Open, StringComparison.OrdinalIgnoreCase))) + { + return WorkflowTaskStatuses.Open; + } + + if (tasks.Any(x => string.Equals(x.Status, WorkflowTaskStatuses.Stale, StringComparison.OrdinalIgnoreCase))) + { + return WorkflowTaskStatuses.Stale; + } + + if (tasks.Any(x => string.Equals(x.Status, WorkflowTaskStatuses.Completed, StringComparison.OrdinalIgnoreCase))) + { + return WorkflowTaskStatuses.Completed; + } + + return instance.Status; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowEngineRuntimeProvider.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowEngineRuntimeProvider.cs new file mode 100644 index 000000000..721bbe15d --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowEngineRuntimeProvider.cs @@ -0,0 +1,379 @@ +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.Constants; +using StellaOps.Workflow.Engine.Definitions; +using StellaOps.Workflow.Engine.Execution; + +namespace StellaOps.Workflow.Engine.Services; + +/// +/// Runtime provider for the declarative workflow engine. Delegates execution to +/// implementations but produces durable +/// engine-format runtime state snapshots that support signal-driven resume. +/// For declarative workflows with canonical definitions, the canonical execution +/// handler is preferred (via ) +/// since it supports the full feature set including forks, timers, and signals. +/// +public sealed class WorkflowEngineRuntimeProvider( + IWorkflowExecutionHandlerCatalog workflowExecutionHandlerCatalog, + IWorkflowRuntimeDefinitionStore workflowRuntimeDefinitionStore, + IWorkflowRuntimeExecutionHandlerFactory workflowRuntimeExecutionHandlerFactory) : IWorkflowRuntimeProvider +{ + public string ProviderName => WorkflowRuntimeProviderNames.Engine; + + public async Task StartAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + StartWorkflowRequest request, + object startRequest, + CancellationToken cancellationToken = default) + { + var handler = GetHandler(definition.WorkflowName, definition.WorkflowVersion); + if (handler is null) + { + var fallbackState = ConvertToJsonDictionary(request.Payload); + var fallbackTasks = definition.Tasks.Select(x => new WorkflowExecutionTaskPlan + { + TaskName = x.TaskName, + TaskType = x.TaskType, + Route = x.Route, + TaskRoles = x.TaskRoles, + }).ToArray(); + + return BuildResult( + WorkflowInstanceStatuses.Open, + businessReference, + fallbackState, + fallbackTasks, + [], + [], + version: 1); + } + + var executionPlan = await handler.StartAsync(new WorkflowStartExecutionContext + { + Registration = registration, + Definition = definition, + BusinessReference = businessReference, + StartRequest = startRequest, + Payload = ConvertToJsonDictionary(request.Payload), + }, cancellationToken); + + return BuildResult( + executionPlan.InstanceStatus, + executionPlan.BusinessReference ?? businessReference, + executionPlan.WorkflowState, + executionPlan.Tasks, + executionPlan.PendingSignals, + executionPlan.Continuations, + version: 1); + } + + public async Task CompleteAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default) + { + var handler = GetHandler(definition.WorkflowName, definition.WorkflowVersion); + if (handler is null) + { + return BuildResult( + WorkflowInstanceStatuses.Completed, + context.CurrentTask.BusinessReference, + context.WorkflowState, + [], + [], + [], + version: ReadCurrentVersion(context) + 1, + runtimeInstanceId: context.WorkflowInstanceId); + } + + var completionPlan = await handler.CompleteTaskAsync(context, cancellationToken); + + return BuildResult( + completionPlan.InstanceStatus, + completionPlan.BusinessReference ?? context.CurrentTask.BusinessReference, + completionPlan.WorkflowState, + completionPlan.NextTasks, + completionPlan.PendingSignals, + completionPlan.Continuations, + version: ReadCurrentVersion(context) + 1, + runtimeInstanceId: context.WorkflowInstanceId); + } + + public async Task ResumeAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowSignalExecutionContext context, + CancellationToken cancellationToken = default) + { + var handler = GetHandler(definition.WorkflowName, definition.WorkflowVersion); + if (handler is not IWorkflowSignalResumableExecutionHandler signalHandler) + { + throw new NotSupportedException( + $"Workflow '{definition.WorkflowName}' does not support signal-driven resume."); + } + + var snapshot = WorkflowEngineRuntimeSnapshotParser.Parse(context.RuntimeState.StateJson); + + if (!IsSignalAccepted(snapshot, context.Signal)) + { + return new WorkflowRuntimeExecutionResult + { + Ignored = true, + RuntimeProvider = ProviderName, + RuntimeInstanceId = context.RuntimeState.RuntimeInstanceId, + InstanceStatus = WorkflowInstanceStatuses.Open, + }; + } + + var resumeState = FindResumeState(snapshot, context.Signal); + + var completionPlan = await signalHandler.ResumeSignalAsync(new WorkflowSignalResumeContext + { + BusinessReference = context.RuntimeState.BusinessReference, + Signal = context.Signal, + WorkflowState = snapshot.WorkflowState, + ResumeState = resumeState, + }, cancellationToken); + + return BuildResult( + completionPlan.InstanceStatus, + completionPlan.BusinessReference ?? context.RuntimeState.BusinessReference, + completionPlan.WorkflowState, + completionPlan.NextTasks, + completionPlan.PendingSignals, + completionPlan.Continuations, + version: context.RuntimeState.Version + 1, + runtimeInstanceId: context.RuntimeState.RuntimeInstanceId); + } + + private WorkflowRuntimeExecutionResult BuildResult( + string instanceStatus, + WorkflowBusinessReference? businessReference, + IReadOnlyDictionary workflowState, + IReadOnlyCollection tasks, + IReadOnlyCollection pendingSignals, + IReadOnlyCollection continuations, + long version, + string? runtimeInstanceId = null) + { + tasks ??= []; + pendingSignals ??= []; + continuations ??= []; + + // Inject engine provider metadata into task payloads + tasks = InjectProviderPayload(tasks); + + var runtimeStatus = DetermineRuntimeStatus(instanceStatus, tasks, pendingSignals, workflowState); + var runtimeState = WorkflowEngineRuntimeSnapshotBuilder.Build( + instanceStatus, + businessReference, + workflowState, + tasks, + pendingSignals, + version); + + return new WorkflowRuntimeExecutionResult + { + RuntimeProvider = ProviderName, + RuntimeInstanceId = runtimeInstanceId, + RuntimeStatus = runtimeStatus, + InstanceStatus = instanceStatus, + BusinessReference = businessReference, + WorkflowState = workflowState, + RuntimeState = runtimeState, + Tasks = tasks, + PendingSignals = pendingSignals, + Continuations = continuations, + }; + } + + private static string DetermineRuntimeStatus( + string instanceStatus, + IReadOnlyCollection tasks, + IReadOnlyCollection pendingSignals, + IReadOnlyDictionary workflowState) + { + if (string.Equals(instanceStatus, WorkflowInstanceStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + { + return WorkflowInstanceStatuses.Completed; + } + + if (tasks.Count > 0) + { + return "WaitingForTask"; + } + + if (pendingSignals.Count > 0) + { + return "WaitingForSignal"; + } + + // Check for fork-embedded pending signals in workflow state + var forkSignals = CanonicalWorkflowForkState.CollectPendingSignals(workflowState); + if (forkSignals.Count > 0) + { + return "WaitingForSignal"; + } + + // Check for fork-embedded outstanding tasks in workflow state + var forkTasks = CanonicalWorkflowForkState.CountOutstandingTasks(workflowState); + if (forkTasks > 0) + { + return "WaitingForTask"; + } + + return instanceStatus; + } + + private static bool IsSignalAccepted( + WorkflowEngineRuntimeSnapshot snapshot, + WorkflowSignalEnvelope signal) + { + // If the signal has a waiting token, it must match one of the known waiting tokens + if (!string.IsNullOrWhiteSpace(signal.WaitingToken)) + { + var allWaitings = snapshot.Waitings.Count > 0 + ? snapshot.Waitings + : snapshot.Waiting is not null ? [snapshot.Waiting] : []; + var hasMatch = allWaitings.Any(w => + string.Equals(w.Token, signal.WaitingToken, StringComparison.OrdinalIgnoreCase)); + if (!hasMatch) + { + return false; + } + } + + // For external signals, verify the signal name matches a waiting signal's expected type + if (string.Equals(signal.SignalType, WorkflowSignalTypes.ExternalSignal, StringComparison.OrdinalIgnoreCase) + && signal.Payload.TryGetValue(WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey, out var signalNameElement)) + { + var incomingSignalName = signalNameElement.GetString(); + var allWaitings = snapshot.Waitings.Count > 0 + ? snapshot.Waitings + : snapshot.Waiting is not null ? [snapshot.Waiting] : []; + + // Check if there's a waiting for this specific signal name + // The signal name is embedded in the resume state as part of the waiting definition + var hasSignalMatch = allWaitings.Any(w => + { + if (!string.Equals(w.Kind, "Signal", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!string.Equals(w.SignalType, WorkflowSignalTypes.ExternalSignal, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // If a specific signal name is expected, the resume state should contain the expected name + if (w.ResumeState.TryGetValue(WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey, out var expectedElement)) + { + var expected = expectedElement.GetString(); + return string.Equals(expected, incomingSignalName, StringComparison.OrdinalIgnoreCase); + } + + // If no expected name is specified, accept any external signal + return true; + }); + + if (!hasSignalMatch) + { + return false; + } + } + + return true; + } + + private static IReadOnlyDictionary FindResumeState( + WorkflowEngineRuntimeSnapshot snapshot, + WorkflowSignalEnvelope signal) + { + if (snapshot.Waitings.Count == 0 && snapshot.Waiting is not null) + { + return snapshot.Waiting.ResumeState; + } + + foreach (var waiting in snapshot.Waitings) + { + if (!string.IsNullOrWhiteSpace(signal.WaitingToken) + && string.Equals(waiting.Token, signal.WaitingToken, StringComparison.OrdinalIgnoreCase)) + { + return waiting.ResumeState; + } + } + + return snapshot.Waiting?.ResumeState + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private IWorkflowExecutionHandler? GetHandler(string workflowName, string workflowVersion) + { + // Prefer the canonical execution handler for declarative workflows + // since it supports the full feature set (forks, timers, signals). + var runtimeDefinition = workflowRuntimeDefinitionStore.GetDefinition(workflowName, workflowVersion); + if (runtimeDefinition is not null) + { + var canonicalHandler = workflowRuntimeExecutionHandlerFactory.TryCreateHandler(runtimeDefinition); + if (canonicalHandler is not null) + { + return canonicalHandler; + } + } + + return workflowExecutionHandlerCatalog.GetHandler(workflowName, workflowVersion); + } + + private IReadOnlyCollection InjectProviderPayload( + IReadOnlyCollection tasks) + { + if (tasks.Count == 0) + { + return tasks; + } + + var updatedTasks = new List(tasks.Count); + foreach (var task in tasks) + { + if (task.Payload.ContainsKey(WorkflowRuntimePayloadKeys.RuntimeProviderPayloadKey)) + { + updatedTasks.Add(task); + continue; + } + + var updatedPayload = new Dictionary(task.Payload, StringComparer.OrdinalIgnoreCase) + { + [WorkflowRuntimePayloadKeys.RuntimeProviderPayloadKey] = + JsonSerializer.SerializeToElement(ProviderName), + }; + + updatedTasks.Add(task with { Payload = updatedPayload }); + } + + return updatedTasks; + } + + private static long ReadCurrentVersion(WorkflowTaskExecutionContext context) + { + return WorkflowEngineRuntimeSnapshotBuilder.ReadVersion(context.RuntimeStateJson); + } + + private static IReadOnlyDictionary ConvertToJsonDictionary(IDictionary values) + { + return values.ToDictionary( + x => x.Key, + x => JsonSerializer.SerializeToElement(x.Value)); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowExecutionHandlerCatalog.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowExecutionHandlerCatalog.cs new file mode 100644 index 000000000..a95df75f2 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowExecutionHandlerCatalog.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.DependencyInjection; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class WorkflowExecutionHandlerCatalog( + IServiceProvider serviceProvider, + IWorkflowRegistrationCatalog workflowRegistrationCatalog) + : IWorkflowExecutionHandlerCatalog +{ + public IWorkflowExecutionHandler? GetHandler(string workflowName, string workflowVersion) + { + var registration = workflowRegistrationCatalog.GetRegistration(workflowName, workflowVersion); + if (registration is null) + { + return null; + } + + if (registration.HandlerType is not null) + { + return serviceProvider.GetRequiredService(registration.HandlerType) as IWorkflowExecutionHandler; + } + + var declarativeWorkflowInterface = registration.WorkflowType + .GetInterfaces() + .FirstOrDefault(x => + x.IsGenericType + && x.GetGenericTypeDefinition() == typeof(IDeclarativeWorkflow<>)); + + if (declarativeWorkflowInterface is null) + { + return null; + } + + var startRequestType = declarativeWorkflowInterface.GetGenericArguments()[0]; + var handlerType = typeof(DeclarativeWorkflowExecutionHandler<,>).MakeGenericType( + registration.WorkflowType, + startRequestType); + + return ActivatorUtilities.CreateInstance(serviceProvider, handlerType) as IWorkflowExecutionHandler; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowFunctionCatalogService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowFunctionCatalogService.cs new file mode 100644 index 000000000..a357792f9 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowFunctionCatalogService.cs @@ -0,0 +1,27 @@ +using System.Linq; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class WorkflowFunctionCatalogService( + IWorkflowFunctionCatalog workflowFunctionCatalog, + IWorkflowModuleCatalog workflowModuleCatalog) +{ + public WorkflowFunctionCatalogGetResponse GetCatalog() + { + return new WorkflowFunctionCatalogGetResponse + { + Functions = workflowFunctionCatalog.GetFunctions().ToArray(), + InstalledModules = workflowModuleCatalog + .GetInstalledModules() + .Select(x => new WorkflowModuleInfo + { + ModuleName = x.ModuleName, + Version = x.Version, + }) + .ToArray(), + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowHostedJobLockServices.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowHostedJobLockServices.cs new file mode 100644 index 000000000..b9ac92f9b --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowHostedJobLockServices.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class InMemoryWorkflowHostedJobLockService : IWorkflowHostedJobLockService +{ + private readonly ConcurrentDictionary locks = new(StringComparer.OrdinalIgnoreCase); + + public Task TryAcquireAsync( + string lockName, + string lockOwner, + DateTime acquiredOnUtc, + TimeSpan lease, + CancellationToken cancellationToken = default) + { + var expiresOnUtc = acquiredOnUtc.Add(lease); + var acquired = false; + + locks.AddOrUpdate( + lockName, + _ => + { + acquired = true; + return new LockEntry + { + LockName = lockName, + LockOwner = lockOwner, + AcquiredOnUtc = acquiredOnUtc, + ExpiresOnUtc = expiresOnUtc, + }; + }, + (_, current) => + { + if (current.ExpiresOnUtc > acquiredOnUtc && !string.Equals(current.LockOwner, lockOwner, StringComparison.OrdinalIgnoreCase)) + { + acquired = false; + return current; + } + + acquired = true; + return new LockEntry + { + LockName = lockName, + LockOwner = lockOwner, + AcquiredOnUtc = acquiredOnUtc, + ExpiresOnUtc = expiresOnUtc, + }; + }); + + return Task.FromResult(acquired); + } + + public Task ReleaseAsync( + string lockName, + string lockOwner, + CancellationToken cancellationToken = default) + { + if (locks.TryGetValue(lockName, out var current) + && string.Equals(current.LockOwner, lockOwner, StringComparison.OrdinalIgnoreCase)) + { + locks.TryRemove(lockName, out _); + } + + return Task.CompletedTask; + } + + private sealed record LockEntry + { + public required string LockName { get; init; } + public required string LockOwner { get; init; } + public required DateTime AcquiredOnUtc { get; init; } + public required DateTime ExpiresOnUtc { get; init; } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowMutationScopeAccessor.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowMutationScopeAccessor.cs new file mode 100644 index 000000000..709cab92f --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowMutationScopeAccessor.cs @@ -0,0 +1,8 @@ +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class WorkflowMutationScopeAccessor : IWorkflowMutationScopeAccessor +{ + public IWorkflowMutationScope? Current { get; set; } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowMutationTransactionScope.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowMutationTransactionScope.cs new file mode 100644 index 000000000..f4b401ca2 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowMutationTransactionScope.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.EntityFrameworkCore.Storage; + +namespace StellaOps.Workflow.Engine.Services; + +internal sealed class WorkflowMutationTransactionScope( + IDbContextTransaction? transaction, + IWorkflowMutationScopeAccessor? scopeAccessor, + bool ownsScope) : IWorkflowMutationScope +{ + private readonly List> postCommitActions = []; + private bool isCommitted; + + public void RegisterPostCommitAction(Func action) + { + ArgumentNullException.ThrowIfNull(action); + postCommitActions.Add(action); + } + + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + if (isCommitted) + { + return; + } + + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + + foreach (var action in postCommitActions) + { + await action(cancellationToken); + } + + isCommitted = true; + } + + public async ValueTask DisposeAsync() + { + try + { + if (transaction is not null) + { + await transaction.DisposeAsync(); + } + } + finally + { + if (ownsScope && scopeAccessor?.Current == this) + { + scopeAccessor.Current = null; + } + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRegistrationCatalog.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRegistrationCatalog.cs new file mode 100644 index 000000000..baaa76c2a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRegistrationCatalog.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class WorkflowRegistrationCatalog(IEnumerable registrations) + : IWorkflowRegistrationCatalog +{ + private readonly IReadOnlyCollection items = registrations + .OrderBy(x => x.Definition.WorkflowName, StringComparer.OrdinalIgnoreCase) + .ThenByDescending(x => x.Definition.WorkflowVersion, WorkflowVersioning.SemanticComparer) + .ToArray(); + + public IReadOnlyCollection GetRegistrations() + { + return items; + } + + public WorkflowRegistration? GetRegistration(string workflowName, string? workflowVersion = null) + { + var query = items.Where(x => + string.Equals(x.Definition.WorkflowName, workflowName, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(workflowVersion)) + { + query = query.Where(x => + string.Equals(x.Definition.WorkflowVersion, workflowVersion, StringComparison.OrdinalIgnoreCase)); + } + + return query + .OrderByDescending(x => x.Definition.WorkflowVersion, WorkflowVersioning.SemanticComparer) + .FirstOrDefault(); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRetentionHostedJobOptions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRetentionHostedJobOptions.cs new file mode 100644 index 000000000..22f9caf64 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRetentionHostedJobOptions.cs @@ -0,0 +1,15 @@ +using System; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class WorkflowRetentionHostedJobOptions +{ + public const string SectionName = "WorkflowRetentionHostedJob"; + + public bool Enabled { get; set; } = true; + public bool RunOnStartup { get; set; } + public TimeSpan InitialDelay { get; set; } = TimeSpan.FromMinutes(5); + public TimeSpan Interval { get; set; } = TimeSpan.FromHours(24); + public string LockName { get; set; } = "workflow.retention"; + public TimeSpan LockLease { get; set; } = TimeSpan.FromHours(2); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRetentionOptions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRetentionOptions.cs new file mode 100644 index 000000000..9023b892b --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRetentionOptions.cs @@ -0,0 +1,9 @@ +namespace StellaOps.Workflow.Engine.Services; + +public sealed class WorkflowRetentionOptions +{ + public const string SectionName = "WorkflowRetention"; + + public int OpenStaleAfterDays { get; set; } = 30; + public int CompletedPurgeAfterDays { get; set; } = 180; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRetentionService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRetentionService.cs new file mode 100644 index 000000000..bfe9cb0e5 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRetentionService.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed record WorkflowRetentionRunResult +{ + public required int StaleInstancesMarked { get; init; } + public required int StaleTasksMarked { get; init; } + public required int PurgedInstances { get; init; } + public required int PurgedTasks { get; init; } + public required int PurgedTaskEvents { get; init; } + public required int PurgedRuntimeStates { get; init; } +} + +public sealed class WorkflowRetentionService( + IWorkflowProjectionRetentionStore projectionRetentionStore, + IWorkflowRuntimeStateStore workflowRuntimeStateStore) +{ + public async Task RunAsync( + DateTime? nowUtc = null, + CancellationToken cancellationToken = default) + { + var now = nowUtc ?? DateTime.UtcNow; + var projectionBatch = await projectionRetentionStore.RunAsync(now, cancellationToken); + + var staleRuntimeStatesMarked = await workflowRuntimeStateStore.MarkStaleAsync( + projectionBatch.StaleWorkflowInstanceIds, + now, + cancellationToken); + + var purgedRuntimeStates = await workflowRuntimeStateStore.DeleteAsync( + projectionBatch.PurgedWorkflowInstanceIds, + cancellationToken); + + return new WorkflowRetentionRunResult + { + StaleInstancesMarked = Math.Max(projectionBatch.StaleInstancesMarked, staleRuntimeStatesMarked), + StaleTasksMarked = projectionBatch.StaleTasksMarked, + PurgedInstances = projectionBatch.PurgedInstances, + PurgedTasks = projectionBatch.PurgedTasks, + PurgedTaskEvents = projectionBatch.PurgedTaskEvents, + PurgedRuntimeStates = purgedRuntimeStates, + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRoleResolutionService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRoleResolutionService.cs new file mode 100644 index 000000000..8d1a28d12 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRoleResolutionService.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class WorkflowRoleResolutionService +{ + public IReadOnlyCollection NormalizeRoles(IReadOnlyCollection roles) + { + return roles + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public IReadOnlyCollection ResolveEffectiveRoles( + IReadOnlyCollection workflowRoles, + IReadOnlyCollection taskRoles, + IReadOnlyCollection? runtimeRoles = null) + { + if (runtimeRoles is { Count: > 0 }) + { + return NormalizeRoles(runtimeRoles); + } + + if (taskRoles.Count > 0) + { + return NormalizeRoles(taskRoles); + } + + return NormalizeRoles(workflowRoles); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRuntimeService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRuntimeService.cs new file mode 100644 index 000000000..f65dd4cda --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowRuntimeService.cs @@ -0,0 +1,915 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Engine.Exceptions; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Engine.Authorization; +using StellaOps.Workflow.Engine.Constants; +using StellaOps.Workflow.Contracts; +using StellaOps.Workflow.Engine.Execution; +using StellaOps.Workflow.Engine.Hosting; +using StellaOps.Workflow.Engine.Projections; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class WorkflowRuntimeService( + IWorkflowRegistrationCatalog workflowRegistrationCatalog, + IWorkflowDefinitionCatalog workflowDefinitionCatalog, + IWorkflowRuntimeOrchestrator workflowRuntimeOrchestrator, + IWorkflowMutationCoordinator workflowMutationCoordinator, + IWorkflowProjectionStore workflowProjectionStore, + WorkflowTaskAuthorizationService workflowTaskAuthorizationService, + IWorkflowRuntimeStateStore workflowRuntimeStateStore, + IWorkflowSignalBus workflowSignalBus, + IWorkflowScheduleBus workflowScheduleBus, + IOptions retentionOptions, + IOptions engineOptions, + ILogger logger) +{ + private const string WorkflowNamePayloadKey = "workflowName"; + private const string WorkflowVersionPayloadKey = "workflowVersion"; + private readonly WorkflowRetentionOptions retention = retentionOptions.Value; + private readonly WorkflowEngineOptions engine = engineOptions.Value; + + public async Task StartWorkflowAsync( + StartWorkflowRequest request, + CancellationToken cancellationToken = default) + { + using var timeoutCts = CreateExecutionTimeoutScope(cancellationToken); + cancellationToken = timeoutCts.Token; + + var registration = workflowRegistrationCatalog.GetRegistration(request.WorkflowName, request.WorkflowVersion) + ?? throw new BaseResultException(MessageKeys.WorkflowDefinitionNotFound, request.WorkflowName); + var definition = workflowDefinitionCatalog.GetDefinition(request.WorkflowName, request.WorkflowVersion) + ?? throw new BaseResultException(MessageKeys.WorkflowDefinitionNotFound, request.WorkflowName); + var typedStartRequest = registration.BindStartRequest(request.Payload); + var businessReference = ResolveBusinessReference(registration, request, typedStartRequest); + var runtimeResult = await workflowRuntimeOrchestrator.StartAsync( + registration, + definition, + businessReference, + request, + typedStartRequest, + cancellationToken); + var resolvedBusinessReference = runtimeResult.BusinessReference ?? businessReference; + var executionPlan = new WorkflowStartExecutionPlan + { + InstanceStatus = runtimeResult.InstanceStatus, + BusinessReference = resolvedBusinessReference, + WorkflowState = runtimeResult.WorkflowState, + Tasks = runtimeResult.Tasks, + }; + await using var transaction = await workflowMutationCoordinator.BeginAsync(cancellationToken); + var response = await workflowProjectionStore.CreateWorkflowAsync(definition, resolvedBusinessReference, executionPlan, cancellationToken); + + var runtimeStateRecord = BuildRuntimeStateRecord( + response.WorkflowInstanceId, + definition, + resolvedBusinessReference, + runtimeResult, + DateTime.UtcNow); + await workflowRuntimeStateStore.UpsertAsync(runtimeStateRecord, cancellationToken); + + await DispatchPendingSignalsAsync( + response.WorkflowInstanceId, + runtimeResult.RuntimeProvider, + runtimeResult.PendingSignals, + runtimeStateRecord, + cancellationToken); + + await DispatchContinuationsAsync( + response.WorkflowInstanceId, + runtimeResult.RuntimeProvider, + runtimeResult.Continuations, + cancellationToken); + await transaction.CommitAsync(cancellationToken); + + logger.LogInformation( + "Workflow started: {WorkflowName} v{WorkflowVersion}, InstanceId={WorkflowInstanceId}.", + definition.WorkflowName, + definition.WorkflowVersion, + response.WorkflowInstanceId); + + return response; + } + + public async Task GetTasksAsync( + WorkflowTasksGetRequest request, + CancellationToken cancellationToken = default) + { + var tasks = await workflowProjectionStore.GetTasksAsync(request, cancellationToken); + return new WorkflowTasksGetResponse + { + Tasks = tasks + .Select(x => EnrichTaskSummary(x, request.ActorId, request.ActorRoles)) + .ToArray(), + }; + } + + public async Task GetTaskAsync( + WorkflowTaskGetRequest request, + CancellationToken cancellationToken = default) + { + var task = await workflowProjectionStore.GetTaskAsync(request.WorkflowTaskId, cancellationToken) + ?? throw new BaseResultException(MessageKeys.WorkflowTaskNotFound, request.WorkflowTaskId); + + return new WorkflowTaskGetResponse + { + Task = EnrichTaskSummary(task, request.ActorId, request.ActorRoles), + }; + } + + public async Task AssignTaskAsync( + WorkflowTaskAssignRequest request, + CancellationToken cancellationToken = default) + { + var task = await workflowProjectionStore.GetTaskAsync(request.WorkflowTaskId, cancellationToken) + ?? throw new BaseResultException(MessageKeys.WorkflowTaskNotFound, request.WorkflowTaskId); + + EnsureTaskIsMutable(task); + + var hasTargetRoles = request.TargetRoles.Count > 0; + if (hasTargetRoles && !string.IsNullOrWhiteSpace(request.TargetUserId)) + { + throw new BaseResultException( + MessageKeys.WorkflowTaskActionDenied, + task.WorkflowTaskId, + WorkflowTaskAction.AssignRoles, + "Task assignment cannot target both a user and role groups."); + } + + if (hasTargetRoles) + { + EnsureAuthorized(task, request.ActorId, request.ActorRoles, WorkflowTaskAction.AssignRoles, null, request.TargetRoles); + + var updatedRoleTask = await workflowProjectionStore.AssignTaskRolesAsync( + request.WorkflowTaskId, + request.ActorId, + request.TargetRoles, + cancellationToken); + + return new WorkflowTaskAssignResponse + { + WorkflowTaskId = updatedRoleTask.WorkflowTaskId, + Assignee = updatedRoleTask.Assignee, + Status = updatedRoleTask.Status, + RuntimeRoles = updatedRoleTask.RuntimeRoles, + EffectiveRoles = updatedRoleTask.EffectiveRoles, + }; + } + + var assignee = string.IsNullOrWhiteSpace(request.TargetUserId) + ? request.ActorId + : request.TargetUserId; + + var action = string.Equals(assignee, request.ActorId, StringComparison.OrdinalIgnoreCase) + ? WorkflowTaskAction.AssignSelf + : WorkflowTaskAction.AssignOther; + + EnsureAuthorized(task, request.ActorId, request.ActorRoles, action, assignee, []); + + var updatedTask = await workflowProjectionStore.AssignTaskAsync( + request.WorkflowTaskId, + request.ActorId, + assignee, + cancellationToken); + + return new WorkflowTaskAssignResponse + { + WorkflowTaskId = updatedTask.WorkflowTaskId, + Assignee = updatedTask.Assignee, + Status = updatedTask.Status, + RuntimeRoles = updatedTask.RuntimeRoles, + EffectiveRoles = updatedTask.EffectiveRoles, + }; + } + + public async Task ReleaseTaskAsync( + WorkflowTaskReleaseRequest request, + CancellationToken cancellationToken = default) + { + var task = await workflowProjectionStore.GetTaskAsync(request.WorkflowTaskId, cancellationToken) + ?? throw new BaseResultException(MessageKeys.WorkflowTaskNotFound, request.WorkflowTaskId); + + EnsureTaskIsMutable(task); + EnsureAuthorized(task, request.ActorId, request.ActorRoles, WorkflowTaskAction.Release, null, []); + + await workflowProjectionStore.ReleaseTaskAsync(request.WorkflowTaskId, request.ActorId, cancellationToken); + + return new WorkflowTaskReleaseResponse + { + WorkflowTaskId = request.WorkflowTaskId, + Released = true, + }; + } + + public async Task CompleteTaskAsync( + WorkflowTaskCompleteRequest request, + CancellationToken cancellationToken = default) + { + using var timeoutCts = CreateExecutionTimeoutScope(cancellationToken); + cancellationToken = timeoutCts.Token; + + var snapshot = await workflowProjectionStore.GetExecutionSnapshotAsync(request.WorkflowTaskId, cancellationToken) + ?? throw new BaseResultException(MessageKeys.WorkflowTaskNotFound, request.WorkflowTaskId); + var task = snapshot.Task; + + EnsureTaskIsMutable(task); + EnsureAuthorized(task, request.ActorId, request.ActorRoles, WorkflowTaskAction.Complete, null, []); + + var activeWorkflowName = ResolveTaskWorkflowName(task); + var activeWorkflowVersion = ResolveTaskWorkflowVersion(task); + var registration = workflowRegistrationCatalog.GetRegistration(activeWorkflowName, activeWorkflowVersion) + ?? throw new BaseResultException(MessageKeys.WorkflowDefinitionNotFound, activeWorkflowName); + var definition = workflowDefinitionCatalog.GetDefinition(activeWorkflowName, activeWorkflowVersion) + ?? throw new BaseResultException(MessageKeys.WorkflowDefinitionNotFound, activeWorkflowName); + var existingRuntimeState = await workflowRuntimeStateStore.GetAsync(snapshot.Task.WorkflowInstanceId, cancellationToken); + var runtimeResult = await workflowRuntimeOrchestrator.CompleteAsync( + registration, + definition, + new WorkflowTaskExecutionContext + { + Registration = registration, + Definition = definition, + WorkflowInstanceId = snapshot.Task.WorkflowInstanceId, + RuntimeInstanceId = existingRuntimeState?.RuntimeInstanceId, + RuntimeStateJson = existingRuntimeState?.StateJson, + CurrentTask = snapshot.Task, + WorkflowState = snapshot.WorkflowState, + Payload = ConvertToJsonDictionary(request.Payload), + }, + cancellationToken); + var resolvedBusinessReference = runtimeResult.BusinessReference ?? snapshot.Task.BusinessReference; + var completionPlan = new WorkflowTaskCompletionPlan + { + InstanceStatus = runtimeResult.InstanceStatus, + BusinessReference = resolvedBusinessReference, + WorkflowState = runtimeResult.WorkflowState, + NextTasks = runtimeResult.Tasks, + }; + + await using var transaction = await workflowMutationCoordinator.BeginAsync(cancellationToken); + + await workflowProjectionStore.ApplyTaskCompletionAsync( + request.WorkflowTaskId, + request.ActorId, + request.Payload, + completionPlan, + resolvedBusinessReference, + cancellationToken); + + var runtimeStateRecord = BuildRuntimeStateRecord( + snapshot.Task.WorkflowInstanceId, + definition, + resolvedBusinessReference, + runtimeResult, + DateTime.UtcNow, + existingRuntimeState); + await workflowRuntimeStateStore.UpsertAsync(runtimeStateRecord, cancellationToken); + + await DispatchPendingSignalsAsync( + snapshot.Task.WorkflowInstanceId, + runtimeResult.RuntimeProvider, + runtimeResult.PendingSignals, + runtimeStateRecord, + cancellationToken); + + await DispatchContinuationsAsync( + snapshot.Task.WorkflowInstanceId, + runtimeResult.RuntimeProvider, + runtimeResult.Continuations, + cancellationToken); + await transaction.CommitAsync(cancellationToken); + + logger.LogInformation( + "Workflow task completed: TaskId={WorkflowTaskId}, InstanceId={WorkflowInstanceId}.", + request.WorkflowTaskId, + snapshot.Task.WorkflowInstanceId); + + return new WorkflowTaskCompleteResponse + { + WorkflowTaskId = request.WorkflowTaskId, + Completed = true, + }; + } + + public async Task ResumeSignalAsync( + WorkflowSignalEnvelope signal, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(signal); + + using var timeoutCts = CreateExecutionTimeoutScope(cancellationToken); + cancellationToken = timeoutCts.Token; + + var existingRuntimeState = await workflowRuntimeStateStore.GetAsync(signal.WorkflowInstanceId, cancellationToken); + if (existingRuntimeState is null) + { + logger.LogDebug( + "Signal {SignalId} ignored: no runtime state found for instance {WorkflowInstanceId}.", + signal.SignalId, + signal.WorkflowInstanceId); + return; + } + + logger.LogDebug( + "Resuming signal {SignalId} type {SignalType} for instance {WorkflowInstanceId}.", + signal.SignalId, + signal.SignalType, + signal.WorkflowInstanceId); + + var registration = workflowRegistrationCatalog.GetRegistration( + existingRuntimeState.WorkflowName, + existingRuntimeState.WorkflowVersion) + ?? throw new BaseResultException(MessageKeys.WorkflowDefinitionNotFound, existingRuntimeState.WorkflowName); + var definition = workflowDefinitionCatalog.GetDefinition( + existingRuntimeState.WorkflowName, + existingRuntimeState.WorkflowVersion) + ?? throw new BaseResultException(MessageKeys.WorkflowDefinitionNotFound, existingRuntimeState.WorkflowName); + var runtimeResult = await workflowRuntimeOrchestrator.ResumeAsync( + registration, + definition, + new WorkflowSignalExecutionContext + { + Registration = registration, + Definition = definition, + RuntimeState = existingRuntimeState, + Signal = signal, + }, + cancellationToken); + + if (runtimeResult.Ignored) + { + return; + } + + var resolvedBusinessReference = runtimeResult.BusinessReference ?? existingRuntimeState.BusinessReference; + var progressPlan = new WorkflowTaskCompletionPlan + { + InstanceStatus = runtimeResult.InstanceStatus, + BusinessReference = resolvedBusinessReference, + WorkflowState = runtimeResult.WorkflowState, + NextTasks = runtimeResult.Tasks, + PendingSignals = runtimeResult.PendingSignals, + Continuations = runtimeResult.Continuations, + }; + + try + { + await using var transaction = await workflowMutationCoordinator.BeginAsync(cancellationToken); + + await workflowProjectionStore.ApplyRuntimeProgressAsync( + signal.WorkflowInstanceId, + progressPlan, + resolvedBusinessReference, + cancellationToken); + + var runtimeStateRecord = BuildRuntimeStateRecord( + signal.WorkflowInstanceId, + definition, + resolvedBusinessReference, + runtimeResult, + DateTime.UtcNow, + existingRuntimeState); + await workflowRuntimeStateStore.UpsertAsync(runtimeStateRecord, cancellationToken); + + await DispatchPendingSignalsAsync( + signal.WorkflowInstanceId, + runtimeResult.RuntimeProvider, + runtimeResult.PendingSignals, + runtimeStateRecord, + cancellationToken); + await DispatchContinuationsAsync( + signal.WorkflowInstanceId, + runtimeResult.RuntimeProvider, + runtimeResult.Continuations, + cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch (WorkflowRuntimeStateConcurrencyException ex) + { + // Duplicate or stale resume delivery is expected with at-least-once AQ semantics. + logger.LogDebug( + "Signal resume skipped due to version conflict on instance {WorkflowInstanceId}. Expected version {ExpectedVersion}, actual version {ActualVersion}.", + ex.WorkflowInstanceId, + ex.ExpectedVersion, + ex.ActualVersion); + return; + } + } + + public async Task RaiseExternalSignalAsync( + WorkflowSignalRaiseRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var existingRuntimeState = await workflowRuntimeStateStore.GetAsync(request.WorkflowInstanceId, cancellationToken) + ?? throw new BaseResultException(MessageKeys.WorkflowInstanceNotFound, request.WorkflowInstanceId); + if (!string.Equals( + existingRuntimeState.RuntimeProvider, + WorkflowRuntimeProviderNames.Engine, + StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException( + $"Workflow runtime provider '{existingRuntimeState.RuntimeProvider}' does not support durable external signals."); + } + + var snapshot = WorkflowEngineRuntimeSnapshotParser.Parse(existingRuntimeState.StateJson); + var waitingToken = request.WaitingToken; + var signalName = request.SignalName; + var matchingWaiting = snapshot.Waitings + .FirstOrDefault(x => + string.Equals(x.Kind, "Signal", StringComparison.OrdinalIgnoreCase) + && string.Equals(x.SignalType, WorkflowSignalTypes.ExternalSignal, StringComparison.OrdinalIgnoreCase) + && (string.IsNullOrWhiteSpace(signalName) + || string.Equals( + TryReadJsonText( + x.ResumeState, + WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey), + signalName, + StringComparison.OrdinalIgnoreCase))); + if (matchingWaiting is not null) + { + waitingToken ??= matchingWaiting.Token; + signalName ??= TryReadJsonText( + matchingWaiting.ResumeState, + WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey); + } + + var payload = new Dictionary(ConvertToJsonDictionary(request.Payload), StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(signalName)) + { + payload[WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey] = + JsonSerializer.SerializeToElement(signalName); + } + + var signalId = Guid.NewGuid().ToString("N"); + await workflowSignalBus.PublishAsync( + new WorkflowSignalEnvelope + { + SignalId = signalId, + WorkflowInstanceId = request.WorkflowInstanceId, + RuntimeProvider = existingRuntimeState.RuntimeProvider, + SignalType = WorkflowSignalTypes.ExternalSignal, + ExpectedVersion = snapshot.Version, + WaitingToken = waitingToken, + Payload = payload, + }, + cancellationToken); + + logger.LogInformation( + "External signal queued: SignalId={SignalId}, InstanceId={WorkflowInstanceId}, SignalName={SignalName}.", + signalId, + request.WorkflowInstanceId, + signalName); + + return new WorkflowSignalRaiseResponse + { + WorkflowInstanceId = request.WorkflowInstanceId, + SignalId = signalId, + Queued = true, + }; + } + + public async Task GetInstancesAsync( + WorkflowInstancesGetRequest request, + CancellationToken cancellationToken = default) + { + var instances = await workflowProjectionStore.GetInstancesAsync(request, cancellationToken); + var runtimeStates = await workflowRuntimeStateStore.GetManyAsync( + instances.Select(x => x.WorkflowInstanceId).ToArray(), + cancellationToken); + var runtimeLookup = runtimeStates.ToDictionary(x => x.WorkflowInstanceId, StringComparer.OrdinalIgnoreCase); + + var enrichedInstances = instances.Select(x => EnrichInstanceSummary(x, runtimeLookup)).ToArray(); + + if (request.IncludeDetails) + { + for (var i = 0; i < enrichedInstances.Length; i++) + { + var inst = enrichedInstances[i]; + var tasks = await workflowProjectionStore.GetTasksAsync( + new WorkflowTasksGetRequest { WorkflowInstanceId = inst.WorkflowInstanceId, Status = "Open" }, + cancellationToken); + var activeTask = tasks.FirstOrDefault(); + var state = runtimeLookup.TryGetValue(inst.WorkflowInstanceId, out var rs) + ? DeserializeObjectDictionary(rs.StateJson) + : new Dictionary(); + + enrichedInstances[i] = inst with + { + ActiveTask = activeTask, + WorkflowState = state, + }; + } + } + + return new WorkflowInstancesGetResponse + { + Instances = enrichedInstances, + }; + } + + public async Task GetInstanceAsync( + WorkflowInstanceGetRequest request, + CancellationToken cancellationToken = default) + { + var details = await workflowProjectionStore.GetInstanceDetailsAsync(request.WorkflowInstanceId, cancellationToken) + ?? throw new BaseResultException(MessageKeys.WorkflowInstanceNotFound, request.WorkflowInstanceId); + var runtimeState = await workflowRuntimeStateStore.GetAsync(request.WorkflowInstanceId, cancellationToken); + var instance = EnrichInstanceSummary(details.Instance, runtimeState); + + return new WorkflowInstanceGetResponse + { + Instance = instance, + WorkflowState = details.WorkflowState, + Tasks = details.Tasks + .Select(x => EnrichTaskSummary(x, request.ActorId, request.ActorRoles)) + .ToArray(), + TaskEvents = details.TaskEvents, + RuntimeState = MapRuntimeState(runtimeState), + }; + } + + private void EnsureAuthorized( + WorkflowTaskSummary task, + string actorId, + IReadOnlyCollection actorRoles, + WorkflowTaskAction action, + string? targetUserId, + IReadOnlyCollection targetRoles) + { + var decision = workflowTaskAuthorizationService.Evaluate(new WorkflowAssignmentPermissionContext + { + Action = action, + ActorId = actorId, + CurrentAssignee = task.Assignee, + TargetUserId = targetUserId, + TargetRoles = targetRoles, + ActorRoles = actorRoles, + EffectiveRoles = task.EffectiveRoles, + }); + + if (!decision.Allowed) + { + throw new BaseResultException( + MessageKeys.WorkflowTaskActionDenied, + task.WorkflowTaskId, + action, + decision.Reason ?? "The task action is not allowed."); + } + } + + private static void EnsureTaskIsMutable(WorkflowTaskSummary task) + { + if (string.Equals(task.Status, WorkflowTaskStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + { + throw new BaseResultException(MessageKeys.WorkflowTaskAlreadyCompleted, task.WorkflowTaskId); + } + } + + private static WorkflowBusinessReference? ResolveBusinessReference( + WorkflowRegistration registration, + StartWorkflowRequest request, + object typedStartRequest) + { + if (request.BusinessReference is not null) + { + return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(request.BusinessReference); + } + + return registration.ExtractBusinessReference(typedStartRequest); + } + + private static IReadOnlyDictionary ConvertToJsonDictionary(IDictionary values) + { + return values.ToDictionary( + x => x.Key, + x => JsonSerializer.SerializeToElement(x.Value)); + } + + private WorkflowRuntimeStateRecord BuildRuntimeStateRecord( + string workflowInstanceId, + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + WorkflowRuntimeExecutionResult runtimeResult, + DateTime nowUtc, + WorkflowRuntimeStateRecord? existingRuntimeState = null) + { + var stateJson = JsonSerializer.Serialize(runtimeResult.RuntimeState ?? runtimeResult.WorkflowState); + var runtimeVersion = string.Equals( + runtimeResult.RuntimeProvider, + WorkflowRuntimeProviderNames.Engine, + StringComparison.OrdinalIgnoreCase) + ? WorkflowEngineRuntimeSnapshotBuilder.ReadVersion(stateJson) + : existingRuntimeState?.Version ?? 0; + + return new WorkflowRuntimeStateRecord + { + WorkflowInstanceId = workflowInstanceId, + WorkflowName = existingRuntimeState?.WorkflowName ?? definition.WorkflowName, + WorkflowVersion = existingRuntimeState?.WorkflowVersion ?? definition.WorkflowVersion, + Version = runtimeVersion, + BusinessReference = businessReference, + RuntimeProvider = runtimeResult.RuntimeProvider, + RuntimeInstanceId = runtimeResult.RuntimeInstanceId ?? existingRuntimeState?.RuntimeInstanceId ?? workflowInstanceId, + RuntimeStatus = runtimeResult.RuntimeStatus ?? runtimeResult.InstanceStatus, + StateJson = stateJson, + CreatedOnUtc = existingRuntimeState?.CreatedOnUtc ?? nowUtc, + CompletedOnUtc = ResolveCompletedOnUtc(runtimeResult.InstanceStatus, nowUtc), + StaleAfterUtc = ResolveOpenStaleAfterUtc(runtimeResult.InstanceStatus, nowUtc), + PurgeAfterUtc = ResolvePurgeAfterUtc(runtimeResult.InstanceStatus, nowUtc), + LastUpdatedOnUtc = nowUtc, + }; + } + + private static string ResolveTaskWorkflowName(WorkflowTaskSummary task) + { + return TryReadPayloadText(task.Payload, WorkflowNamePayloadKey) ?? task.WorkflowName; + } + + private static string? ResolveTaskWorkflowVersion(WorkflowTaskSummary task) + { + return TryReadPayloadText(task.Payload, WorkflowVersionPayloadKey) ?? task.WorkflowVersion; + } + + private static string? TryReadPayloadText(IDictionary payload, string key) + { + if (!payload.TryGetValue(key, out var value) || value is null) + { + return null; + } + + return value switch + { + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.String => jsonElement.GetString(), + JsonElement jsonElement => jsonElement.ToString(), + _ => value.ToString(), + }; + } + + private async Task DispatchContinuationsAsync( + string workflowInstanceId, + string runtimeProvider, + IReadOnlyCollection continuations, + CancellationToken cancellationToken) + { + if (continuations.Count == 0) + { + return; + } + + if (!string.Equals(runtimeProvider, WorkflowRuntimeProviderNames.Engine, StringComparison.OrdinalIgnoreCase)) + { + foreach (var continuation in continuations) + { + await StartWorkflowAsync(continuation.Request, cancellationToken); + } + + return; + } + + foreach (var continuation in continuations) + { + var signal = new WorkflowSignalEnvelope + { + SignalId = Guid.NewGuid().ToString("N"), + WorkflowInstanceId = workflowInstanceId, + RuntimeProvider = WorkflowRuntimeProviderNames.Engine, + SignalType = WorkflowSignalTypes.InternalContinue, + ExpectedVersion = 0, + Payload = new Dictionary + { + [WorkflowSignalPayloadKeys.StartWorkflowRequestPayloadKey] = + JsonSerializer.SerializeToElement(continuation.Request), + }, + }; + + if (continuation.DueAtUtc.HasValue) + { + await workflowScheduleBus.ScheduleAsync(signal, continuation.DueAtUtc.Value, cancellationToken); + continue; + } + + await workflowSignalBus.PublishAsync(signal, cancellationToken); + } + } + + private async Task DispatchPendingSignalsAsync( + string workflowInstanceId, + string runtimeProvider, + IReadOnlyCollection pendingSignals, + WorkflowRuntimeStateRecord runtimeStateRecord, + CancellationToken cancellationToken) + { + if (pendingSignals.Count == 0) + { + return; + } + + if (!string.Equals(runtimeProvider, WorkflowRuntimeProviderNames.Engine, StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException( + $"Runtime provider '{runtimeProvider}' cannot dispatch workflow engine pending signals."); + } + + var expectedVersion = runtimeStateRecord.Version > 0 + ? runtimeStateRecord.Version + : WorkflowEngineRuntimeSnapshotBuilder.ReadVersion(runtimeStateRecord.StateJson); + foreach (var pendingSignal in pendingSignals) + { + if (!pendingSignal.AutoDispatch) + { + continue; + } + + var signal = new WorkflowSignalEnvelope + { + SignalId = Guid.NewGuid().ToString("N"), + WorkflowInstanceId = workflowInstanceId, + RuntimeProvider = runtimeProvider, + SignalType = pendingSignal.SignalType, + ExpectedVersion = expectedVersion, + WaitingToken = pendingSignal.WaitingToken, + DueAtUtc = pendingSignal.DueAtUtc, + Payload = pendingSignal.Payload, + }; + + if (pendingSignal.DueAtUtc.HasValue) + { + await workflowScheduleBus.ScheduleAsync(signal, pendingSignal.DueAtUtc.Value, cancellationToken); + continue; + } + + await workflowSignalBus.PublishAsync(signal, cancellationToken); + } + } + + private static string? TryReadJsonText( + IReadOnlyDictionary values, + string key) + { + if (!values.TryGetValue(key, out var value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.String + ? value.GetString() + : value.ToString(); + } + + private WorkflowInstanceSummary EnrichInstanceSummary( + WorkflowInstanceSummary instance, + IReadOnlyDictionary runtimeLookup) + { + runtimeLookup.TryGetValue(instance.WorkflowInstanceId, out var runtimeState); + return EnrichInstanceSummary(instance, runtimeState); + } + + private static WorkflowInstanceSummary EnrichInstanceSummary( + WorkflowInstanceSummary instance, + WorkflowRuntimeStateRecord? runtimeState) + { + if (runtimeState is null) + { + return instance; + } + + return instance with + { + RuntimeProvider = runtimeState.RuntimeProvider, + RuntimeInstanceId = runtimeState.RuntimeInstanceId, + RuntimeStatus = runtimeState.RuntimeStatus, + }; + } + + private static WorkflowRuntimeStateSummary? MapRuntimeState(WorkflowRuntimeStateRecord? runtimeState) + { + if (runtimeState is null) + { + return null; + } + + return new WorkflowRuntimeStateSummary + { + BusinessReference = runtimeState.BusinessReference, + RuntimeProvider = runtimeState.RuntimeProvider, + RuntimeInstanceId = runtimeState.RuntimeInstanceId, + RuntimeStatus = runtimeState.RuntimeStatus, + State = DeserializeObjectDictionary(runtimeState.StateJson), + CreatedOnUtc = runtimeState.CreatedOnUtc, + CompletedOnUtc = runtimeState.CompletedOnUtc, + StaleAfterUtc = runtimeState.StaleAfterUtc, + PurgeAfterUtc = runtimeState.PurgeAfterUtc, + LastUpdatedOnUtc = runtimeState.LastUpdatedOnUtc, + }; + } + + private WorkflowTaskSummary EnrichTaskSummary( + WorkflowTaskSummary task, + string? actorId, + IReadOnlyCollection actorRoles) + { + return task with + { + AllowedActions = ResolveAllowedActions(task, actorId, actorRoles), + }; + } + + private IReadOnlyCollection ResolveAllowedActions( + WorkflowTaskSummary task, + string? actorId, + IReadOnlyCollection actorRoles) + { + if (string.IsNullOrWhiteSpace(actorId)) + { + return []; + } + + return Enum + .GetValues() + .Where(action => IsActionAllowed(task, actorId, actorRoles, action)) + .Select(x => x.ToString()) + .ToArray(); + } + + private bool IsActionAllowed( + WorkflowTaskSummary task, + string actorId, + IReadOnlyCollection actorRoles, + WorkflowTaskAction action) + { + if (string.Equals(task.Status, WorkflowTaskStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var targetUserId = action == WorkflowTaskAction.AssignOther ? "__other-user__" : null; + IReadOnlyCollection targetRoles = action == WorkflowTaskAction.AssignRoles + ? new[] { "__other-role__" } + : []; + + return workflowTaskAuthorizationService.Evaluate(new WorkflowAssignmentPermissionContext + { + Action = action, + ActorId = actorId, + CurrentAssignee = task.Assignee, + TargetUserId = targetUserId, + TargetRoles = targetRoles, + ActorRoles = actorRoles, + EffectiveRoles = task.EffectiveRoles, + }).Allowed; + } + + private DateTime? ResolveCompletedOnUtc(string status, DateTime nowUtc) + { + return string.Equals(status, WorkflowInstanceStatuses.Completed, StringComparison.OrdinalIgnoreCase) + ? nowUtc + : null; + } + + private DateTime? ResolveOpenStaleAfterUtc(string status, DateTime nowUtc) + { + return string.Equals(status, WorkflowInstanceStatuses.Open, StringComparison.OrdinalIgnoreCase) + ? nowUtc.AddDays(retention.OpenStaleAfterDays) + : null; + } + + private DateTime? ResolvePurgeAfterUtc(string status, DateTime nowUtc) + { + return string.Equals(status, WorkflowInstanceStatuses.Completed, StringComparison.OrdinalIgnoreCase) + ? nowUtc.AddDays(retention.CompletedPurgeAfterDays) + : null; + } + + private static IDictionary DeserializeObjectDictionary(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new Dictionary(); + } + + return JsonSerializer.Deserialize>(value) + ?? new Dictionary(); + } + + private CancellationTokenSource CreateExecutionTimeoutScope(CancellationToken cancellationToken) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (engine.ExecutionTimeoutSeconds is > 0) + { + cts.CancelAfter(TimeSpan.FromSeconds(engine.ExecutionTimeoutSeconds.Value)); + } + + return cts; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowSignalOperationsService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowSignalOperationsService.cs new file mode 100644 index 000000000..31eb7bda0 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowSignalOperationsService.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; +using StellaOps.Workflow.Engine.Signaling; + +namespace StellaOps.Workflow.Engine.Services; + +public sealed class WorkflowSignalDeadLetterService( + IWorkflowSignalDeadLetterStore deadLetterStore) +{ + public Task GetMessagesAsync( + WorkflowSignalDeadLettersGetRequest request, + CancellationToken cancellationToken = default) + { + return deadLetterStore.GetMessagesAsync(request, cancellationToken); + } + + public Task ReplayAsync( + WorkflowSignalDeadLetterReplayRequest request, + CancellationToken cancellationToken = default) + { + return deadLetterStore.ReplayAsync(request, cancellationToken); + } +} + +public sealed class WorkflowSignalPumpTelemetryService +{ + private readonly ConcurrentDictionary signalTypeCounters = new(StringComparer.OrdinalIgnoreCase); + private readonly DateTime startedOnUtc = DateTime.UtcNow; + private long emptyPollCount; + private long processedCount; + private long failureCount; + private long deadLetterCount; + private long concurrencySkipCount; + private WorkflowSignalPumpLastEvent? lastSuccess; + private WorkflowSignalPumpLastEvent? lastFailure; + private WorkflowSignalPumpLastEvent? lastDeadLetter; + private DateTime? lastActivityOnUtc; + private DateTime? lastSuccessOnUtc; + private DateTime? lastFailureOnUtc; + private DateTime? lastDeadLetterOnUtc; + private readonly object stateLock = new(); + + public void RecordEmptyPoll(string consumerName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(consumerName); + + Interlocked.Increment(ref emptyPollCount); + lock (stateLock) + { + lastActivityOnUtc = DateTime.UtcNow; + } + } + + public void RecordProcessed( + string consumerName, + WorkflowSignalEnvelope envelope, + int deliveryCount, + TimeSpan duration) + { + ArgumentNullException.ThrowIfNull(envelope); + + Interlocked.Increment(ref processedCount); + var counter = signalTypeCounters.GetOrAdd(envelope.SignalType, _ => new SignalTypeCounter()); + Interlocked.Increment(ref counter.ProcessedCount); + + var eventInfo = BuildEvent(consumerName, envelope, deliveryCount, duration, null); + lock (stateLock) + { + lastActivityOnUtc = eventInfo.OccurredOnUtc; + lastSuccessOnUtc = eventInfo.OccurredOnUtc; + lastSuccess = eventInfo; + } + } + + public void RecordFailure( + string consumerName, + WorkflowSignalEnvelope envelope, + int deliveryCount, + TimeSpan duration, + Exception exception) + { + ArgumentNullException.ThrowIfNull(envelope); + ArgumentNullException.ThrowIfNull(exception); + + Interlocked.Increment(ref failureCount); + var counter = signalTypeCounters.GetOrAdd(envelope.SignalType, _ => new SignalTypeCounter()); + Interlocked.Increment(ref counter.FailureCount); + + var eventInfo = BuildEvent(consumerName, envelope, deliveryCount, duration, exception.Message); + lock (stateLock) + { + lastActivityOnUtc = eventInfo.OccurredOnUtc; + lastFailureOnUtc = eventInfo.OccurredOnUtc; + lastFailure = eventInfo; + } + } + + public void RecordConcurrencySkip( + string consumerName, + WorkflowSignalEnvelope envelope, + int deliveryCount, + TimeSpan duration) + { + ArgumentNullException.ThrowIfNull(envelope); + + Interlocked.Increment(ref concurrencySkipCount); + var counter = signalTypeCounters.GetOrAdd(envelope.SignalType, _ => new SignalTypeCounter()); + Interlocked.Increment(ref counter.ConcurrencySkipCount); + + lock (stateLock) + { + lastActivityOnUtc = DateTime.UtcNow; + } + } + + public void RecordDeadLetter( + string consumerName, + WorkflowSignalEnvelope envelope, + int deliveryCount, + TimeSpan duration, + string? message) + { + ArgumentNullException.ThrowIfNull(envelope); + + Interlocked.Increment(ref deadLetterCount); + var counter = signalTypeCounters.GetOrAdd(envelope.SignalType, _ => new SignalTypeCounter()); + Interlocked.Increment(ref counter.DeadLetterCount); + + var eventInfo = BuildEvent(consumerName, envelope, deliveryCount, duration, message); + lock (stateLock) + { + lastActivityOnUtc = eventInfo.OccurredOnUtc; + lastDeadLetterOnUtc = eventInfo.OccurredOnUtc; + lastDeadLetter = eventInfo; + } + } + + public WorkflowSignalPumpStatsGetResponse GetStats() + { + WorkflowSignalPumpLastEvent? currentLastSuccess; + WorkflowSignalPumpLastEvent? currentLastFailure; + WorkflowSignalPumpLastEvent? currentLastDeadLetter; + DateTime? currentLastActivityOnUtc; + DateTime? currentLastSuccessOnUtc; + DateTime? currentLastFailureOnUtc; + DateTime? currentLastDeadLetterOnUtc; + lock (stateLock) + { + currentLastSuccess = lastSuccess; + currentLastFailure = lastFailure; + currentLastDeadLetter = lastDeadLetter; + currentLastActivityOnUtc = lastActivityOnUtc; + currentLastSuccessOnUtc = lastSuccessOnUtc; + currentLastFailureOnUtc = lastFailureOnUtc; + currentLastDeadLetterOnUtc = lastDeadLetterOnUtc; + } + + return new WorkflowSignalPumpStatsGetResponse + { + Stats = new WorkflowSignalPumpStats + { + StartedOnUtc = startedOnUtc, + LastActivityOnUtc = currentLastActivityOnUtc, + LastSuccessOnUtc = currentLastSuccessOnUtc, + LastFailureOnUtc = currentLastFailureOnUtc, + LastDeadLetterOnUtc = currentLastDeadLetterOnUtc, + EmptyPollCount = Interlocked.Read(ref emptyPollCount), + ProcessedCount = Interlocked.Read(ref processedCount), + FailureCount = Interlocked.Read(ref failureCount), + DeadLetterCount = Interlocked.Read(ref deadLetterCount), + ConcurrencySkipCount = Interlocked.Read(ref concurrencySkipCount), + SignalsByType = signalTypeCounters + .OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .Select(x => new WorkflowSignalPumpStatsSignalType + { + SignalType = x.Key, + ProcessedCount = Interlocked.Read(ref x.Value.ProcessedCount), + FailureCount = Interlocked.Read(ref x.Value.FailureCount), + DeadLetterCount = Interlocked.Read(ref x.Value.DeadLetterCount), + ConcurrencySkipCount = Interlocked.Read(ref x.Value.ConcurrencySkipCount), + }) + .ToArray(), + LastSuccess = currentLastSuccess, + LastFailure = currentLastFailure, + LastDeadLetter = currentLastDeadLetter, + }, + }; + } + + private static WorkflowSignalPumpLastEvent BuildEvent( + string consumerName, + WorkflowSignalEnvelope envelope, + int deliveryCount, + TimeSpan duration, + string? message) + { + return new WorkflowSignalPumpLastEvent + { + ConsumerName = consumerName, + SignalId = envelope.SignalId, + WorkflowInstanceId = envelope.WorkflowInstanceId, + SignalType = envelope.SignalType, + DeliveryCount = deliveryCount, + OccurredOnUtc = DateTime.UtcNow, + DurationMs = (long)duration.TotalMilliseconds, + Message = message, + }; + } + + private sealed class SignalTypeCounter + { + public long ProcessedCount; + public long FailureCount; + public long DeadLetterCount; + public long ConcurrencySkipCount; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/StellaOps.Workflow.Engine.csproj b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/StellaOps.Workflow.Engine.csproj new file mode 100644 index 000000000..c4159b005 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/StellaOps.Workflow.Engine.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + disable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/ElkJsWorkflowRenderLayoutEngine.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/ElkJsWorkflowRenderLayoutEngine.cs new file mode 100644 index 000000000..f9852d6dc --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/ElkJsWorkflowRenderLayoutEngine.cs @@ -0,0 +1,444 @@ +using System.Diagnostics; +using System.Text.Json; +using CultureInfo = global::System.Globalization.CultureInfo; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Renderer.ElkJs; + +public sealed class ElkJsWorkflowRenderLayoutEngine : INamedWorkflowRenderGraphLayoutEngine +{ + private const string RootParentKey = "\0root"; + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = false, + }; + + private static readonly SemaphoreSlim BootstrapLock = new(1, 1); + + private readonly ElkJsWorkflowRenderLayoutEngineOptions options; + + public ElkJsWorkflowRenderLayoutEngine(ElkJsWorkflowRenderLayoutEngineOptions? options = null) + { + this.options = options ?? new ElkJsWorkflowRenderLayoutEngineOptions(); + } + + public string ProviderName => WorkflowRenderLayoutProviderNames.ElkJs; + + public async Task LayoutAsync( + WorkflowRenderGraph graph, + WorkflowRenderLayoutRequest? request = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(graph); + + var toolDirectory = ResolveToolDirectory(); + await EnsureToolingAsync(toolDirectory, cancellationToken); + + var inputPath = Path.Combine(Path.GetTempPath(), $"workflow-elk-input-{Guid.NewGuid():N}.json"); + var outputPath = Path.Combine(Path.GetTempPath(), $"workflow-elk-output-{Guid.NewGuid():N}.json"); + + try + { + var payload = new ElkToolInput + { + Graph = BuildRootGraph(graph, request ?? new WorkflowRenderLayoutRequest()), + }; + + await File.WriteAllTextAsync( + inputPath, + JsonSerializer.Serialize(payload, SerializerOptions), + cancellationToken); + + var scriptPath = Path.Combine(toolDirectory, "layout.mjs"); + await RunProcessAsync( + options.NodeCommand, + toolDirectory, + [scriptPath, inputPath, outputPath], + cancellationToken); + + var resultJson = await File.ReadAllTextAsync(outputPath, cancellationToken); + var elkGraph = JsonSerializer.Deserialize(resultJson, SerializerOptions) + ?? throw new InvalidOperationException("ELK layout output could not be deserialized."); + + return MapResult(graph, elkGraph); + } + finally + { + TryDelete(inputPath); + TryDelete(outputPath); + } + } + + private string ResolveToolDirectory() + { + var candidates = new List + { + Path.Combine(AppContext.BaseDirectory, options.ToolRelativePath), + Path.Combine(Path.GetDirectoryName(typeof(ElkJsWorkflowRenderLayoutEngine).Assembly.Location) ?? AppContext.BaseDirectory, options.ToolRelativePath), + }; + + var probeDirectory = new DirectoryInfo(AppContext.BaseDirectory); + while (probeDirectory is not null) + { + if (File.Exists(Path.Combine(probeDirectory.FullName, "Directory.Build.props"))) + { + candidates.Add(Path.Combine(probeDirectory.FullName, options.SourceToolRelativePath)); + break; + } + + probeDirectory = probeDirectory.Parent; + } + + foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (Directory.Exists(candidate)) + { + return candidate; + } + } + + throw new DirectoryNotFoundException( + $"The ELK layout tooling directory was not found. Tried:{Environment.NewLine}{string.Join(Environment.NewLine, candidates)}"); + } + + private async Task EnsureToolingAsync(string toolDirectory, CancellationToken cancellationToken) + { + var markerPath = Path.Combine(toolDirectory, "node_modules", "elkjs", "package.json"); + if (File.Exists(markerPath)) + { + return; + } + + await BootstrapLock.WaitAsync(cancellationToken); + try + { + if (File.Exists(markerPath)) + { + return; + } + + await RunProcessAsync( + options.NpmCommand, + toolDirectory, + ["ci", "--no-audit", "--no-fund"], + cancellationToken); + } + finally + { + BootstrapLock.Release(); + } + } + + private static async Task RunProcessAsync( + string fileName, + string workingDirectory, + IReadOnlyCollection arguments, + CancellationToken cancellationToken) + { + var commandName = fileName; + var commandArguments = arguments; + if (OperatingSystem.IsWindows() + && (fileName.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".bat", StringComparison.OrdinalIgnoreCase))) + { + commandName = "cmd.exe"; + commandArguments = ["/c", fileName, .. arguments]; + } + + var startInfo = new ProcessStartInfo + { + FileName = commandName, + WorkingDirectory = workingDirectory, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + foreach (var argument in commandArguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + var standardOutputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var standardErrorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + using var registration = cancellationToken.Register(static state => + { + try + { + ((Process)state!).Kill(entireProcessTree: true); + } + catch + { + } + }, process); + + await process.WaitForExitAsync(cancellationToken); + var standardOutput = await standardOutputTask; + var standardError = await standardErrorTask; + + if (process.ExitCode == 0) + { + return; + } + + throw new InvalidOperationException( + $"External ELK process '{fileName}' failed with exit code {process.ExitCode}.{Environment.NewLine}" + + $"STDOUT:{Environment.NewLine}{standardOutput}{Environment.NewLine}" + + $"STDERR:{Environment.NewLine}{standardError}"); + } + + private static ElkNode BuildRootGraph(WorkflowRenderGraph graph, WorkflowRenderLayoutRequest request) + { + var nodesByParent = graph.Nodes + .GroupBy(x => x.ParentNodeId ?? RootParentKey, StringComparer.Ordinal) + .ToDictionary(x => x.Key, x => x.ToArray(), StringComparer.Ordinal); + + var root = new ElkNode + { + Id = graph.Id, + LayoutOptions = new Dictionary(StringComparer.Ordinal) + { + ["elk.algorithm"] = "layered", + ["elk.direction"] = request.Direction == WorkflowRenderLayoutDirection.LeftToRight ? "RIGHT" : "DOWN", + ["elk.spacing.nodeNode"] = request.NodeSpacing.ToString(CultureInfo.InvariantCulture), + ["elk.layered.spacing.nodeNodeBetweenLayers"] = request.LayerSpacing.ToString(CultureInfo.InvariantCulture), + ["elk.edgeRouting"] = "ORTHOGONAL", + }, + Children = BuildChildren(parentNodeId: RootParentKey, nodesByParent), + Edges = graph.Edges.Select(MapEdge).ToArray(), + }; + + return root; + } + + private static ElkNode[] BuildChildren( + string parentNodeId, + IReadOnlyDictionary nodesByParent) + { + if (!nodesByParent.TryGetValue(parentNodeId, out var children)) + { + return []; + } + + return children + .Select(node => new ElkNode + { + Id = node.Id, + Width = node.Width, + Height = node.Height, + Ports = node.Ports.Select(x => new ElkPort + { + Id = x.Id, + Width = x.Width, + Height = x.Height, + LayoutOptions = string.IsNullOrWhiteSpace(x.Side) + ? null + : new Dictionary(StringComparer.Ordinal) + { + ["elk.port.side"] = x.Side!, + }, + }).ToArray(), + Children = BuildChildren(node.Id, nodesByParent), + }) + .ToArray(); + } + + private static ElkEdge MapEdge(WorkflowRenderEdge edge) + { + return new ElkEdge + { + Id = edge.Id, + Sources = edge.SourcePortId is null ? [edge.SourceNodeId] : [$"{edge.SourceNodeId}.{edge.SourcePortId}"], + Targets = edge.TargetPortId is null ? [edge.TargetNodeId] : [$"{edge.TargetNodeId}.{edge.TargetPortId}"], + }; + } + + private static WorkflowRenderLayoutResult MapResult(WorkflowRenderGraph originalGraph, ElkNode root) + { + var sourceNodes = originalGraph.Nodes.ToDictionary(x => x.Id, StringComparer.Ordinal); + var sourceEdges = originalGraph.Edges.ToDictionary(x => x.Id, StringComparer.Ordinal); + + var positionedNodes = new List(); + FlattenNodes(root, parentNodeId: null, sourceNodes, positionedNodes); + + var routedEdges = new List(); + FlattenEdges(root, sourceEdges, routedEdges); + + return new WorkflowRenderLayoutResult + { + GraphId = originalGraph.Id, + Nodes = positionedNodes, + Edges = routedEdges, + }; + } + + private static void FlattenNodes( + ElkNode parent, + string? parentNodeId, + IReadOnlyDictionary sourceNodes, + ICollection target) + { + foreach (var child in parent.Children ?? []) + { + if (!sourceNodes.TryGetValue(child.Id, out var source)) + { + continue; + } + + target.Add(new WorkflowRenderPositionedNode + { + Id = source.Id, + Label = source.Label, + Kind = source.Kind, + IconKey = source.IconKey, + SemanticType = source.SemanticType, + SemanticKey = source.SemanticKey, + Route = source.Route, + TaskType = source.TaskType, + ParentNodeId = parentNodeId, + X = child.X ?? 0, + Y = child.Y ?? 0, + Width = child.Width ?? source.Width, + Height = child.Height ?? source.Height, + Ports = (child.Ports ?? []) + .Select(x => + { + var sourcePort = source.Ports.FirstOrDefault(port => string.Equals(port.Id, x.Id, StringComparison.Ordinal)); + return new WorkflowRenderPositionedPort + { + Id = x.Id, + Side = sourcePort?.Side, + X = x.X ?? 0, + Y = x.Y ?? 0, + Width = x.Width ?? sourcePort?.Width ?? 8, + Height = x.Height ?? sourcePort?.Height ?? 8, + }; + }) + .ToArray(), + }); + + FlattenNodes(child, child.Id, sourceNodes, target); + } + } + + private static void FlattenEdges( + ElkNode parent, + IReadOnlyDictionary sourceEdges, + ICollection target) + { + foreach (var edge in parent.Edges ?? []) + { + if (!sourceEdges.TryGetValue(edge.Id, out var source)) + { + continue; + } + + target.Add(new WorkflowRenderRoutedEdge + { + Id = source.Id, + SourceNodeId = source.SourceNodeId, + TargetNodeId = source.TargetNodeId, + SourcePortId = source.SourcePortId, + TargetPortId = source.TargetPortId, + Kind = source.Kind, + Label = source.Label, + Sections = (edge.Sections ?? []) + .Select(section => new WorkflowRenderEdgeSection + { + StartPoint = new WorkflowRenderPoint + { + X = section.StartPoint?.X ?? 0, + Y = section.StartPoint?.Y ?? 0, + }, + EndPoint = new WorkflowRenderPoint + { + X = section.EndPoint?.X ?? 0, + Y = section.EndPoint?.Y ?? 0, + }, + BendPoints = (section.BendPoints ?? []) + .Select(x => new WorkflowRenderPoint + { + X = x.X, + Y = x.Y, + }) + .ToArray(), + }) + .ToArray(), + }); + } + + foreach (var child in parent.Children ?? []) + { + FlattenEdges(child, sourceEdges, target); + } + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + } + } + + private sealed record ElkToolInput + { + public required ElkNode Graph { get; init; } + } + + private sealed record ElkNode + { + public required string Id { get; init; } + public double? X { get; init; } + public double? Y { get; init; } + public double? Width { get; init; } + public double? Height { get; init; } + public IDictionary? LayoutOptions { get; init; } + public ElkPort[]? Ports { get; init; } + public ElkNode[]? Children { get; init; } + public ElkEdge[]? Edges { get; init; } + } + + private sealed record ElkPort + { + public required string Id { get; init; } + public double? X { get; init; } + public double? Y { get; init; } + public double? Width { get; init; } + public double? Height { get; init; } + public IDictionary? LayoutOptions { get; init; } + } + + private sealed record ElkEdge + { + public required string Id { get; init; } + public string[]? Sources { get; init; } + public string[]? Targets { get; init; } + public ElkEdgeSection[]? Sections { get; init; } + } + + private sealed record ElkEdgeSection + { + public ElkPoint? StartPoint { get; init; } + public ElkPoint? EndPoint { get; init; } + public ElkPoint[]? BendPoints { get; init; } + } + + private sealed record ElkPoint + { + public required double X { get; init; } + public required double Y { get; init; } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/ElkJsWorkflowRenderLayoutEngineOptions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/ElkJsWorkflowRenderLayoutEngineOptions.cs new file mode 100644 index 000000000..b5abcac69 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/ElkJsWorkflowRenderLayoutEngineOptions.cs @@ -0,0 +1,18 @@ +using System; +using System.IO; + +namespace StellaOps.Workflow.Renderer.ElkJs; + +public sealed class ElkJsWorkflowRenderLayoutEngineOptions +{ + public string NodeCommand { get; set; } = "node"; + public string NpmCommand { get; set; } = OperatingSystem.IsWindows() ? "npm.cmd" : "npm"; + public string ToolRelativePath { get; set; } = Path.Combine("tools", "elk-layout"); + public string SourceToolRelativePath { get; set; } = Path.Combine( + "src", + "Workflow", + "__Libraries", + "StellaOps.Workflow.Renderer.ElkJs", + "tools", + "elk-layout"); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/StellaOps.Workflow.Renderer.ElkJs.csproj b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/StellaOps.Workflow.Renderer.ElkJs.csproj new file mode 100644 index 000000000..12d54f70d --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/StellaOps.Workflow.Renderer.ElkJs.csproj @@ -0,0 +1,26 @@ + + + net10.0 + enable + enable + + + + + + + + + PreserveNewest + tools\elk-layout\layout.mjs + + + PreserveNewest + tools\elk-layout\package.json + + + PreserveNewest + tools\elk-layout\package-lock.json + + + diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/tools/elk-layout/layout.mjs b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/tools/elk-layout/layout.mjs new file mode 100644 index 000000000..66246c3a6 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/tools/elk-layout/layout.mjs @@ -0,0 +1,15 @@ +import fs from "node:fs/promises"; +import ELK from "elkjs/lib/elk.bundled.js"; + +const [, , inputPath, outputPath] = process.argv; + +if (!inputPath || !outputPath) { + console.error("Usage: node layout.mjs "); + process.exit(1); +} + +const input = JSON.parse(await fs.readFile(inputPath, "utf8")); +const elk = new ELK(); +const result = await elk.layout(input.graph); + +await fs.writeFile(outputPath, JSON.stringify(result), "utf8"); diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/tools/elk-layout/package-lock.json b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/tools/elk-layout/package-lock.json new file mode 100644 index 000000000..792950e77 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/tools/elk-layout/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "serdica-workflow-elk-layout", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "serdica-workflow-elk-layout", + "dependencies": { + "elkjs": "0.11.1" + } + }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "license": "EPL-2.0" + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/tools/elk-layout/package.json b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/tools/elk-layout/package.json new file mode 100644 index 000000000..a78fc0dfa --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkJs/tools/elk-layout/package.json @@ -0,0 +1,8 @@ +{ + "name": "stellaops-workflow-elk-layout", + "private": true, + "type": "module", + "dependencies": { + "elkjs": "0.11.1" + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkSharp/ElkSharpWorkflowRenderLayoutEngine.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkSharp/ElkSharpWorkflowRenderLayoutEngine.cs new file mode 100644 index 000000000..d432d8615 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkSharp/ElkSharpWorkflowRenderLayoutEngine.cs @@ -0,0 +1,172 @@ +using StellaOps.Workflow.Abstractions; + +using StellaOps.ElkSharp; + +namespace StellaOps.Workflow.Renderer.ElkSharp; + +public sealed class ElkSharpWorkflowRenderLayoutEngine : INamedWorkflowRenderGraphLayoutEngine +{ + private readonly IElkLayoutEngine elkLayoutEngine; + + public ElkSharpWorkflowRenderLayoutEngine(IElkLayoutEngine? elkLayoutEngine = null) + { + this.elkLayoutEngine = elkLayoutEngine ?? new ElkSharpLayeredLayoutEngine(); + } + + public string ProviderName => WorkflowRenderLayoutProviderNames.ElkSharp; + + public async Task LayoutAsync( + WorkflowRenderGraph graph, + WorkflowRenderLayoutRequest? request = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(graph); + + request ??= new WorkflowRenderLayoutRequest(); + var elkGraph = new ElkGraph + { + Id = graph.Id, + Nodes = graph.Nodes.Select(MapNode).ToArray(), + Edges = graph.Edges.Select(MapEdge).ToArray(), + }; + + var elkResult = await elkLayoutEngine.LayoutAsync( + elkGraph, + new ElkLayoutOptions + { + Direction = request.Direction == WorkflowRenderLayoutDirection.LeftToRight + ? ElkLayoutDirection.LeftToRight + : ElkLayoutDirection.TopToBottom, + NodeSpacing = request.NodeSpacing, + LayerSpacing = request.LayerSpacing, + Effort = request.Effort switch + { + WorkflowRenderLayoutEffort.Draft => ElkLayoutEffort.Draft, + WorkflowRenderLayoutEffort.Balanced => ElkLayoutEffort.Balanced, + _ => ElkLayoutEffort.Best, + }, + OrderingIterations = request.OrderingIterations, + PlacementIterations = request.PlacementIterations, + }, + cancellationToken); + + return new WorkflowRenderLayoutResult + { + GraphId = elkResult.GraphId, + Nodes = elkResult.Nodes.Select(MapNode).ToArray(), + Edges = elkResult.Edges.Select(MapEdge).ToArray(), + }; + } + + private static ElkNode MapNode(WorkflowRenderNode node) + { + return new ElkNode + { + Id = node.Id, + Label = node.Label, + Kind = node.Kind, + IconKey = node.IconKey, + SemanticType = node.SemanticType, + SemanticKey = node.SemanticKey, + Route = node.Route, + TaskType = node.TaskType, + ParentNodeId = node.ParentNodeId, + Width = node.Width, + Height = node.Height, + Ports = node.Ports.Select(MapPort).ToArray(), + }; + } + + private static ElkPort MapPort(WorkflowRenderPort port) + { + return new ElkPort + { + Id = port.Id, + Side = port.Side, + Width = port.Width, + Height = port.Height, + }; + } + + private static ElkEdge MapEdge(WorkflowRenderEdge edge) + { + return new ElkEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + }; + } + + private static WorkflowRenderPositionedNode MapNode(ElkPositionedNode node) + { + return new WorkflowRenderPositionedNode + { + Id = node.Id, + Label = node.Label, + Kind = node.Kind, + IconKey = node.IconKey, + SemanticType = node.SemanticType, + SemanticKey = node.SemanticKey, + Route = node.Route, + TaskType = node.TaskType, + ParentNodeId = node.ParentNodeId, + X = node.X, + Y = node.Y, + Width = node.Width, + Height = node.Height, + Ports = node.Ports.Select(MapPort).ToArray(), + }; + } + + private static WorkflowRenderPositionedPort MapPort(ElkPositionedPort port) + { + return new WorkflowRenderPositionedPort + { + Id = port.Id, + Side = port.Side, + X = port.X, + Y = port.Y, + Width = port.Width, + Height = port.Height, + }; + } + + private static WorkflowRenderRoutedEdge MapEdge(ElkRoutedEdge edge) + { + return new WorkflowRenderRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = edge.Sections.Select(MapSection).ToArray(), + }; + } + + private static WorkflowRenderEdgeSection MapSection(ElkEdgeSection section) + { + return new WorkflowRenderEdgeSection + { + StartPoint = MapPoint(section.StartPoint), + EndPoint = MapPoint(section.EndPoint), + BendPoints = section.BendPoints.Select(MapPoint).ToArray(), + }; + } + + private static WorkflowRenderPoint MapPoint(ElkPoint point) + { + return new WorkflowRenderPoint + { + X = point.X, + Y = point.Y, + }; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkSharp/StellaOps.Workflow.Renderer.ElkSharp.csproj b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkSharp/StellaOps.Workflow.Renderer.ElkSharp.csproj new file mode 100644 index 000000000..2d145d27a --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.ElkSharp/StellaOps.Workflow.Renderer.ElkSharp.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + + + + + + + diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Msagl/MsaglWorkflowRenderLayoutEngine.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Msagl/MsaglWorkflowRenderLayoutEngine.cs new file mode 100644 index 000000000..4c8ecf580 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Msagl/MsaglWorkflowRenderLayoutEngine.cs @@ -0,0 +1,365 @@ +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Renderer.Msagl; + +public sealed class MsaglWorkflowRenderLayoutEngine : INamedWorkflowRenderGraphLayoutEngine +{ + public string ProviderName => WorkflowRenderLayoutProviderNames.Msagl; + + public Task LayoutAsync( + WorkflowRenderGraph graph, + WorkflowRenderLayoutRequest? request = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(graph); + cancellationToken.ThrowIfCancellationRequested(); + + var effectiveRequest = request ?? new WorkflowRenderLayoutRequest(); + var msaglGraph = new Microsoft.Msagl.Drawing.Graph(graph.Id) + { + Directed = true, + }; + + msaglGraph.Attr.LayerSeparation = effectiveRequest.LayerSpacing; + msaglGraph.Attr.NodeSeparation = effectiveRequest.NodeSpacing; + msaglGraph.Attr.LayerDirection = effectiveRequest.Direction == WorkflowRenderLayoutDirection.LeftToRight + ? Microsoft.Msagl.Drawing.LayerDirection.LR + : Microsoft.Msagl.Drawing.LayerDirection.TB; + + foreach (var renderNode in graph.Nodes) + { + var node = msaglGraph.AddNode(renderNode.Id); + node.LabelText = renderNode.Label; + node.Attr.LabelMargin = 8; + node.Attr.Shape = ResolveShape(renderNode.Kind); + node.Attr.XRadius = Math.Max(4, renderNode.Width / 2); + node.Attr.YRadius = Math.Max(4, renderNode.Height / 2); + node.UserData = renderNode; + } + + foreach (var renderEdge in graph.Edges) + { + var edge = msaglGraph.AddEdge(renderEdge.SourceNodeId, renderEdge.TargetNodeId); + edge.Attr.ArrowheadAtTarget = Microsoft.Msagl.Drawing.ArrowStyle.Normal; + edge.UserData = renderEdge; + } + + msaglGraph.CreateGeometryGraph(); + foreach (var node in msaglGraph.Nodes.Cast()) + { + var sourceNode = (WorkflowRenderNode)node.UserData; + node.GeometryNode.BoundaryCurve = Microsoft.Msagl.Drawing.NodeBoundaryCurves.GetNodeBoundaryCurve( + node, + sourceNode.Width, + sourceNode.Height); + } + + var settings = Microsoft.Msagl.Drawing.GeometryGraphCreator.CreateLayoutSettings(msaglGraph); + Microsoft.Msagl.Miscellaneous.LayoutHelpers.CalculateLayout( + msaglGraph.GeometryGraph, + settings, + new Microsoft.Msagl.Core.CancelToken(), + graph.Id); + + return Task.FromResult(MapResult(graph, msaglGraph, effectiveRequest.Direction)); + } + + private static WorkflowRenderLayoutResult MapResult( + WorkflowRenderGraph sourceGraph, + Microsoft.Msagl.Drawing.Graph msaglGraph, + WorkflowRenderLayoutDirection direction) + { + var positionedNodes = msaglGraph.Nodes + .Cast() + .Select(node => + { + var sourceNode = (WorkflowRenderNode)node.UserData; + var geometryNode = node.GeometryNode; + var boundingBox = geometryNode.BoundingBox; + return new WorkflowRenderPositionedNode + { + Id = sourceNode.Id, + Label = sourceNode.Label, + Kind = sourceNode.Kind, + IconKey = sourceNode.IconKey, + SemanticType = sourceNode.SemanticType, + SemanticKey = sourceNode.SemanticKey, + Route = sourceNode.Route, + TaskType = sourceNode.TaskType, + ParentNodeId = sourceNode.ParentNodeId, + X = boundingBox.Left, + Y = boundingBox.Bottom, + Width = boundingBox.Width, + Height = boundingBox.Height, + Ports = sourceNode.Ports.Select(port => new WorkflowRenderPositionedPort + { + Id = port.Id, + Side = port.Side, + X = boundingBox.Left + (boundingBox.Width / 2), + Y = boundingBox.Bottom + (boundingBox.Height / 2), + Width = port.Width, + Height = port.Height, + }).ToArray(), + }; + }) + .ToArray(); + var positionedNodesById = positionedNodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + + var routedEdges = msaglGraph.Edges + .Cast() + .Select(edge => + { + var sourceEdge = (WorkflowRenderEdge)edge.UserData; + return RouteEdge(sourceEdge, positionedNodesById, direction); + }) + .ToArray(); + + return new WorkflowRenderLayoutResult + { + GraphId = sourceGraph.Id, + Nodes = positionedNodes, + Edges = routedEdges, + }; + } + + private static Microsoft.Msagl.Drawing.Shape ResolveShape(string kind) + { + return kind switch + { + "Start" => Microsoft.Msagl.Drawing.Shape.Circle, + "End" => Microsoft.Msagl.Drawing.Shape.DoubleCircle, + "Decision" => Microsoft.Msagl.Drawing.Shape.Diamond, + _ => Microsoft.Msagl.Drawing.Shape.Box, + }; + } + + private static WorkflowRenderRoutedEdge RouteEdge( + WorkflowRenderEdge edge, + IReadOnlyDictionary nodesById, + WorkflowRenderLayoutDirection direction) + { + var sourceNode = nodesById[edge.SourceNodeId]; + var targetNode = nodesById[edge.TargetNodeId]; + var (sourceSide, targetSide) = ResolveRouteSides(sourceNode, targetNode, direction); + var startPoint = ResolveAnchorPoint(sourceNode, targetNode, sourceSide, direction); + var endPoint = ResolveAnchorPoint(targetNode, sourceNode, targetSide, direction); + var bendPoints = direction == WorkflowRenderLayoutDirection.LeftToRight + ? BuildHorizontalBendPoints(sourceNode, targetNode, startPoint, endPoint) + : BuildVerticalBendPoints(sourceNode, targetNode, startPoint, endPoint); + + return new WorkflowRenderRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = + [ + new WorkflowRenderEdgeSection + { + StartPoint = startPoint, + EndPoint = endPoint, + BendPoints = bendPoints, + }, + ], + }; + } + + private static (string SourceSide, string TargetSide) ResolveRouteSides( + WorkflowRenderPositionedNode sourceNode, + WorkflowRenderPositionedNode targetNode, + WorkflowRenderLayoutDirection direction) + { + var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d); + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterX = targetNode.X + (targetNode.Width / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var deltaX = targetCenterX - sourceCenterX; + var deltaY = targetCenterY - sourceCenterY; + + if (direction == WorkflowRenderLayoutDirection.LeftToRight) + { + var isHorizontal = deltaX >= 24d || Math.Abs(deltaX) >= Math.Abs(deltaY) * 0.35d; + return isHorizontal + ? (deltaX >= 0d ? "EAST" : "WEST", "WEST") + : (deltaY >= 0d ? "SOUTH" : "NORTH", deltaY >= 0d ? "NORTH" : "SOUTH"); + } + + var isVertical = deltaY >= 24d || Math.Abs(deltaY) >= Math.Abs(deltaX) * 0.35d; + return isVertical + ? (deltaY >= 0d ? "SOUTH" : "NORTH", "NORTH") + : (deltaX >= 0d ? "EAST" : "WEST", deltaX >= 0d ? "WEST" : "EAST"); + } + + private static WorkflowRenderPoint ResolveAnchorPoint( + WorkflowRenderPositionedNode node, + WorkflowRenderPositionedNode otherNode, + string preferredSide, + WorkflowRenderLayoutDirection direction) + { + var nodeCenterX = node.X + (node.Width / 2d); + var nodeCenterY = node.Y + (node.Height / 2d); + var targetX = otherNode.X + (otherNode.Width / 2d); + var targetY = otherNode.Y + (otherNode.Height / 2d); + var insetX = Math.Min(18d, node.Width / 4d); + var insetY = Math.Min(18d, node.Height / 4d); + + var preferredTargetX = preferredSide switch + { + "EAST" => node.X + node.Width + 256d, + "WEST" => node.X - 256d, + _ => Clamp(targetX, node.X + insetX, node.X + node.Width - insetX), + }; + var preferredTargetY = preferredSide switch + { + "SOUTH" => node.Y + node.Height + 256d, + "NORTH" => node.Y - 256d, + _ => Clamp(targetY, node.Y + insetY, node.Y + node.Height - insetY), + }; + + if (node.Kind is "Decision" or "Fork" or "Join") + { + return IntersectDiamondBoundary( + nodeCenterX, + nodeCenterY, + node.Width / 2d, + node.Height / 2d, + preferredTargetX - nodeCenterX, + preferredTargetY - nodeCenterY); + } + + _ = direction; + return new WorkflowRenderPoint + { + X = preferredSide switch + { + "EAST" => node.X + node.Width, + "WEST" => node.X, + _ => Clamp(preferredTargetX, node.X + insetX, node.X + node.Width - insetX), + }, + Y = preferredSide switch + { + "SOUTH" => node.Y + node.Height, + "NORTH" => node.Y, + _ => Clamp(preferredTargetY, node.Y + insetY, node.Y + node.Height - insetY), + }, + }; + } + + private static IReadOnlyCollection BuildHorizontalBendPoints( + WorkflowRenderPositionedNode sourceNode, + WorkflowRenderPositionedNode targetNode, + WorkflowRenderPoint startPoint, + WorkflowRenderPoint endPoint) + { + if (Math.Abs(endPoint.Y - startPoint.Y) <= 6d && endPoint.X >= startPoint.X) + { + return []; + } + + if (endPoint.X < startPoint.X) + { + var outerX = Math.Min(sourceNode.X, targetNode.X) - 72d; + return NormalizeBendPoints( + new WorkflowRenderPoint { X = outerX, Y = startPoint.Y }, + new WorkflowRenderPoint { X = outerX, Y = endPoint.Y }); + } + + var forwardGap = endPoint.X - startPoint.X; + var bendX = Math.Min( + endPoint.X - 24d, + startPoint.X + Math.Min(28d, Math.Max(12d, forwardGap * 0.18d))); + if (bendX <= startPoint.X + 1d || bendX >= endPoint.X - 1d) + { + bendX = (startPoint.X + endPoint.X) / 2d; + } + + return NormalizeBendPoints( + new WorkflowRenderPoint { X = bendX, Y = startPoint.Y }, + new WorkflowRenderPoint { X = bendX, Y = endPoint.Y }); + } + + private static IReadOnlyCollection BuildVerticalBendPoints( + WorkflowRenderPositionedNode sourceNode, + WorkflowRenderPositionedNode targetNode, + WorkflowRenderPoint startPoint, + WorkflowRenderPoint endPoint) + { + if (Math.Abs(endPoint.X - startPoint.X) <= 6d && endPoint.Y >= startPoint.Y) + { + return []; + } + + if (endPoint.Y < startPoint.Y) + { + var outerY = Math.Min(sourceNode.Y, targetNode.Y) - 72d; + return NormalizeBendPoints( + new WorkflowRenderPoint { X = startPoint.X, Y = outerY }, + new WorkflowRenderPoint { X = endPoint.X, Y = outerY }); + } + + var forwardGap = endPoint.Y - startPoint.Y; + var bendY = Math.Min( + endPoint.Y - 24d, + startPoint.Y + Math.Min(28d, Math.Max(12d, forwardGap * 0.18d))); + if (bendY <= startPoint.Y + 1d || bendY >= endPoint.Y - 1d) + { + bendY = (startPoint.Y + endPoint.Y) / 2d; + } + + return NormalizeBendPoints( + new WorkflowRenderPoint { X = startPoint.X, Y = bendY }, + new WorkflowRenderPoint { X = endPoint.X, Y = bendY }); + } + + private static IReadOnlyCollection NormalizeBendPoints(params WorkflowRenderPoint[] points) + { + var normalized = new List(points.Length); + foreach (var point in points) + { + if (normalized.Count > 0 + && Math.Abs(normalized[^1].X - point.X) <= 0.01d + && Math.Abs(normalized[^1].Y - point.Y) <= 0.01d) + { + continue; + } + + normalized.Add(point); + } + + return normalized; + } + + private static WorkflowRenderPoint IntersectDiamondBoundary( + double centerX, + double centerY, + double halfWidth, + double halfHeight, + double deltaX, + double deltaY) + { + if (Math.Abs(deltaX) < 0.001d && Math.Abs(deltaY) < 0.001d) + { + return new WorkflowRenderPoint + { + X = centerX, + Y = centerY, + }; + } + + var scale = 1d / ((Math.Abs(deltaX) / Math.Max(halfWidth, 0.001d)) + (Math.Abs(deltaY) / Math.Max(halfHeight, 0.001d))); + return new WorkflowRenderPoint + { + X = centerX + (deltaX * scale), + Y = centerY + (deltaY * scale), + }; + } + + private static double Clamp(double value, double minimum, double maximum) + { + return Math.Min(Math.Max(value, minimum), maximum); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Msagl/StellaOps.Workflow.Renderer.Msagl.csproj b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Msagl/StellaOps.Workflow.Renderer.Msagl.csproj new file mode 100644 index 000000000..e4b7ec798 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Msagl/StellaOps.Workflow.Renderer.Msagl.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/StellaOps.Workflow.Renderer.Svg.csproj b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/StellaOps.Workflow.Renderer.Svg.csproj new file mode 100644 index 000000000..0e15679b8 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/StellaOps.Workflow.Renderer.Svg.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderPngExporter.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderPngExporter.cs new file mode 100644 index 000000000..7f959a8a6 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderPngExporter.cs @@ -0,0 +1,41 @@ +using System.Text; + +using SkiaSharp; +using Svg.Skia; + +namespace StellaOps.Workflow.Renderer.Svg; + +public sealed class WorkflowRenderPngExporter +{ + public async Task ExportAsync( + WorkflowRenderSvgDocument document, + string pngPath, + float scale = 2f, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentException.ThrowIfNullOrWhiteSpace(pngPath); + + var svg = new SKSvg(); + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(document.Svg)); + var picture = svg.Load(stream) ?? throw new InvalidOperationException("SVG document could not be loaded."); + + var width = Math.Max(1, (int)Math.Ceiling(document.Width * scale)); + var height = Math.Max(1, (int)Math.Ceiling(document.Height * scale)); + + using var surface = SKSurface.Create(new SKImageInfo(width, height)); + var canvas = surface.Canvas; + canvas.Clear(SKColors.White); + canvas.Scale(scale); + canvas.DrawPicture(picture); + canvas.Flush(); + + Directory.CreateDirectory(Path.GetDirectoryName(pngPath)!); + + using var image = surface.Snapshot(); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + await using var output = File.Open(pngPath, FileMode.Create, FileAccess.Write, FileShare.None); + data.SaveTo(output); + await output.FlushAsync(cancellationToken); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgDocument.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgDocument.cs new file mode 100644 index 000000000..aedb7dc75 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgDocument.cs @@ -0,0 +1,8 @@ +namespace StellaOps.Workflow.Renderer.Svg; + +public sealed record WorkflowRenderSvgDocument +{ + public required string Svg { get; init; } + public required double Width { get; init; } + public required double Height { get; init; } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs new file mode 100644 index 000000000..41243d947 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs @@ -0,0 +1,2310 @@ +using System.Globalization; +using System.Net; +using System.Text; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Renderer.Svg; + +public sealed class WorkflowRenderSvgRenderer +{ + private const double Margin = 32; + private const double HeaderHeight = 220; + private const double LegendTop = 34; + private const double LegendHeight = 160; + private const double LabelMinTop = LegendTop + LegendHeight + 18; + private const double LabelInsetX = 56; + private const double LabelInsetRight = 16; + + public WorkflowRenderSvgDocument Render( + WorkflowRenderLayoutResult layout, + string title) + { + ArgumentNullException.ThrowIfNull(layout); + ArgumentException.ThrowIfNullOrWhiteSpace(title); + + var bounds = CalculateBounds(layout); + var width = Math.Max(1328, bounds.Width + (Margin * 2)); + var height = Math.Max(320, bounds.Height + HeaderHeight + Margin + 96); + var offsetX = Margin - bounds.MinX; + var offsetY = HeaderHeight - bounds.MinY; + + var builder = new StringBuilder(); + var edgeLabels = new List(); + var pendingEdgeLabels = new List(); + var edgeLaneOffsets = ResolveEdgeLaneOffsets(layout.Edges); + var nodeObstacles = layout.Nodes + .Select(node => InflateRect( + new WorkflowRenderRect( + node.X + offsetX, + node.Y + offsetY, + node.Width, + node.Height), + 26d)) + .ToArray(); + builder.AppendLine($""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {Encode(title)} + """); + RenderLegend(builder, width, layout); + + var highways = DetectHighwayGroups(layout); + var highwayByEdgeId = highways.Values + .SelectMany(group => group.EdgeIds.Select(edgeId => new KeyValuePair(edgeId, group))) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + var endSinkGroups = DetectEndSinkGroups(layout); + var endSinkGroupByEdgeId = endSinkGroups.Values + .SelectMany(group => group.EdgeIds.Select(edgeId => new KeyValuePair(edgeId, group))) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + var renderedPaths = new List(); + + foreach (var edge in layout.Edges) + { + for (var sectionIndex = 0; sectionIndex < edge.Sections.Count; sectionIndex++) + { + var section = edge.Sections.ElementAt(sectionIndex); + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); + if (edgeLaneOffsets.TryGetValue(CreateEdgeSectionKey(edge.Id, sectionIndex), out var laneOffset) + && Math.Abs(laneOffset) > 0.001d) + { + points = ApplyLaneOffset(points, laneOffset); + } + + var edgeStyle = ResolveEdgeStyle(edge.Label); + var (strokeWidth, strokeOpacity) = ResolveEdgePathAppearance(edge, points); + if (endSinkGroupByEdgeId.TryGetValue(edge.Id, out var endSinkGroup) && points.Count >= 2) + { + var truncated = TruncateToEndSinkCollector(points, endSinkGroup.CorridorY, endSinkGroup.CollectorX); + renderedPaths.Add(new RenderedEdgePath( + edge.Id, + truncated, + edgeStyle, + strokeWidth, + null, + strokeOpacity, + endSinkGroup.GroupId, + IsCollector: false)); + } + else if (highwayByEdgeId.TryGetValue(edge.Id, out var highway) && points.Count >= 2) + { + var edgeIdx = highway.EdgeIds.IndexOf(edge.Id); + List truncated; + if (highway.IsBackward) + { + truncated = TruncateBackwardToCollector(points, highway.CollectorX, highway.CollectorY); + } + else + { + truncated = TruncateToCollector(points, highway.CollectorX, highway.CollectorY, + edgeIdx, highway.EdgeIds.Count, highway.SpreadPerEdge); + } + + renderedPaths.Add(new RenderedEdgePath( + edge.Id, + truncated, + edgeStyle, + strokeWidth, + highway.IsBackward ? null : edgeStyle.MarkerId, + strokeOpacity, + highway.GroupId, + IsCollector: false)); + } + else + { + renderedPaths.Add(new RenderedEdgePath( + edge.Id, + points, + edgeStyle, + strokeWidth, + edgeStyle.MarkerId, + strokeOpacity, + null, + IsCollector: false)); + } + + if (ShouldRenderEdgeLabel(edge.Label)) + { + pendingEdgeLabels.Add(new PendingEdgeLabel( + points, + edge.Label!, + edgeStyle)); + } + } + } + + foreach (var highway in highways.Values) + { + var trunkStyle = ResolveEdgeStyle(highway.DominantLabel); + var (collectorStrokeWidth, collectorOpacity) = ResolveCollectorAppearance(highway.DominantLabel, highway.IsBackward, isEndSink: false); + renderedPaths.Add(new RenderedEdgePath( + $"collector::{highway.GroupId}", + highway.IsBackward + ? + [ + new WorkflowRenderPoint { X = highway.CollectorX, Y = highway.CollectorY }, + new WorkflowRenderPoint { X = highway.TargetX, Y = highway.CollectorY }, + new WorkflowRenderPoint { X = highway.TargetX, Y = highway.TargetY }, + ] + : + [ + new WorkflowRenderPoint { X = highway.CollectorX, Y = highway.CollectorY }, + new WorkflowRenderPoint { X = highway.TargetX, Y = highway.TargetY }, + ], + trunkStyle, + collectorStrokeWidth, + highway.IsBackward ? null : trunkStyle.MarkerId, + collectorOpacity, + highway.GroupId, + IsCollector: true)); + } + + foreach (var endSinkGroup in endSinkGroups.Values) + { + var style = ResolveEdgeStyle(endSinkGroup.DominantLabel); + var (collectorStrokeWidth, collectorOpacity) = ResolveCollectorAppearance(endSinkGroup.DominantLabel, isBackward: false, isEndSink: true); + renderedPaths.Add(new RenderedEdgePath( + $"sink-highway::{endSinkGroup.GroupId}", + [ + new WorkflowRenderPoint { X = endSinkGroup.CorridorStartX, Y = endSinkGroup.CorridorY }, + new WorkflowRenderPoint { X = endSinkGroup.CollectorX, Y = endSinkGroup.CorridorY }, + new WorkflowRenderPoint { X = endSinkGroup.CollectorX, Y = endSinkGroup.TargetY }, + new WorkflowRenderPoint { X = endSinkGroup.TargetX, Y = endSinkGroup.TargetY }, + ], + style, + collectorStrokeWidth, + style.MarkerId, + collectorOpacity, + endSinkGroup.GroupId, + IsCollector: true)); + } + + var edgePathObstacles = BuildEdgePathObstacles(renderedPaths, offsetX, offsetY); + var labelObstacles = nodeObstacles + .Concat(edgePathObstacles) + .ToArray(); + foreach (var pendingEdgeLabel in pendingEdgeLabels) + { + edgeLabels.Add(ResolveEdgeLabelPlacement( + pendingEdgeLabel.Points, + pendingEdgeLabel.Label, + pendingEdgeLabel.EdgeStyle, + offsetX, + offsetY, + width, + height, + labelObstacles, + edgeLabels)); + } + + var bridgeGapsByPathIndex = ResolveBridgeGaps(renderedPaths); + for (var pathIndex = 0; pathIndex < renderedPaths.Count; pathIndex++) + { + if (bridgeGapsByPathIndex.TryGetValue(pathIndex, out var bridgeGaps)) + { + foreach (var bridgeGap in bridgeGaps) + { + builder.AppendLine($""" + + """); + } + } + + var renderedPath = renderedPaths[pathIndex]; + var pathData = BuildRoundedEdgePath(renderedPath.Points, offsetX, offsetY, renderedPath.IsCollector ? 0d : 12d); + var markerAttribute = string.IsNullOrWhiteSpace(renderedPath.MarkerId) + ? string.Empty + : $" marker-end=\"{renderedPath.MarkerId}\""; + var dataHighwayAttribute = string.IsNullOrWhiteSpace(renderedPath.GroupId) + ? string.Empty + : $" data-highway-group=\"{Encode(renderedPath.GroupId)}\""; + var collectorAttribute = renderedPath.IsCollector ? " data-highway-collector=\"true\"" : string.Empty; + builder.AppendLine($""" + + """); + } + + foreach (var node in layout.Nodes) + { + RenderNode(builder, node, offsetX, offsetY); + } + + foreach (var edgeLabel in edgeLabels) + { + RenderEdgeLabel(builder, edgeLabel); + } + + builder.AppendLine(""); + + return new WorkflowRenderSvgDocument + { + Svg = builder.ToString(), + Width = width, + Height = height, + }; + } + + private static void RenderNode( + StringBuilder builder, + WorkflowRenderPositionedNode node, + double offsetX, + double offsetY) + { + var x = node.X + offsetX; + var y = node.Y + offsetY; + var color = ResolveFill(node.Kind); + var stroke = ResolveStroke(node.Kind); + + switch (node.Kind) + { + case "Start": + var startRadius = Math.Min(40d, node.Height * 0.34d); + var startGlyphRadius = Math.Min(26d, node.Height * 0.24d); + builder.AppendLine($""" + + + + """); + break; + case "End": + var endRadius = Math.Min(40d, node.Height * 0.34d); + var endGlyphRadius = Math.Min(26d, node.Height * 0.24d); + builder.AppendLine($""" + + + + + """); + break; + case "Decision": + builder.AppendLine($""" + + + """); + break; + case "Fork": + case "Join": + builder.AppendLine($""" + + + """); + break; + default: + var panelFill = ResolvePanelFill(node.Kind); + var shellFill = ResolveShellFill(node.Kind); + var contentFill = ResolveContentFill(node.Kind); + builder.AppendLine($""" + + + + + + + """); + RenderPanelGlyph(builder, x, y, node, stroke); + break; + } + + if (ShouldRenderBadge(node.Kind)) + { + RenderBadge(builder, x, y, node); + } + + RenderLabel(builder, x, y, node); + } + + private static void RenderBadge( + StringBuilder builder, + double x, + double y, + WorkflowRenderPositionedNode node) + { + var badgeFill = ResolveBadgeFill(node.Kind); + builder.AppendLine($""" + + + """); + RenderBadgeGlyph(builder, x + 22, y + 22, 13d, node.Kind, "#0f172a"); + builder.AppendLine(""); + } + + private static void RenderLabel( + StringBuilder builder, + double x, + double y, + WorkflowRenderPositionedNode node) + { + var lines = WrapLabel(node); + if (string.Equals(node.Kind, "Decision", StringComparison.OrdinalIgnoreCase) + || string.Equals(node.Kind, "Fork", StringComparison.OrdinalIgnoreCase) + || string.Equals(node.Kind, "Join", StringComparison.OrdinalIgnoreCase)) + { + var glyphCenterX = x + (node.Width / 2d); + var glyphCenterY = y + Math.Max(30d, Math.Min(node.Height * 0.30d, 40d)); + var labelBlockHeight = Math.Max(0d, (lines.Length - 1) * 18d); + var minimumStartY = glyphCenterY + 28d; + var preferredStartY = y + (node.Height * 0.64d) - (labelBlockHeight / 2d); + var maximumStartY = Math.Max(minimumStartY, y + node.Height - 20d - labelBlockHeight); + var startYGateway = Clamp(preferredStartY, minimumStartY, maximumStartY); + builder.AppendLine($""" + + """); + RenderDiamondGlyph(builder, glyphCenterX, glyphCenterY, node.Kind, "#0f172a"); + + for (var gatewayIndex = 0; gatewayIndex < lines.Length; gatewayIndex++) + { + builder.AppendLine($""" + {Encode(lines[gatewayIndex])} + """); + } + + return; + } + + if (string.Equals(node.Kind, "Start", StringComparison.OrdinalIgnoreCase) + || string.Equals(node.Kind, "End", StringComparison.OrdinalIgnoreCase)) + { + var labelX = x + Math.Max(74d, Math.Min(node.Width - 28d, node.Width * 0.54d)); + builder.AppendLine($""" + {Encode(node.Label)} + """); + return; + } + + var contentLeft = x + LabelInsetX; + var contentWidth = Math.Max(72, node.Width - LabelInsetX - LabelInsetRight); + var startY = y + (node.Height / 2) - ((lines.Length - 1) * 9); + var fontSize = ResolveLabelFontSize(lines); + for (var index = 0; index < lines.Length; index++) + { + builder.AppendLine($""" + {Encode(lines[index])} + """); + } + } + + private static double ResolveLabelFontSize(IReadOnlyCollection lines) + { + return lines.Any(line => line.Length > 15) + ? 12d + : 13d; + } + + private static void RenderEdgeLabel( + StringBuilder builder, + WorkflowRenderEdgeLabelPlacement placement) + { + var deltaX = placement.AnchorX - placement.CenterX; + var deltaY = placement.AnchorY - placement.CenterY; + var connectorX = Math.Abs(deltaX) > Math.Abs(deltaY) + ? (deltaX >= 0 ? placement.Left + placement.Width : placement.Left) + : placement.CenterX; + var connectorY = Math.Abs(deltaX) > Math.Abs(deltaY) + ? placement.CenterY + : (deltaY >= 0 ? placement.Top + placement.Height : placement.Top); + + builder.AppendLine($""" + + + {Encode(placement.Label)} + """); + } + + private static void RenderLegend(StringBuilder builder, double canvasWidth, WorkflowRenderLayoutResult layout) + { + var nodeKinds = new HashSet(layout.Nodes.Select(n => n.Kind), StringComparer.OrdinalIgnoreCase); + var edgeLabels = new HashSet( + layout.Edges.Where(e => !string.IsNullOrWhiteSpace(e.Label)).Select(e => e.Label!), + StringComparer.OrdinalIgnoreCase); + + var legendWidth = Math.Min(canvasWidth - 48d, 1260d); + builder.AppendLine($""" + + + Legend + Node Shapes: + + """); + + var nodeChips = new List<(string Kind, string Label)>(); + if (nodeKinds.Contains("Start")) nodeChips.Add(("Start", "Start")); + if (nodeKinds.Contains("End")) nodeChips.Add(("End", "End")); + if (nodeKinds.Contains("SetState")) nodeChips.Add(("SetState", "Setter")); + if (nodeKinds.Contains("HumanTask")) nodeChips.Add(("HumanTask", "Human Task")); + if (nodeKinds.Contains("TransportCall")) nodeChips.Add(("TransportCall", "Service Call")); + if (nodeKinds.Contains("Timer")) nodeChips.Add(("Timer", "Timer")); + if (nodeKinds.Contains("Decision")) nodeChips.Add(("Decision", "Decision / Branch")); + if (nodeKinds.Contains("Fork") || nodeKinds.Contains("Join")) nodeChips.Add(("Fork", "Fork / Join")); + if (nodeKinds.Contains("Repeat")) nodeChips.Add(("Repeat", "Repeat / Loop")); + if (nodeKinds.Contains("Signal")) nodeChips.Add(("Signal", "Signal")); + + var nodeChipX = 118d; + foreach (var (kind, label) in nodeChips) + { + RenderLegendNodeChip(builder, nodeChipX, 62, kind, label); + nodeChipX += (label.Length * 7.5d) + 56d; + } + + var badgeChips = new List<(string Kind, string Label)>(); + if (nodeKinds.Contains("SetState")) badgeChips.Add(("SetState", "set state")); + if (nodeKinds.Contains("BusinessReference")) badgeChips.Add(("BusinessReference", "business ref")); + if (nodeKinds.Contains("HumanTask")) badgeChips.Add(("HumanTask", "human task")); + if (nodeKinds.Contains("TransportCall")) badgeChips.Add(("TransportCall", "service call")); + if (nodeKinds.Contains("Signal")) badgeChips.Add(("Signal", "signal")); + if (nodeKinds.Contains("Repeat")) badgeChips.Add(("Repeat", "repeat")); + if (nodeKinds.Contains("SubWorkflow") || nodeKinds.Contains("ContinueWorkflow")) badgeChips.Add(("SubWorkflow", "subworkflow")); + + if (badgeChips.Count > 0) + { + builder.AppendLine(""" + Badges: + """); + var badgeChipX = 102d; + foreach (var (kind, label) in badgeChips) + { + RenderLegendBadgeChip(builder, badgeChipX, 128, kind, label); + badgeChipX += (label.Length * 7d) + 50d; + } + } + + var hasWhenCondition = edgeLabels.Any(l => l.StartsWith("when ", StringComparison.OrdinalIgnoreCase)); + var hasDefault = edgeLabels.Any(l => l.StartsWith("default", StringComparison.OrdinalIgnoreCase) + || l.StartsWith("otherwise", StringComparison.OrdinalIgnoreCase)); + var hasFailure = edgeLabels.Any(l => l.Contains("failure", StringComparison.OrdinalIgnoreCase)); + var hasTimeout = edgeLabels.Any(l => l.Contains("timeout", StringComparison.OrdinalIgnoreCase)); + var hasRepeatBody = edgeLabels.Any(l => l.StartsWith("repeat", StringComparison.OrdinalIgnoreCase) + || string.Equals(l, "body", StringComparison.OrdinalIgnoreCase)); + var hasMissingCondition = edgeLabels.Any(l => l.Contains("missing", StringComparison.OrdinalIgnoreCase)); + + var branchChips = new List<(string Color, string Label)>(); + if (hasWhenCondition) branchChips.Add(("#15803d", "when condition")); + if (hasDefault) branchChips.Add(("#475569", "default / otherwise")); + if (hasFailure) branchChips.Add(("#dc2626", "on failure")); + if (hasTimeout) branchChips.Add(("#d97706", "on timeout")); + if (hasRepeatBody) branchChips.Add(("#2563eb", "repeat / body")); + if (hasMissingCondition) branchChips.Add(("#b91c1c", "missing condition")); + + if (branchChips.Count > 0) + { + builder.AppendLine(""" + Branch Callouts: + """); + var branchChipX = 152d; + foreach (var (color, label) in branchChips) + { + RenderLegendBranchChip(builder, branchChipX, 160, color, label); + branchChipX += (label.Length * 7d) + 46d; + } + } + } + + private static (double MinX, double MinY, double Width, double Height) CalculateBounds(WorkflowRenderLayoutResult layout) + { + var minX = layout.Nodes.Min(node => node.X); + var minY = layout.Nodes.Min(node => node.Y); + var maxX = layout.Nodes.Max(node => node.X + node.Width); + var maxY = layout.Nodes.Max(node => node.Y + node.Height); + + foreach (var point in layout.Edges + .SelectMany(edge => edge.Sections) + .SelectMany(section => new[] { section.StartPoint, section.EndPoint }.Concat(section.BendPoints))) + { + minX = Math.Min(minX, point.X); + minY = Math.Min(minY, point.Y); + maxX = Math.Max(maxX, point.X); + maxY = Math.Max(maxY, point.Y); + } + + return (minX, minY, maxX - minX, maxY - minY); + } + + private static string ResolveFill(string kind) + { + return kind switch + { + "Start" => "#bbf7d0", + "End" => "#fecaca", + "Decision" => "#fcd34d", + "Fork" => "#d8b4fe", + "Join" => "#ddd6fe", + "SetState" => "#e8f0ff", + "BusinessReference" => "#f3ebff", + "HumanTask" => "#dff7f3", + "TransportCall" => "#f3ebff", + "SubWorkflow" => "#ffe5f0", + "Timer" => "#dff6ff", + "Signal" => "#eef8d8", + "Repeat" => "#ffe6ec", + "ContinueWorkflow" => "#f3ebff", + "Complete" => "#ffe7e7", + _ => "#ffffff", + }; + } + + private static string ResolveShellFill(string kind) + { + return kind switch + { + "SetState" => "#f7fbff", + "HumanTask" => "#f4fdfb", + "BusinessReference" or "TransportCall" or "ContinueWorkflow" => "#fdfbff", + "SubWorkflow" or "Repeat" => "#fff8fb", + "Timer" => "#f8fdff", + "Signal" => "#fbfef7", + "Complete" => "#fff7f7", + _ => ResolveFill(kind), + }; + } + + private static string ResolveContentFill(string kind) + { + return kind switch + { + "SetState" => "#eaf2ff", + "HumanTask" => "#e1faf5", + "BusinessReference" or "TransportCall" or "ContinueWorkflow" => "#f5eeff", + "SubWorkflow" or "Repeat" => "#ffe9f1", + "Timer" => "#e3f7ff", + "Signal" => "#eff8db", + "Complete" => "#ffe8e8", + _ => ResolveFill(kind), + }; + } + + private static string ResolveStroke(string kind) + { + return kind switch + { + "Start" => "#15803d", + "End" => "#b91c1c", + "Decision" => "#a16207", + "Fork" => "#7e22ce", + "Join" => "#7e22ce", + "SetState" => "#2563eb", + "BusinessReference" => "#7c3aed", + "HumanTask" => "#0f766e", + "TransportCall" => "#6d28d9", + "SubWorkflow" => "#be185d", + "Timer" => "#0369a1", + "Signal" => "#4d7c0f", + "Repeat" => "#be123c", + "ContinueWorkflow" => "#7c3aed", + "Complete" => "#b91c1c", + _ => "#334155", + }; + } + + private static string ResolveBadgeFill(string kind) + { + return kind switch + { + "Decision" or "Fork" or "Join" => "#fff7ed", + "HumanTask" => "#e6fffb", + "TransportCall" => "#f5f3ff", + "SetState" => "#eff6ff", + "Start" => "#f0fdf4", + "End" => "#fef2f2", + "Timer" => "#ecfeff", + "Signal" => "#f7fee7", + "Repeat" => "#eff6ff", + "BusinessReference" => "#f5f3ff", + _ => "#ffffff", + }; + } + + private static string ResolvePanelFill(string kind) + { + return kind switch + { + "SetState" => "#bfdbfe", + "BusinessReference" => "#ddd6fe", + "HumanTask" => "#99f6e4", + "TransportCall" => "#ddd6fe", + "SubWorkflow" => "#fecdd3", + "Timer" => "#bae6fd", + "Signal" => "#d9f99d", + "Repeat" => "#fecdd3", + "ContinueWorkflow" => "#ddd6fe", + "Complete" => "#fecaca", + _ => "#e2e8f0", + }; + } + + private static bool ShouldRenderBadge(string kind) + { + return kind is not ("Start" or "End" or "Decision" or "Fork" or "Join" or "Timer"); + } + + private static void RenderPanelGlyph( + StringBuilder builder, + double x, + double y, + WorkflowRenderPositionedNode node, + string stroke) + { + if (!string.Equals(node.Kind, "Timer", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var centerX = x + 24d; + var centerY = y + (node.Height / 2d); + builder.AppendLine($""" + + + + """); + } + + private static string[] WrapLabel(WorkflowRenderPositionedNode node) + { + var logicalLines = node.Label + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Split('\n', StringSplitOptions.None); + var rendered = new List(); + var maxCharsPerLine = ResolveMaxCharsPerLine(node); + var maxLines = string.Equals(node.Kind, "SetState", StringComparison.OrdinalIgnoreCase) + ? 5 + : 4; + + foreach (var logicalLine in logicalLines) + { + rendered.AddRange(WrapSingleLine(logicalLine, maxCharsPerLine)); + } + + if (rendered.Count <= maxLines) + { + return rendered.ToArray(); + } + + var clipped = rendered.Take(maxLines).ToArray(); + clipped[^1] = "..."; + return clipped; + } + + private static int ResolveMaxCharsPerLine(WorkflowRenderPositionedNode node) + { + var availableWidth = node.Kind switch + { + "Decision" or "Fork" or "Join" => node.Width * 0.55d, + "Start" or "End" => node.Width - 64d, + "SetState" => Math.Max(68d, node.Width - LabelInsetX - LabelInsetRight - 8d), + _ => Math.Max(72d, node.Width - LabelInsetX - LabelInsetRight), + }; + + return Math.Clamp((int)Math.Floor(availableWidth / 7.1d), 11, 22); + } + + private static IReadOnlyCollection WrapSingleLine(string line, int maxCharsPerLine) + { + if (string.IsNullOrWhiteSpace(line)) + { + return [string.Empty]; + } + + var words = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (words.Length == 0) + { + return [TruncateSingleLine(line, maxCharsPerLine)]; + } + + var lines = new List(); + var current = new StringBuilder(); + foreach (var word in words) + { + var candidate = current.Length == 0 ? word : $"{current} {word}"; + if (candidate.Length <= maxCharsPerLine) + { + current.Clear(); + current.Append(candidate); + continue; + } + + if (current.Length > 0) + { + lines.Add(current.ToString()); + current.Clear(); + } + + current.Append(TruncateSingleLine(word, maxCharsPerLine)); + } + + if (current.Length > 0) + { + lines.Add(current.ToString()); + } + + return lines.Count == 0 + ? [TruncateSingleLine(line, maxCharsPerLine)] + : lines; + } + + private static string TruncateSingleLine(string value, int maxCharsPerLine) + { + if (value.Length <= maxCharsPerLine) + { + return value; + } + + return $"{value[..(maxCharsPerLine - 3)]}..."; + } + + private static string Encode(string value) + { + return WebUtility.HtmlEncode(value); + } + + private static string Format(double value) + { + return value.ToString("0.###", CultureInfo.InvariantCulture); + } + + private static WorkflowRenderEdgeLabelPlacement ResolveEdgeLabelPlacement( + IReadOnlyList points, + string label, + WorkflowRenderEdgeStyle edgeStyle, + double offsetX, + double offsetY, + double canvasWidth, + double canvasHeight, + IReadOnlyCollection nodeObstacles, + IReadOnlyCollection placedLabels) + { + var renderedLabel = TruncateSingleLine(label, 50); + var width = Math.Min(368d, Math.Max(92d, (renderedLabel.Length * 6.35d) + 10d)); + var height = 22d; + var isErrorLabel = label.Contains("failure", StringComparison.OrdinalIgnoreCase) + || label.Contains("timeout", StringComparison.OrdinalIgnoreCase); + var segment = ResolveLabelAnchorSegment(points); + double anchorX; + double anchorY; + if (isErrorLabel && points.Count >= 2) + { + var sourcePoint = points[0]; + var secondPoint = points[Math.Min(1, points.Count - 1)]; + anchorX = (sourcePoint.X * 0.6d + secondPoint.X * 0.4d) + offsetX; + anchorY = (sourcePoint.Y * 0.6d + secondPoint.Y * 0.4d) + offsetY; + } + else + { + anchorX = ((segment.Start.X + segment.End.X) / 2d) + offsetX; + anchorY = ((segment.Start.Y + segment.End.Y) / 2d) + offsetY; + } + var horizontal = Math.Abs(segment.End.Y - segment.Start.Y) <= Math.Abs(segment.End.X - segment.Start.X); + var occupied = nodeObstacles + .Concat(placedLabels.Select(labelPlacement => InflateRect(ToRect(labelPlacement), 16d))) + .ToArray(); + + WorkflowRenderEdgeLabelPlacement? bestPlacement = null; + var bestCollisionCount = int.MaxValue; + var bestOverlapArea = double.MaxValue; + var bestDistance = double.MaxValue; + + foreach (var candidate in EnumerateEdgeLabelCandidates(anchorX, anchorY, width, height, horizontal, canvasWidth, canvasHeight)) + { + var placement = new WorkflowRenderEdgeLabelPlacement( + renderedLabel, + edgeStyle, + anchorX, + anchorY, + candidate.Left, + candidate.Top, + width, + height); + var candidateRect = ToRect(placement); + var collisionCount = occupied.Count(obstacle => Intersects(candidateRect, obstacle)); + var overlapArea = occupied.Sum(obstacle => CalculateOverlapArea(candidateRect, obstacle)); + var distance = Math.Abs(placement.CenterX - anchorX) + Math.Abs(placement.CenterY - anchorY); + + if (collisionCount < bestCollisionCount + || (collisionCount == bestCollisionCount && overlapArea < bestOverlapArea - 0.001d) + || (collisionCount == bestCollisionCount && Math.Abs(overlapArea - bestOverlapArea) < 0.001d && distance < bestDistance)) + { + bestPlacement = placement; + bestCollisionCount = collisionCount; + bestOverlapArea = overlapArea; + bestDistance = distance; + + if (collisionCount == 0 && overlapArea < 0.001d) + { + break; + } + } + } + + return bestPlacement + ?? new WorkflowRenderEdgeLabelPlacement( + renderedLabel, + edgeStyle, + anchorX, + anchorY, + Clamp(anchorX - (width / 2d), 24d, Math.Max(24d, canvasWidth - 24d - width)), + Clamp(anchorY - height - 54d, LabelMinTop, Math.Max(LabelMinTop, canvasHeight - 24d - height)), + width, + height); + } + + private static IEnumerable<(double Left, double Top)> EnumerateEdgeLabelCandidates( + double anchorX, + double anchorY, + double width, + double height, + bool horizontal, + double canvasWidth, + double canvasHeight) + { + static double ResolveTopBound(double canvasHeightLocal, double heightLocal) + { + return Math.Max(LabelMinTop, canvasHeightLocal - 24d - heightLocal); + } + + (double Left, double Top) Normalize(double left, double top) + { + var maxLeft = Math.Max(24d, canvasWidth - 24d - width); + var maxTop = ResolveTopBound(canvasHeight, height); + return ( + Clamp(left, 24d, maxLeft), + Clamp(top, LabelMinTop, maxTop)); + } + + for (var level = 0; level < 5; level++) + { + var primaryGap = horizontal ? 86d + (level * 34d) : 60d + (level * 28d); + var secondaryGap = 54d + (level * 20d); + var drift = 22d + (level * 16d); + + if (horizontal) + { + yield return Normalize(anchorX - (width / 2d), anchorY - height - primaryGap); + yield return Normalize(anchorX - (width / 2d), anchorY + primaryGap); + yield return Normalize(anchorX - (width / 2d) - drift, anchorY - height - primaryGap); + yield return Normalize(anchorX - (width / 2d) + drift, anchorY - height - primaryGap); + yield return Normalize(anchorX - (width / 2d) - drift, anchorY + primaryGap); + yield return Normalize(anchorX - (width / 2d) + drift, anchorY + primaryGap); + yield return Normalize(anchorX - width - secondaryGap, anchorY - height - (primaryGap * 0.78d)); + yield return Normalize(anchorX + secondaryGap, anchorY - height - (primaryGap * 0.78d)); + yield return Normalize(anchorX - width - secondaryGap, anchorY + (primaryGap * 0.78d)); + yield return Normalize(anchorX + secondaryGap, anchorY + (primaryGap * 0.78d)); + } + else + { + yield return Normalize(anchorX + primaryGap, anchorY - (height / 2d)); + yield return Normalize(anchorX - width - primaryGap, anchorY - (height / 2d)); + yield return Normalize(anchorX + primaryGap, anchorY - height - secondaryGap); + yield return Normalize(anchorX - width - primaryGap, anchorY - height - secondaryGap); + yield return Normalize(anchorX + primaryGap, anchorY + secondaryGap); + yield return Normalize(anchorX - width - primaryGap, anchorY + secondaryGap); + yield return Normalize(anchorX + primaryGap + drift, anchorY - (height / 2d)); + yield return Normalize(anchorX - width - primaryGap - drift, anchorY - (height / 2d)); + } + } + } + + private static (WorkflowRenderPoint Start, WorkflowRenderPoint End) ResolveLabelAnchorSegment( + IReadOnlyList points) + { + if (points.Count < 2) + { + return (points[0], points[0]); + } + + return points + .Zip(points.Skip(1), static (start, end) => new + { + Start = start, + End = end, + Length = Math.Sqrt(Math.Pow(end.X - start.X, 2) + Math.Pow(end.Y - start.Y, 2)), + }) + .OrderByDescending(segment => segment.Length) + .ThenByDescending(segment => Math.Abs(segment.End.X - segment.Start.X)) + .Select(segment => (segment.Start, segment.End)) + .First(); + } + + private static double Clamp(double value, double minimum, double maximum) + { + return Math.Min(Math.Max(value, minimum), maximum); + } + + private static WorkflowRenderRect ToRect(WorkflowRenderEdgeLabelPlacement placement) + { + return new WorkflowRenderRect(placement.Left, placement.Top, placement.Width, placement.Height); + } + + private static WorkflowRenderRect InflateRect(WorkflowRenderRect rect, double padding) + { + return new WorkflowRenderRect( + rect.Left - padding, + rect.Top - padding, + rect.Width + (padding * 2d), + rect.Height + (padding * 2d)); + } + + private static bool Intersects(WorkflowRenderRect first, WorkflowRenderRect second) + { + return first.Left < second.Right + && first.Right > second.Left + && first.Top < second.Bottom + && first.Bottom > second.Top; + } + + private static double CalculateOverlapArea(WorkflowRenderRect first, WorkflowRenderRect second) + { + if (!Intersects(first, second)) + { + return 0d; + } + + var overlapWidth = Math.Min(first.Right, second.Right) - Math.Max(first.Left, second.Left); + var overlapHeight = Math.Min(first.Bottom, second.Bottom) - Math.Max(first.Top, second.Top); + return Math.Max(0d, overlapWidth) * Math.Max(0d, overlapHeight); + } + + private static WorkflowRenderEdgeStyle ResolveEdgeStyle(string? label) + { + if (string.IsNullOrWhiteSpace(label)) + { + return new WorkflowRenderEdgeStyle("#64748b", "url(#arrow)", "#ffffff", "#334155"); + } + + var normalized = label.Trim().ToLowerInvariant(); + if (normalized.Contains("failure", StringComparison.Ordinal)) + { + return new WorkflowRenderEdgeStyle("#dc2626", "url(#arrow-failure)", "#fef2f2", "#991b1b"); + } + + if (normalized.Contains("timeout", StringComparison.Ordinal)) + { + return new WorkflowRenderEdgeStyle("#d97706", "url(#arrow-timeout)", "#fff7ed", "#9a3412"); + } + + if (normalized.StartsWith("when ", StringComparison.Ordinal)) + { + return new WorkflowRenderEdgeStyle("#15803d", "url(#arrow-success)", "#f0fdf4", "#166534"); + } + + if (normalized.StartsWith("repeat ", StringComparison.Ordinal) + || normalized.Equals("body", StringComparison.Ordinal)) + { + return new WorkflowRenderEdgeStyle("#2563eb", "url(#arrow-repeat)", "#eff6ff", "#1d4ed8"); + } + + if (normalized.Contains("otherwise", StringComparison.Ordinal) + || normalized.Contains("default", StringComparison.Ordinal)) + { + return new WorkflowRenderEdgeStyle("#475569", "url(#arrow-default-muted)", "#f8fafc", "#334155"); + } + + if (normalized.Contains("missing condition", StringComparison.Ordinal)) + { + return new WorkflowRenderEdgeStyle("#b91c1c", "url(#arrow-missing-condition)", "#fef2f2", "#991b1b"); + } + + return new WorkflowRenderEdgeStyle("#64748b", "url(#arrow)", "#ffffff", "#334155"); + } + + private static string ResolveEdgeFamilyKey(string? label) + { + if (string.IsNullOrWhiteSpace(label)) + { + return "default"; + } + + var normalized = label.Trim().ToLowerInvariant(); + if (normalized.Contains("failure", StringComparison.Ordinal)) + { + return "failure"; + } + + if (normalized.Contains("timeout", StringComparison.Ordinal)) + { + return "timeout"; + } + + if (normalized.StartsWith("repeat ", StringComparison.Ordinal) + || normalized.Equals("body", StringComparison.Ordinal)) + { + return "repeat"; + } + + if (normalized.StartsWith("when ", StringComparison.Ordinal)) + { + return "success"; + } + + if (normalized.Contains("otherwise", StringComparison.Ordinal) + || normalized.Contains("default", StringComparison.Ordinal)) + { + return "default"; + } + + if (normalized.Contains("missing condition", StringComparison.Ordinal)) + { + return "missing-condition"; + } + + return "default"; + } + + private static (double StrokeWidth, double StrokeOpacity) ResolveEdgePathAppearance( + WorkflowRenderRoutedEdge edge, + IReadOnlyList points) + { + var familyKey = ResolveEdgeFamilyKey(edge.Label); + var span = ComputePolylineSpan(points); + if (familyKey == "repeat" && span >= 1800d) + { + return (1.72d, 0.84d); + } + + if (edge.TargetNodeId.Equals("end", StringComparison.OrdinalIgnoreCase) + && familyKey is "failure" or "timeout" + && span >= 1800d) + { + return (1.55d, 0.72d); + } + + if (edge.TargetNodeId.Equals("end", StringComparison.OrdinalIgnoreCase) + && familyKey == "default" + && span >= 2200d) + { + return (1.72d, 0.8d); + } + + return (1.95d, 1d); + } + + private static (double StrokeWidth, double StrokeOpacity) ResolveCollectorAppearance( + string? label, + bool isBackward, + bool isEndSink) + { + var familyKey = ResolveEdgeFamilyKey(label); + if (isEndSink) + { + return familyKey switch + { + "default" => (2.9d, 0.34d), + "success" => (2.85d, 0.34d), + "repeat" => (2.8d, 0.32d), + "timeout" => (2.75d, 0.3d), + "failure" => (2.7d, 0.28d), + _ => (2.8d, 0.32d), + }; + } + + if (isBackward) + { + return familyKey switch + { + "repeat" => (2.55d, 0.28d), + "timeout" => (2.7d, 0.3d), + "failure" => (2.65d, 0.28d), + _ => (2.7d, 0.3d), + }; + } + + return familyKey switch + { + "default" => (2.85d, 0.34d), + "success" => (2.85d, 0.34d), + "repeat" => (2.8d, 0.32d), + _ => (2.8d, 0.32d), + }; + } + + private static bool ShouldRenderEdgeLabel(string? label) + { + if (string.IsNullOrWhiteSpace(label)) + { + return false; + } + + var trimmed = label.TrimStart(); + return trimmed.StartsWith("when ", StringComparison.OrdinalIgnoreCase) + || trimmed.Contains("failure", StringComparison.OrdinalIgnoreCase) + || trimmed.Contains("timeout", StringComparison.OrdinalIgnoreCase); + } + + private static Dictionary DetectHighwayGroups(WorkflowRenderLayoutResult layout) + { + var groups = new Dictionary(StringComparer.Ordinal); + var nodesById = layout.Nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + + var candidateGroups = layout.Edges + .Where(edge => edge.Sections.Count > 0 && nodesById.ContainsKey(edge.TargetNodeId)) + .Select(edge => new + { + Edge = edge, + TargetNode = nodesById[edge.TargetNodeId], + FamilyKey = ResolveEdgeFamilyKey(edge.Label), + IsBackward = edge.Sections.First().EndPoint.X < edge.Sections.First().StartPoint.X, + Span = ComputePrimarySpan(edge), + }) + .Where(entry => + entry.Span >= 96d + || string.Equals(entry.TargetNode.Kind, "End", StringComparison.OrdinalIgnoreCase)) + .GroupBy( + entry => $"{entry.Edge.TargetNodeId}|{entry.FamilyKey}|{(entry.IsBackward ? "back" : "forward")}", + StringComparer.Ordinal) + .Select(group => + { + var edges = group.Select(entry => entry.Edge).ToList(); + var targetNode = group.First().TargetNode; + var isBackward = group.First().IsBackward; + var dominantLabel = edges + .Where(edge => !string.IsNullOrWhiteSpace(edge.Label)) + .GroupBy(edge => edge.Label, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(labelGroup => labelGroup.Count()) + .Select(labelGroup => labelGroup.Key) + .FirstOrDefault(); + + return new HighwayCandidateGroup( + targetNode.Id, + targetNode, + edges, + dominantLabel, + group.First().FamilyKey, + isBackward); + }) + .Where(candidate => candidate.IsBackward || !string.Equals(candidate.TargetNode.Kind, "End", StringComparison.OrdinalIgnoreCase)) + .Where(candidate => + candidate.EdgeIds.Count >= (string.Equals(candidate.TargetNode.Kind, "End", StringComparison.OrdinalIgnoreCase) ? 2 : 3)) + .ToArray(); + + foreach (var targetDirectionGroup in candidateGroups + .GroupBy(candidate => $"{candidate.TargetId}|{(candidate.IsBackward ? "back" : "forward")}", StringComparer.Ordinal)) + { + var orderedGroups = targetDirectionGroup + .OrderBy(candidate => ResolveHighwayPriority(candidate.DominantLabel)) + .ThenBy(candidate => candidate.FamilyKey, StringComparer.Ordinal) + .ToArray(); + + for (var bandIndex = 0; bandIndex < orderedGroups.Length; bandIndex++) + { + var candidate = orderedGroups[bandIndex]; + var targetNode = candidate.TargetNode; + var groupId = $"{candidate.TargetId}|{(candidate.IsBackward ? "back" : "forward")}|{candidate.FamilyKey}"; + double collectorX; + double collectorY; + double targetX; + double targetY; + double spreadPerEdge; + + if (candidate.IsBackward) + { + var preferredCollectorY = candidate.Edges + .SelectMany(edge => edge.Sections) + .SelectMany(section => section.BendPoints) + .GroupBy(point => Math.Round(point.Y, 2)) + .OrderByDescending(group => group.Count()) + .Select(group => (double?)group.First().Y) + .FirstOrDefault(); + collectorY = preferredCollectorY + ?? (targetNode.Y - 42d - ((orderedGroups.Length - 1) * 4d)); + var requiredOverlapCount = candidate.EdgeIds.Count <= 2 ? candidate.EdgeIds.Count : (candidate.EdgeIds.Count / 2) + 1; + if (!TryResolveHorizontalOverlapInterval(candidate.Edges, collectorY, requiredOverlapCount, out var sharedMinX, out var sharedMaxX)) + { + collectorX = targetNode.X + (targetNode.Width / 2d) + + ResolveCenteredOffset(bandIndex, orderedGroups.Length, Math.Min(18d, targetNode.Width / 4d)); + targetX = collectorX; + } + else + { + collectorX = sharedMaxX; + targetX = sharedMinX; + } + + targetY = targetNode.Y; + spreadPerEdge = 0d; + + groups[groupId] = new HighwayGroup( + groupId, + candidate.EdgeIds, + collectorX, + collectorY, + targetX, + targetY, + spreadPerEdge, + candidate.DominantLabel, + true); + continue; + } + else + { + collectorX = targetNode.X - (string.Equals(targetNode.Kind, "End", StringComparison.OrdinalIgnoreCase) + ? Math.Min(104d, targetNode.Width + 28d) + : Math.Min(72d, targetNode.Width * 0.5d)); + collectorY = targetNode.Y + (targetNode.Height / 2d) + + ResolveCenteredOffset( + bandIndex, + orderedGroups.Length, + string.Equals(targetNode.Kind, "End", StringComparison.OrdinalIgnoreCase) ? 18d : 14d); + targetX = targetNode.X; + targetY = collectorY; + spreadPerEdge = Math.Min(12d, (targetNode.Height - 16d) / Math.Max(1, candidate.EdgeIds.Count)); + } + + groups[groupId] = new HighwayGroup( + groupId, + candidate.EdgeIds, + collectorX, + collectorY, + targetX, + targetY, + spreadPerEdge, + candidate.DominantLabel, + candidate.IsBackward); + } + } + + return groups; + } + + private static WorkflowRenderRect[] BuildEdgePathObstacles( + IReadOnlyCollection renderedPaths, + double offsetX, + double offsetY) + { + return renderedPaths + .SelectMany(path => path.Points.Zip(path.Points.Skip(1), (start, end) => + { + var left = Math.Min(start.X, end.X) + offsetX - 6d; + var top = Math.Min(start.Y, end.Y) + offsetY - 6d; + var width = Math.Abs(end.X - start.X) + 12d; + var height = Math.Abs(end.Y - start.Y) + 12d; + return new WorkflowRenderRect(left, top, width, height); + })) + .ToArray(); + } + + private static List TruncateToCollector( + IReadOnlyList points, + double collectorX, + double collectorY, + int edgeIndex, + int edgeCount, + double spreadPerEdge) + { + var totalSpread = (edgeCount - 1) * spreadPerEdge; + var branchY = collectorY - (totalSpread / 2d) + (edgeIndex * spreadPerEdge); + var result = new List(); + + for (var index = 0; index < points.Count - 1; index++) + { + var current = points[index]; + if (current.X >= collectorX - 1d && index > 0) + { + break; + } + + result.Add(current); + } + + if (result.Count == 0) + { + result.Add(points[0]); + } + + var lastPoint = result[^1]; + if (Math.Abs(lastPoint.X - collectorX) > 1d) + { + result.Add(new WorkflowRenderPoint { X = collectorX, Y = lastPoint.Y }); + } + + result.Add(new WorkflowRenderPoint { X = collectorX, Y = branchY }); + + return result; + } + + private static List TruncateBackwardToCollector( + IReadOnlyList points, + double collectorX, + double collectorY) + { + var start = points[0]; + var result = new List { start }; + var branchX = TryResolveHorizontalIntervalAtY(points, collectorY, out _, out var intervalMaxX) + ? intervalMaxX + : start.X; + + if (Math.Abs(start.Y - collectorY) > 0.01d) + { + result.Add(new WorkflowRenderPoint { X = start.X, Y = collectorY }); + } + + if (branchX > collectorX + 0.01d && Math.Abs(result[^1].X - branchX) > 0.01d) + { + result.Add(new WorkflowRenderPoint { X = branchX, Y = collectorY }); + } + + if (Math.Abs(result[^1].X - collectorX) > 0.01d || Math.Abs(result[^1].Y - collectorY) > 0.01d) + { + result.Add(new WorkflowRenderPoint { X = collectorX, Y = collectorY }); + } + + return result; + } + + private static List TruncateToEndSinkCollector( + IReadOnlyList points, + double corridorY, + double collectorX) + { + var result = new List(); + foreach (var point in points) + { + result.Add(point); + if (Math.Abs(point.Y - corridorY) <= 0.01d && point.X < collectorX - 0.01d) + { + break; + } + } + + return result.Count == 0 ? [points[0]] : result; + } + + private static Dictionary DetectEndSinkGroups(WorkflowRenderLayoutResult layout) + { + var groups = new Dictionary(StringComparer.Ordinal); + var nodesById = layout.Nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + + foreach (var group in layout.Edges + .Where(edge => edge.Sections.Count > 0 && nodesById.ContainsKey(edge.TargetNodeId)) + .Select(edge => new + { + Edge = edge, + TargetNode = nodesById[edge.TargetNodeId], + FamilyKey = ResolveEdgeFamilyKey(edge.Label), + Points = SectionToPoints(edge.Sections.First()), + }) + .Where(entry => string.Equals(entry.TargetNode.Kind, "End", StringComparison.OrdinalIgnoreCase)) + .GroupBy(entry => $"{entry.Edge.TargetNodeId}|{entry.FamilyKey}", StringComparer.Ordinal)) + { + var entries = group.ToArray(); + if (entries.Length < 2) + { + continue; + } + + var groupedByCorridor = entries + .GroupBy(entry => Math.Round(entry.Points.Max(point => point.Y), 2)) + .OrderByDescending(candidate => candidate.Count()) + .FirstOrDefault(); + if (groupedByCorridor is null || groupedByCorridor.Count() < 2) + { + continue; + } + + var selected = groupedByCorridor.ToArray(); + if (selected.Length > 2) + { + var targetMidY = selected[0].TargetNode.Y + (selected[0].TargetNode.Height / 2d); + var clusteredSelection = selected + .GroupBy(entry => Math.Round(entry.Points.First().Y / 96d)) + .Select(group => new + { + Entries = group.ToArray(), + Count = group.Count(), + AverageSourceY = group.Average(entry => entry.Points.First().Y), + }) + .Where(group => group.Count >= 2) + .OrderByDescending(group => group.Count) + .ThenBy(group => Math.Abs(group.AverageSourceY - targetMidY)) + .FirstOrDefault(); + if (clusteredSelection is not null && clusteredSelection.Count < selected.Length) + { + selected = clusteredSelection.Entries; + } + } + + var corridorY = selected[0].Points.Max(point => point.Y); + var corridorIntervals = selected + .Select(entry => TryResolveHorizontalIntervalAtY(entry.Points, corridorY, out var minX, out var maxX) + ? (HasInterval: true, MinX: minX, MaxX: maxX) + : (HasInterval: false, MinX: 0d, MaxX: 0d)) + .Where(interval => interval.HasInterval) + .ToArray(); + if (corridorIntervals.Length < 2) + { + continue; + } + + var targetNode = selected[0].TargetNode; + var requiredOverlapCount = selected.Length <= 2 ? selected.Length : (selected.Length / 2) + 1; + if (!TryResolveHorizontalOverlapInterval( + selected.Select(entry => entry.Points).ToArray(), + corridorY, + requiredOverlapCount, + out var corridorStartX, + out var collectorX)) + { + collectorX = corridorIntervals.Min(interval => interval.MaxX); + corridorStartX = corridorIntervals.Max(interval => interval.MinX); + } + + if (collectorX <= corridorStartX + 18d) + { + corridorStartX = corridorIntervals.Max(interval => interval.MinX); + } + + var dominantLabel = selected + .Select(entry => entry.Edge.Label) + .FirstOrDefault(label => !string.IsNullOrWhiteSpace(label)); + var groupId = $"end-sink::{group.Key}"; + + groups[groupId] = new EndSinkGroup( + groupId, + selected.Select(entry => entry.Edge.Id).ToList(), + corridorStartX, + corridorY, + collectorX, + targetNode.X, + targetNode.Y + (targetNode.Height / 2d), + dominantLabel); + } + + return groups; + } + + private static IReadOnlyList SectionToPoints(WorkflowRenderEdgeSection section) + { + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); + return points; + } + + private static bool TryResolveSharedHorizontalInterval( + IReadOnlyCollection edges, + double corridorY, + out double sharedMinX, + out double sharedMaxX) + { + sharedMinX = double.NegativeInfinity; + sharedMaxX = double.PositiveInfinity; + var resolvedAny = false; + + foreach (var edge in edges) + { + var points = SectionToPoints(edge.Sections.First()); + if (!TryResolveHorizontalIntervalAtY(points, corridorY, out var minX, out var maxX)) + { + return false; + } + + sharedMinX = Math.Max(sharedMinX, minX); + sharedMaxX = Math.Min(sharedMaxX, maxX); + resolvedAny = true; + } + + return resolvedAny && sharedMaxX > sharedMinX + 24d; + } + + private static bool TryResolveHorizontalOverlapInterval( + IReadOnlyCollection edges, + double corridorY, + int requiredOverlapCount, + out double overlapMinX, + out double overlapMaxX) + { + var pointSets = edges + .Select(edge => SectionToPoints(edge.Sections.First())) + .ToArray(); + return TryResolveHorizontalOverlapInterval(pointSets, corridorY, requiredOverlapCount, out overlapMinX, out overlapMaxX); + } + + private static bool TryResolveHorizontalOverlapInterval( + IReadOnlyCollection> pointSets, + double corridorY, + int requiredOverlapCount, + out double overlapMinX, + out double overlapMaxX) + { + overlapMinX = double.NaN; + overlapMaxX = double.NaN; + if (requiredOverlapCount <= 0) + { + return false; + } + + var intervals = pointSets + .Select(points => TryResolveHorizontalIntervalAtY(points, corridorY, out var minX, out var maxX) + ? (HasInterval: true, MinX: minX, MaxX: maxX) + : (HasInterval: false, MinX: 0d, MaxX: 0d)) + .Where(interval => interval.HasInterval) + .ToArray(); + if (intervals.Length < requiredOverlapCount) + { + return false; + } + + var events = new List<(double X, int Delta)>(intervals.Length * 2); + foreach (var interval in intervals) + { + events.Add((interval.MinX, +1)); + events.Add((interval.MaxX, -1)); + } + + var ordered = events + .OrderBy(entry => entry.X) + .ThenByDescending(entry => entry.Delta) + .ToArray(); + if (ordered.Length == 0) + { + return false; + } + + var activeCount = 0; + var previousX = ordered[0].X; + double bestLength = double.NegativeInfinity; + + var index = 0; + while (index < ordered.Length) + { + var currentX = ordered[index].X; + if (currentX > previousX + 0.01d && activeCount >= requiredOverlapCount) + { + var candidateLength = currentX - previousX; + if (candidateLength > bestLength) + { + bestLength = candidateLength; + overlapMinX = previousX; + overlapMaxX = currentX; + } + } + + while (index < ordered.Length && Math.Abs(ordered[index].X - currentX) <= 0.01d) + { + activeCount += ordered[index].Delta; + index++; + } + + previousX = currentX; + } + + return !double.IsNaN(overlapMinX) && !double.IsNaN(overlapMaxX) && overlapMaxX > overlapMinX + 24d; + } + + private static bool TryResolveHorizontalIntervalAtY( + IReadOnlyList points, + double corridorY, + out double minX, + out double maxX) + { + minX = double.PositiveInfinity; + maxX = double.NegativeInfinity; + + for (var index = 0; index < points.Count - 1; index++) + { + var current = points[index]; + var next = points[index + 1]; + if (Math.Abs(current.Y - corridorY) > 0.01d + || Math.Abs(next.Y - corridorY) > 0.01d + || Math.Abs(current.X - next.X) <= 0.01d) + { + continue; + } + + minX = Math.Min(minX, Math.Min(current.X, next.X)); + maxX = Math.Max(maxX, Math.Max(current.X, next.X)); + } + + return !double.IsInfinity(minX) && !double.IsInfinity(maxX); + } + + private sealed record HighwayGroup( + string GroupId, + List EdgeIds, + double CollectorX, + double CollectorY, + double TargetX, + double TargetY, + double SpreadPerEdge, + string? DominantLabel, + bool IsBackward = false); + + private sealed record EndSinkGroup( + string GroupId, + List EdgeIds, + double CorridorStartX, + double CorridorY, + double CollectorX, + double TargetX, + double TargetY, + string? DominantLabel); + + private sealed record HighwayCandidateGroup( + string TargetId, + WorkflowRenderPositionedNode TargetNode, + List Edges, + string? DominantLabel, + string FamilyKey, + bool IsBackward) + { + public List EdgeIds { get; } = Edges.Select(edge => edge.Id).ToList(); + } + + private sealed record RenderedEdgePath( + string Id, + IReadOnlyList Points, + WorkflowRenderEdgeStyle Style, + double StrokeWidth, + string? MarkerId, + double StrokeOpacity, + string? GroupId, + bool IsCollector); + + private sealed record PendingEdgeLabel( + IReadOnlyList Points, + string Label, + WorkflowRenderEdgeStyle EdgeStyle); + + private sealed record BridgeGap( + WorkflowRenderPoint StartPoint, + WorkflowRenderPoint EndPoint, + double StrokeWidth); + + private readonly record struct RenderedSegment( + WorkflowRenderPoint StartPoint, + WorkflowRenderPoint EndPoint, + bool IsHorizontal); + + private static double ComputePrimarySpan(WorkflowRenderRoutedEdge edge) + { + var section = edge.Sections.First(); + return Math.Abs(section.EndPoint.X - section.StartPoint.X) + + Math.Abs(section.EndPoint.Y - section.StartPoint.Y); + } + + private static double ComputePolylineSpan(IReadOnlyList points) + { + double total = 0d; + for (var index = 0; index < points.Count - 1; index++) + { + total += Math.Abs(points[index + 1].X - points[index].X) + + Math.Abs(points[index + 1].Y - points[index].Y); + } + + return total; + } + + private static int ResolveHighwayPriority(string? label) + { + var style = ResolveEdgeStyle(label); + return style.Stroke switch + { + "#dc2626" => 0, + "#d97706" => 1, + "#2563eb" => 2, + "#64748b" => 3, + "#475569" => 4, + "#15803d" => 5, + "#b91c1c" => 6, + _ => 7, + }; + } + + private static Dictionary> ResolveBridgeGaps(IReadOnlyList paths) + { + var bridgeGapsByPathIndex = new Dictionary>(); + var dedupeKeys = new HashSet(StringComparer.Ordinal); + + for (var pathIndex = 1; pathIndex < paths.Count; pathIndex++) + { + var overSegments = EnumerateSegments(paths[pathIndex].Points).ToArray(); + for (var underPathIndex = 0; underPathIndex < pathIndex; underPathIndex++) + { + var underSegments = EnumerateSegments(paths[underPathIndex].Points).ToArray(); + foreach (var underSegment in underSegments) + { + foreach (var overSegment in overSegments) + { + if (!TryCreateBridgeGap(underSegment, overSegment, paths[underPathIndex].StrokeWidth, out var bridgeGap)) + { + continue; + } + + var key = $"{pathIndex}:{Format(bridgeGap.StartPoint.X)}:{Format(bridgeGap.StartPoint.Y)}:{Format(bridgeGap.EndPoint.X)}:{Format(bridgeGap.EndPoint.Y)}"; + if (!dedupeKeys.Add(key)) + { + continue; + } + + if (!bridgeGapsByPathIndex.TryGetValue(pathIndex, out var bridgeGaps)) + { + bridgeGaps = []; + bridgeGapsByPathIndex[pathIndex] = bridgeGaps; + } + + bridgeGaps.Add(bridgeGap); + } + } + } + } + + return bridgeGapsByPathIndex; + } + + private static IEnumerable EnumerateSegments(IReadOnlyList points) + { + for (var index = 0; index < points.Count - 1; index++) + { + var start = points[index]; + var end = points[index + 1]; + if (Math.Abs(start.X - end.X) <= 0.01d && Math.Abs(start.Y - end.Y) <= 0.01d) + { + continue; + } + + var isHorizontal = Math.Abs(start.Y - end.Y) <= 0.01d; + if (!isHorizontal && Math.Abs(start.X - end.X) > 0.01d) + { + continue; + } + + yield return new RenderedSegment(start, end, isHorizontal); + } + } + + private static bool TryCreateBridgeGap( + RenderedSegment underSegment, + RenderedSegment overSegment, + double underStrokeWidth, + out BridgeGap bridgeGap) + { + bridgeGap = default!; + if ((underSegment.IsHorizontal && overSegment.IsHorizontal) + || (!underSegment.IsHorizontal && !overSegment.IsHorizontal)) + { + return false; + } + + var horizontal = underSegment.IsHorizontal ? underSegment : overSegment; + var vertical = underSegment.IsHorizontal ? overSegment : underSegment; + var intersectionX = vertical.StartPoint.X; + var intersectionY = horizontal.StartPoint.Y; + if (!IsWithinExclusive(intersectionX, horizontal.StartPoint.X, horizontal.EndPoint.X) + || !IsWithinExclusive(intersectionY, vertical.StartPoint.Y, vertical.EndPoint.Y) + || IsNearEndpoint(horizontal.StartPoint, intersectionX, intersectionY) + || IsNearEndpoint(horizontal.EndPoint, intersectionX, intersectionY) + || IsNearEndpoint(vertical.StartPoint, intersectionX, intersectionY) + || IsNearEndpoint(vertical.EndPoint, intersectionX, intersectionY)) + { + return false; + } + + if (underSegment.IsHorizontal) + { + var gapHalfLength = Math.Max(5d, underStrokeWidth * 2.6d); + bridgeGap = new BridgeGap( + new WorkflowRenderPoint { X = intersectionX - gapHalfLength, Y = intersectionY }, + new WorkflowRenderPoint { X = intersectionX + gapHalfLength, Y = intersectionY }, + underStrokeWidth + 4d); + return true; + } + + var verticalGapHalfLength = Math.Max(5d, underStrokeWidth * 2.6d); + bridgeGap = new BridgeGap( + new WorkflowRenderPoint { X = intersectionX, Y = intersectionY - verticalGapHalfLength }, + new WorkflowRenderPoint { X = intersectionX, Y = intersectionY + verticalGapHalfLength }, + underStrokeWidth + 4d); + return true; + } + + private static bool IsWithinExclusive(double value, double boundA, double boundB) + { + var minimum = Math.Min(boundA, boundB) + 2d; + var maximum = Math.Max(boundA, boundB) - 2d; + return value >= minimum && value <= maximum; + } + + private static bool IsNearEndpoint(WorkflowRenderPoint endpoint, double x, double y) + { + return Math.Abs(endpoint.X - x) <= 2d && Math.Abs(endpoint.Y - y) <= 2d; + } + + private static double ResolveCenteredOffset(int index, int count, double spacing) + { + if (count <= 1) + { + return 0d; + } + + return (index * spacing) - (((count - 1) * spacing) / 2d); + } + + private static Dictionary ResolveEdgeLaneOffsets(IReadOnlyCollection edges) + { + var offsets = new Dictionary(StringComparer.Ordinal); + foreach (var group in edges + .SelectMany(edge => edge.Sections.Select((section, index) => new { edge, index })) + .GroupBy(entry => $"{entry.edge.SourceNodeId}|{entry.edge.TargetNodeId}", StringComparer.Ordinal) + .Where(group => group.Count() > 1)) + { + var ordered = group + .OrderBy(entry => entry.edge.Label, StringComparer.Ordinal) + .ThenBy(entry => entry.edge.Id, StringComparer.Ordinal) + .ToArray(); + var midpoint = (ordered.Length - 1) / 2d; + for (var index = 0; index < ordered.Length; index++) + { + offsets[CreateEdgeSectionKey(ordered[index].edge.Id, ordered[index].index)] = (index - midpoint) * 6d; + } + } + + return offsets; + } + + private static string CreateEdgeSectionKey(string edgeId, int sectionIndex) + { + return $"{edgeId}:{sectionIndex}"; + } + + private static List ApplyLaneOffset(IReadOnlyList points, double offset) + { + if (Math.Abs(offset) <= 0.001d) + { + return points.ToList(); + } + + var start = points[0]; + var end = points[^1]; + var horizontal = Math.Abs(end.X - start.X) >= Math.Abs(end.Y - start.Y); + return points + .Select(point => new WorkflowRenderPoint + { + X = horizontal ? point.X : point.X + offset, + Y = horizontal ? point.Y + offset : point.Y, + }) + .ToList(); + } + + private static string BuildRoundedEdgePath( + IReadOnlyList points, + double offsetX, + double offsetY, + double radius) + { + if (points.Count < 2) + { + return string.Empty; + } + + if (points.Count == 2) + { + return $"M {Format(points[0].X + offsetX)},{Format(points[0].Y + offsetY)} L {Format(points[1].X + offsetX)},{Format(points[1].Y + offsetY)}"; + } + + var builder = new StringBuilder(); + var p0 = points[0]; + builder.Append($"M {Format(p0.X + offsetX)},{Format(p0.Y + offsetY)}"); + + for (var index = 1; index < points.Count - 1; index++) + { + var prev = points[index - 1]; + var curr = points[index]; + var next = points[index + 1]; + + var dxIn = curr.X - prev.X; + var dyIn = curr.Y - prev.Y; + var lenIn = Math.Sqrt((dxIn * dxIn) + (dyIn * dyIn)); + + var dxOut = next.X - curr.X; + var dyOut = next.Y - curr.Y; + var lenOut = Math.Sqrt((dxOut * dxOut) + (dyOut * dyOut)); + + var r = Math.Min(radius, Math.Min(lenIn / 2.5d, lenOut / 2.5d)); + if (r < 0.5d || lenIn < 1d || lenOut < 1d) + { + builder.Append($" L {Format(curr.X + offsetX)},{Format(curr.Y + offsetY)}"); + continue; + } + + var approachX = curr.X - (dxIn / lenIn * r); + var approachY = curr.Y - (dyIn / lenIn * r); + var departX = curr.X + (dxOut / lenOut * r); + var departY = curr.Y + (dyOut / lenOut * r); + + builder.Append($" L {Format(approachX + offsetX)},{Format(approachY + offsetY)}"); + builder.Append($" Q {Format(curr.X + offsetX)},{Format(curr.Y + offsetY)} {Format(departX + offsetX)},{Format(departY + offsetY)}"); + } + + var last = points[^1]; + builder.Append($" L {Format(last.X + offsetX)},{Format(last.Y + offsetY)}"); + + return builder.ToString(); + } + + private static string BuildForkPolygonPoints(double x, double y, double width, double height, double cornerInset, double verticalInset) + { + return string.Join(" ", new[] + { + $"{Format(x + cornerInset)},{Format(y + verticalInset)}", + $"{Format(x + width - cornerInset)},{Format(y + verticalInset)}", + $"{Format(x + width)},{Format(y + (height / 2d))}", + $"{Format(x + width - cornerInset)},{Format(y + height - verticalInset)}", + $"{Format(x + cornerInset)},{Format(y + height - verticalInset)}", + $"{Format(x)},{Format(y + (height / 2d))}", + }); + } + + private sealed record WorkflowRenderEdgeStyle( + string Stroke, + string MarkerId, + string LabelFill, + string LabelText); + + private sealed record WorkflowRenderEdgeLabelPlacement( + string Label, + WorkflowRenderEdgeStyle Style, + double AnchorX, + double AnchorY, + double Left, + double Top, + double Width, + double Height) + { + public double CenterX => Left + (Width / 2d); + public double CenterY => Top + (Height / 2d); + } + + private sealed record WorkflowRenderRect( + double Left, + double Top, + double Width, + double Height) + { + public double Right => Left + Width; + public double Bottom => Top + Height; + } + + private static void RenderLegendNodeChip( + StringBuilder builder, + double x, + double y, + string kind, + string label) + { + var sampleX = x; + var sampleY = y; + var textX = x + 42d; + var stroke = ResolveStroke(kind); + var fill = ResolveFill(kind); + + switch (kind) + { + case "Decision": + builder.AppendLine($""" + + """); + RenderDiamondGlyph(builder, sampleX + 12d, sampleY + 12d, kind, "#0f172a", 7d); + break; + case "Fork": + case "Join": + builder.AppendLine($""" + + """); + RenderDiamondGlyph(builder, sampleX + 12d, sampleY + 12d, kind, "#0f172a", 7d); + break; + default: + var shellFill = kind is "Start" or "End" + ? fill + : ResolveShellFill(kind); + builder.AppendLine($""" + + """); + + if (kind is not ("Start" or "End")) + { + builder.AppendLine($""" + + + """); + } + + break; + } + + builder.AppendLine($""" + {Encode(label)} + """); + } + + private static void RenderLegendBadgeChip( + StringBuilder builder, + double x, + double y, + string kind, + string label) + { + builder.AppendLine($""" + + {Encode(label)} + """); + RenderBadgeGlyph(builder, x, y, 10.5d, kind, "#0f172a"); + } + + private static void RenderBadgeGlyph( + StringBuilder builder, + double centerX, + double centerY, + double radius, + string kind, + string stroke) + { + var lineWidth = radius >= 12d ? 1.5d : 1.3d; + + switch (kind) + { + case "SetState": + RenderSetStateGlyph(builder, centerX, centerY, stroke, lineWidth); + break; + case "BusinessReference": + RenderBusinessReferenceGlyph(builder, centerX, centerY, stroke, lineWidth); + break; + case "HumanTask": + RenderHumanTaskGlyph(builder, centerX, centerY, stroke, lineWidth); + break; + case "TransportCall": + RenderTransportGlyph(builder, centerX, centerY, stroke, lineWidth); + break; + case "Timer": + RenderClockGlyph(builder, centerX, centerY, stroke, lineWidth, radius - 3d); + break; + case "Signal": + RenderSignalGlyph(builder, centerX, centerY, stroke, lineWidth); + break; + case "Repeat": + RenderRepeatGlyph(builder, centerX, centerY, stroke, lineWidth); + break; + case "SubWorkflow": + case "ContinueWorkflow": + RenderSubWorkflowGlyph(builder, centerX, centerY, stroke, lineWidth); + break; + case "Complete": + RenderCompleteGlyph(builder, centerX, centerY, stroke, lineWidth); + break; + } + } + + private static void RenderDiamondGlyph( + StringBuilder builder, + double centerX, + double centerY, + string kind, + string stroke, + double size = 10d) + { + var lineWidth = size >= 9d ? 1.8d : 1.4d; + + if (string.Equals(kind, "Decision", StringComparison.Ordinal)) + { + builder.AppendLine($""" + + + + """); + return; + } + + builder.AppendLine($""" + + """); + } + + private static void RenderSetStateGlyph(StringBuilder builder, double centerX, double centerY, string stroke, double lineWidth) + { + foreach (var offset in new[] { -4d, 0d, 4d }) + { + builder.AppendLine($""" + + + """); + } + } + + private static void RenderBusinessReferenceGlyph(StringBuilder builder, double centerX, double centerY, string stroke, double lineWidth) + { + builder.AppendLine($""" + + + """); + } + + private static void RenderHumanTaskGlyph(StringBuilder builder, double centerX, double centerY, string stroke, double lineWidth) + { + builder.AppendLine($""" + + + """); + } + + private static void RenderTransportGlyph(StringBuilder builder, double centerX, double centerY, string stroke, double lineWidth) + { + builder.AppendLine($""" + + + """); + } + + private static void RenderClockGlyph(StringBuilder builder, double centerX, double centerY, string stroke, double lineWidth, double radius) + { + builder.AppendLine($""" + + + """); + } + + private static void RenderSignalGlyph(StringBuilder builder, double centerX, double centerY, string stroke, double lineWidth) + { + builder.AppendLine($""" + + + """); + } + + private static void RenderRepeatGlyph(StringBuilder builder, double centerX, double centerY, string stroke, double lineWidth) + { + builder.AppendLine($""" + + + """); + } + + private static void RenderSubWorkflowGlyph(StringBuilder builder, double centerX, double centerY, string stroke, double lineWidth) + { + builder.AppendLine($""" + + + """); + } + + private static void RenderCompleteGlyph(StringBuilder builder, double centerX, double centerY, string stroke, double lineWidth) + { + builder.AppendLine($""" + + """); + } + + private static void RenderLegendBranchChip( + StringBuilder builder, + double x, + double y, + string stroke, + string label) + { + builder.AppendLine($""" + + {Encode(label)} + """); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqTransportContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqTransportContracts.cs new file mode 100644 index 000000000..c9fe0cece --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqTransportContracts.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Workflow.Signaling.OracleAq; + +public sealed record OracleAqEnqueueRequest +{ + public required string QueueName { get; init; } + public required byte[] Payload { get; init; } + public string? Correlation { get; init; } + public int DelaySeconds { get; init; } + public string? ExceptionQueueName { get; init; } +} + +public sealed record OracleAqDequeueRequest +{ + public required string QueueName { get; init; } + public string? ConsumerName { get; init; } + public string? Correlation { get; init; } + public int WaitSeconds { get; init; } +} + +public sealed record OracleAqBrowseRequest +{ + public required string QueueName { get; init; } + public string? ConsumerName { get; init; } + public string? Correlation { get; init; } + public int MaxMessages { get; init; } = 50; +} + +public sealed record OracleAqDequeuedMessage +{ + public required byte[] Payload { get; init; } + public byte[]? MessageId { get; init; } + public string? Correlation { get; init; } + public int DequeueAttempts { get; init; } + public DateTime? EnqueueTimeUtc { get; init; } +} + +public interface IOracleAqMessageLease : IAsyncDisposable +{ + OracleAqDequeuedMessage Message { get; } + + Task CommitAsync(CancellationToken cancellationToken = default); + + Task RollbackAsync(CancellationToken cancellationToken = default); + + Task DeadLetterAsync( + string queueName, + CancellationToken cancellationToken = default); +} + +public interface IOracleAqTransport +{ + Task EnqueueAsync( + OracleAqEnqueueRequest request, + CancellationToken cancellationToken = default); + + Task> BrowseAsync( + OracleAqBrowseRequest request, + CancellationToken cancellationToken = default); + + Task DequeueAsync( + OracleAqDequeueRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowScheduleBus.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowScheduleBus.cs new file mode 100644 index 000000000..fa1112bf0 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowScheduleBus.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.Options; + +namespace StellaOps.Workflow.Signaling.OracleAq; + +public sealed class OracleAqWorkflowScheduleBus( + IOracleAqTransport transport, + WorkflowSignalEnvelopeSerializer serializer, + IOptions aqOptions) : IWorkflowScheduleBus, IWorkflowSignalScheduler +{ + private readonly WorkflowAqOptions options = aqOptions.Value; + + public Task ScheduleAsync( + WorkflowSignalEnvelope envelope, + DateTime dueAtUtc, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope); + var delaySeconds = Math.Max(0, (int)Math.Ceiling((dueAtUtc - DateTime.UtcNow).TotalSeconds)); + + return transport.EnqueueAsync( + new OracleAqEnqueueRequest + { + // Scheduled signals are delayed directly onto the signal queue so the existing signal pump + // can consume them without a second relay worker. + QueueName = options.SignalQueueName, + Payload = serializer.Serialize(envelope with { DueAtUtc = dueAtUtc }), + Correlation = envelope.SignalId, + DelaySeconds = delaySeconds, + ExceptionQueueName = options.DeadLetterQueueName, + }, + cancellationToken); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowSignalBus.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowSignalBus.cs new file mode 100644 index 000000000..bcf150dd3 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowSignalBus.cs @@ -0,0 +1,143 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StellaOps.Workflow.Signaling.OracleAq; + +public sealed class OracleAqWorkflowSignalBus( + IOracleAqTransport transport, + WorkflowSignalEnvelopeSerializer serializer, + IOptions aqOptions, + ILogger logger) : IWorkflowSignalBus, IWorkflowSignalStore, IWorkflowSignalDriver, IWorkflowSignalClaimStore +{ + private readonly WorkflowAqOptions options = aqOptions.Value; + + public string DriverName => "Oracle.AQ"; + + public WorkflowSignalDriverDispatchMode DispatchMode => WorkflowSignalDriverDispatchMode.NativeTransactional; + + public Task PublishAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope); + + return transport.EnqueueAsync( + new OracleAqEnqueueRequest + { + QueueName = options.SignalQueueName, + Payload = serializer.Serialize(envelope), + Correlation = envelope.SignalId, + DelaySeconds = 0, + ExceptionQueueName = options.DeadLetterQueueName, + }, + cancellationToken); + } + + public Task PublishDeadLetterAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope); + + return transport.EnqueueAsync( + new OracleAqEnqueueRequest + { + QueueName = options.DeadLetterQueueName, + Payload = serializer.Serialize(envelope), + Correlation = envelope.SignalId, + DelaySeconds = 0, + ExceptionQueueName = null, + }, + cancellationToken); + } + + public async Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + return await ReceiveInternalAsync(options.BlockingDequeueSeconds, cancellationToken); + } + + public Task TryClaimAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + return ReceiveInternalAsync(waitSeconds: 0, cancellationToken); + } + + public Task NotifySignalAvailableAsync( + WorkflowSignalWakeNotification notification, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + private async Task ReceiveInternalAsync( + int waitSeconds, + CancellationToken cancellationToken) + { + var transportLease = await transport.DequeueAsync( + new OracleAqDequeueRequest + { + QueueName = options.SignalQueueName, + WaitSeconds = Math.Max(0, waitSeconds), + }, + cancellationToken); + + if (transportLease is null) + { + return null; + } + + try + { + var envelope = serializer.Deserialize(transportLease.Message.Payload); + return new WorkflowSignalLease(envelope, transportLease, options.DeadLetterQueueName); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Failed to deserialize Oracle AQ workflow signal message. Message will be dead-lettered to {DeadLetterQueueName}.", + options.DeadLetterQueueName); + await transportLease.DeadLetterAsync(options.DeadLetterQueueName, cancellationToken); + await transportLease.DisposeAsync(); + throw; + } + } + + private sealed class WorkflowSignalLease( + WorkflowSignalEnvelope envelope, + IOracleAqMessageLease innerLease, + string deadLetterQueueName) : IWorkflowSignalLease + { + public WorkflowSignalEnvelope Envelope { get; } = envelope; + public int DeliveryCount => Math.Max(1, innerLease.Message.DequeueAttempts); + + public Task CompleteAsync(CancellationToken cancellationToken = default) + { + return innerLease.CommitAsync(cancellationToken); + } + + public Task AbandonAsync(CancellationToken cancellationToken = default) + { + return innerLease.RollbackAsync(cancellationToken); + } + + public Task DeadLetterAsync(CancellationToken cancellationToken = default) + { + return innerLease.DeadLetterAsync(deadLetterQueueName, cancellationToken); + } + + public ValueTask DisposeAsync() + { + return innerLease.DisposeAsync(); + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowSignalDeadLetterStore.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowSignalDeadLetterStore.cs new file mode 100644 index 000000000..40a159d8f --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowSignalDeadLetterStore.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +using Microsoft.Extensions.Options; + +namespace StellaOps.Workflow.Signaling.OracleAq; + +public sealed class OracleAqWorkflowSignalDeadLetterStore( + IOracleAqTransport transport, + WorkflowSignalEnvelopeSerializer serializer, + IOptions aqOptions) : IWorkflowSignalDeadLetterStore +{ + private readonly WorkflowAqOptions options = aqOptions.Value; + + public async Task GetMessagesAsync( + WorkflowSignalDeadLettersGetRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var rawMessages = await transport.BrowseAsync( + new OracleAqBrowseRequest + { + QueueName = options.DeadLetterQueueName, + Correlation = request.SignalId, + MaxMessages = Math.Clamp(request.MaxMessages, 1, 200), + }, + cancellationToken); + + var results = new List(rawMessages.Count); + foreach (var rawMessage in rawMessages) + { + var mapped = MapMessage(rawMessage, request.IncludeRawPayload); + if (!MatchesFilter(mapped, request)) + { + continue; + } + + results.Add(mapped); + } + + return new WorkflowSignalDeadLettersGetResponse + { + Messages = results, + }; + } + + public async Task ReplayAsync( + WorkflowSignalDeadLetterReplayRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + await using var lease = await transport.DequeueAsync( + new OracleAqDequeueRequest + { + QueueName = options.DeadLetterQueueName, + Correlation = request.SignalId, + WaitSeconds = 0, + }, + cancellationToken); + if (lease is null) + { + return new WorkflowSignalDeadLetterReplayResponse + { + SignalId = request.SignalId, + Replayed = false, + }; + } + + var mapped = MapMessage(lease.Message, includeRawPayload: false); + + await transport.EnqueueAsync( + new OracleAqEnqueueRequest + { + QueueName = options.SignalQueueName, + Payload = lease.Message.Payload, + Correlation = lease.Message.Correlation ?? request.SignalId, + DelaySeconds = 0, + ExceptionQueueName = options.DeadLetterQueueName, + }, + cancellationToken); + await lease.CommitAsync(cancellationToken); + + return new WorkflowSignalDeadLetterReplayResponse + { + SignalId = mapped.SignalId ?? request.SignalId, + Replayed = true, + WorkflowInstanceId = mapped.WorkflowInstanceId, + SignalType = mapped.SignalType, + WasEnvelopeReadable = mapped.IsEnvelopeReadable, + }; + } + + private WorkflowSignalDeadLetterMessage MapMessage( + OracleAqDequeuedMessage rawMessage, + bool includeRawPayload) + { + if (TryDeserializeEnvelope(rawMessage.Payload, out var envelope, out var error)) + { + return new WorkflowSignalDeadLetterMessage + { + SignalId = envelope!.SignalId, + Correlation = rawMessage.Correlation, + WorkflowInstanceId = envelope.WorkflowInstanceId, + RuntimeProvider = envelope.RuntimeProvider, + SignalType = envelope.SignalType, + ExpectedVersion = envelope.ExpectedVersion, + WaitingToken = envelope.WaitingToken, + OccurredAtUtc = envelope.OccurredAtUtc, + DueAtUtc = envelope.DueAtUtc, + EnqueuedOnUtc = rawMessage.EnqueueTimeUtc, + DeliveryCount = rawMessage.DequeueAttempts, + IsEnvelopeReadable = true, + Payload = envelope.Payload, + RawPayloadBase64 = includeRawPayload ? Convert.ToBase64String(rawMessage.Payload) : null, + }; + } + + return new WorkflowSignalDeadLetterMessage + { + SignalId = rawMessage.Correlation, + Correlation = rawMessage.Correlation, + EnqueuedOnUtc = rawMessage.EnqueueTimeUtc, + DeliveryCount = rawMessage.DequeueAttempts, + IsEnvelopeReadable = false, + ReadError = error, + RawPayloadBase64 = includeRawPayload ? Convert.ToBase64String(rawMessage.Payload) : null, + }; + } + + private bool MatchesFilter( + WorkflowSignalDeadLetterMessage message, + WorkflowSignalDeadLettersGetRequest request) + { + if (!string.IsNullOrWhiteSpace(request.SignalId) + && !string.Equals(message.SignalId, request.SignalId, StringComparison.OrdinalIgnoreCase) + && !string.Equals(message.Correlation, request.SignalId, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(request.WorkflowInstanceId) + && !string.Equals(message.WorkflowInstanceId, request.WorkflowInstanceId, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(request.SignalType) + && !string.Equals(message.SignalType, request.SignalType, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return true; + } + + private bool TryDeserializeEnvelope( + byte[] payload, + out WorkflowSignalEnvelope? envelope, + out string? error) + { + try + { + envelope = serializer.Deserialize(payload); + error = null; + return true; + } + catch (Exception exception) + { + envelope = null; + error = exception.Message; + return false; + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowSignalingExtensions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowSignalingExtensions.cs new file mode 100644 index 000000000..4512d0c72 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleAqWorkflowSignalingExtensions.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Signaling.OracleAq; + +public static class OracleAqWorkflowSignalingExtensions +{ + public static IServiceCollection AddWorkflowOracleAqSignaling( + this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(WorkflowAqOptions.SectionName)); + + services.TryAddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Scoped()); + + return services; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleManagedAqTransport.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleManagedAqTransport.cs new file mode 100644 index 000000000..898d2ae71 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/OracleManagedAqTransport.cs @@ -0,0 +1,338 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +using Oracle.ManagedDataAccess.Client; + +namespace StellaOps.Workflow.Signaling.OracleAq; + +public sealed class OracleManagedAqTransport( + DbContext dbContext, + IConfiguration configuration, + IOptions aqOptions) : IOracleAqTransport +{ + private const int DequeueTimeoutOracleErrorNumber = 25228; + private readonly WorkflowAqOptions options = aqOptions.Value; + + public async Task EnqueueAsync( + OracleAqEnqueueRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var ambientTransaction = TryGetAmbientOracleTransaction(); + if (ambientTransaction is not null) + { + using var ambientQueue = CreateQueue(ambientTransaction.Connection, request.QueueName); + EnqueueMessage(ambientQueue, request); + return; + } + + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken); + using var transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted); + using var queue = CreateQueue(connection, request.QueueName); + EnqueueMessage(queue, request); + transaction.Commit(); + } + + public async Task> BrowseAsync( + OracleAqBrowseRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken); + using var queue = CreateQueue(connection, request.QueueName); + var results = new List(Math.Max(0, request.MaxMessages)); + + var dequeueOptions = new OracleAQDequeueOptions + { + ConsumerName = request.ConsumerName, + DequeueMode = OracleAQDequeueMode.Browse, + NavigationMode = OracleAQNavigationMode.FirstMessage, + ProviderSpecificType = false, + Wait = 0, + }; + if (!string.IsNullOrWhiteSpace(request.Correlation)) + { + dequeueOptions.Correlation = request.Correlation; + } + + try + { + for (var index = 0; index < Math.Max(0, request.MaxMessages); index++) + { + var message = queue.Dequeue(dequeueOptions); + if (message is null) + { + break; + } + + results.Add(new OracleAqDequeuedMessage + { + Payload = ConvertPayload(message.Payload), + MessageId = message.MessageId, + Correlation = message.Correlation, + DequeueAttempts = message.DequeueAttempts, + EnqueueTimeUtc = message.EnqueueTime, + }); + + dequeueOptions.NavigationMode = OracleAQNavigationMode.NextMessage; + } + } + catch (OracleException exception) when (exception.Number == DequeueTimeoutOracleErrorNumber) + { + return results; + } + + return results; + } + + public async Task DequeueAsync( + OracleAqDequeueRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken); + var transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted); + var queue = CreateQueue(connection, request.QueueName); + + try + { + var message = queue.Dequeue(new OracleAQDequeueOptions + { + ConsumerName = request.ConsumerName, + Correlation = request.Correlation, + DequeueMode = OracleAQDequeueMode.Remove, + NavigationMode = OracleAQNavigationMode.FirstMessage, + ProviderSpecificType = false, + Visibility = OracleAQVisibilityMode.OnCommit, + Wait = request.WaitSeconds, + }); + + if (message is null) + { + queue.Dispose(); + transaction.Dispose(); + await connection.DisposeAsync(); + return null; + } + + return new OracleAqMessageLease( + connection, + transaction, + queue, + ResolveQualifiedQueueName, + new OracleAqDequeuedMessage + { + Payload = ConvertPayload(message.Payload), + MessageId = message.MessageId, + Correlation = message.Correlation, + DequeueAttempts = message.DequeueAttempts, + EnqueueTimeUtc = message.EnqueueTime, + }); + } + catch (OracleException exception) when (exception.Number == DequeueTimeoutOracleErrorNumber) + { + queue.Dispose(); + transaction.Dispose(); + await connection.DisposeAsync(); + return null; + } + catch + { + queue.Dispose(); + transaction.Dispose(); + await connection.DisposeAsync(); + throw; + } + } + + private OracleConnection CreateConnection() + { + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("Oracle AQ requires a workflow default connection string."); + + return new OracleConnection(connectionString); + } + + private AmbientOracleTransaction? TryGetAmbientOracleTransaction() + { + if (!string.Equals(dbContext.Database.ProviderName, "Oracle.EntityFrameworkCore", StringComparison.Ordinal)) + { + return null; + } + + var currentTransaction = dbContext.Database.CurrentTransaction; + if (currentTransaction is null) + { + return null; + } + + var connection = dbContext.Database.GetDbConnection(); + var dbTransaction = currentTransaction.GetDbTransaction(); + if (connection is not OracleConnection oracleConnection || dbTransaction is not OracleTransaction oracleTransaction) + { + return null; + } + + return new AmbientOracleTransaction(oracleConnection, oracleTransaction); + } + + private OracleAQQueue CreateQueue(OracleConnection connection, string queueName) + { + return new OracleAQQueue( + ResolveQualifiedQueueName(queueName), + connection, + OracleAQMessageType.Raw, + null); + } + + private void EnqueueMessage(OracleAQQueue queue, OracleAqEnqueueRequest request) + { + var message = new OracleAQMessage(request.Payload) + { + Correlation = request.Correlation, + ExceptionQueue = ResolveQualifiedQueueName(request.ExceptionQueueName), + }; + + if (request.DelaySeconds > 0) + { + message.Delay = request.DelaySeconds; + } + + queue.Enqueue( + message, + new OracleAQEnqueueOptions + { + Visibility = OracleAQVisibilityMode.OnCommit, + }); + } + + private string ResolveQualifiedQueueName(string? queueName) + { + if (string.IsNullOrWhiteSpace(queueName)) + { + throw new InvalidOperationException("Oracle AQ queue name is not configured."); + } + + if (queueName.Contains('.', StringComparison.Ordinal)) + { + return queueName; + } + + return string.IsNullOrWhiteSpace(options.QueueOwner) + ? queueName + : $"{options.QueueOwner}.{queueName}"; + } + + private static byte[] ConvertPayload(object? payload) + { + return payload switch + { + byte[] bytes => bytes, + string text => global::System.Text.Encoding.UTF8.GetBytes(text), + null => [], + _ => throw new InvalidOperationException( + $"Oracle AQ payload type '{payload.GetType().FullName}' is not supported for RAW queues."), + }; + } + + private sealed class OracleAqMessageLease( + OracleConnection connection, + OracleTransaction transaction, + OracleAQQueue queue, + Func resolveQueueName, + OracleAqDequeuedMessage message) : IOracleAqMessageLease + { + private bool isSettled; + + public OracleAqDequeuedMessage Message { get; } = message; + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!isSettled) + { + transaction.Commit(); + isSettled = true; + } + + return Task.CompletedTask; + } + + public Task RollbackAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!isSettled) + { + transaction.Rollback(); + isSettled = true; + } + + return Task.CompletedTask; + } + + public Task DeadLetterAsync( + string queueName, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!isSettled) + { + using var deadLetterQueue = new OracleAQQueue( + resolveQueueName(queueName), + connection, + OracleAQMessageType.Raw, + null); + deadLetterQueue.Enqueue( + new OracleAQMessage(Message.Payload) + { + Correlation = Message.Correlation, + }, + new OracleAQEnqueueOptions + { + Visibility = OracleAQVisibilityMode.OnCommit, + }); + transaction.Commit(); + isSettled = true; + } + + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + if (!isSettled) + { + try + { + transaction.Rollback(); + } + catch + { + // Best-effort cleanup for abandoned AQ leases. + } + } + + queue.Dispose(); + transaction.Dispose(); + await connection.DisposeAsync(); + } + } + + private sealed record AmbientOracleTransaction( + OracleConnection Connection, + OracleTransaction Transaction); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/StellaOps.Workflow.Signaling.OracleAq.csproj b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/StellaOps.Workflow.Signaling.OracleAq.csproj new file mode 100644 index 000000000..086af3f4d --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/StellaOps.Workflow.Signaling.OracleAq.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/WorkflowAqOptions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/WorkflowAqOptions.cs new file mode 100644 index 000000000..57bea6c59 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/WorkflowAqOptions.cs @@ -0,0 +1,14 @@ +namespace StellaOps.Workflow.Signaling.OracleAq; + +public sealed class WorkflowAqOptions +{ + public const string SectionName = "WorkflowAq"; + + public string QueueOwner { get; set; } = string.Empty; + public string SignalQueueName { get; set; } = "WF_SIGNAL_Q"; + public string ScheduleQueueName { get; set; } = "WF_SCHEDULE_Q"; + public string DeadLetterQueueName { get; set; } = "WF_DLQ_Q"; + public string ConsumerName { get; set; } = "WORKFLOW_SERVICE"; + public int BlockingDequeueSeconds { get; set; } = 30; + public int MaxDeliveryAttempts { get; set; } = 10; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/WorkflowSignalEnvelopeSerializer.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/WorkflowSignalEnvelopeSerializer.cs new file mode 100644 index 000000000..225c888b5 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.OracleAq/WorkflowSignalEnvelopeSerializer.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Signaling.OracleAq; + +public sealed class WorkflowSignalEnvelopeSerializer +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + public byte[] Serialize(WorkflowSignalEnvelope envelope) + { + ArgumentNullException.ThrowIfNull(envelope); + + var payload = new WorkflowSignalEnvelopeRecord + { + SignalId = envelope.SignalId, + WorkflowInstanceId = envelope.WorkflowInstanceId, + RuntimeProvider = envelope.RuntimeProvider, + SignalType = envelope.SignalType, + ExpectedVersion = envelope.ExpectedVersion, + WaitingToken = envelope.WaitingToken, + OccurredAtUtc = envelope.OccurredAtUtc, + DueAtUtc = envelope.DueAtUtc, + Payload = new Dictionary(envelope.Payload, StringComparer.OrdinalIgnoreCase), + }; + + return JsonSerializer.SerializeToUtf8Bytes(payload, SerializerOptions); + } + + public WorkflowSignalEnvelope Deserialize(byte[] payload) + { + ArgumentNullException.ThrowIfNull(payload); + + var envelope = JsonSerializer.Deserialize(payload, SerializerOptions) + ?? throw new InvalidOperationException("Workflow signal payload could not be deserialized."); + + return new WorkflowSignalEnvelope + { + SignalId = envelope.SignalId, + WorkflowInstanceId = envelope.WorkflowInstanceId, + RuntimeProvider = envelope.RuntimeProvider, + SignalType = envelope.SignalType, + ExpectedVersion = envelope.ExpectedVersion, + WaitingToken = envelope.WaitingToken, + OccurredAtUtc = envelope.OccurredAtUtc, + DueAtUtc = envelope.DueAtUtc, + Payload = envelope.Payload, + }; + } + + private sealed record WorkflowSignalEnvelopeRecord + { + public required string SignalId { get; init; } + public required string WorkflowInstanceId { get; init; } + public required string RuntimeProvider { get; init; } + public required string SignalType { get; init; } + public required long ExpectedVersion { get; init; } + public string? WaitingToken { get; init; } + public DateTime OccurredAtUtc { get; init; } + public DateTime? DueAtUtc { get; init; } + public Dictionary Payload { get; init; } = new(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowSignalDriver.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowSignalDriver.cs new file mode 100644 index 000000000..e23be62ca --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowSignalDriver.cs @@ -0,0 +1,55 @@ +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.Options; + +using StackExchange.Redis; + +namespace StellaOps.Workflow.Signaling.Redis; + +public sealed class RedisWorkflowSignalDriver( + IConnectionMultiplexer connectionMultiplexer, + IWorkflowSignalClaimStore signalClaimStore, + RedisWorkflowWakeSubscription wakeSubscription, + IOptions driverOptions) : IWorkflowSignalDriver +{ + private readonly RedisWorkflowSignalDriverOptions options = driverOptions.Value; + + public string DriverName => WorkflowSignalDriverNames.Redis; + + public WorkflowSignalDriverDispatchMode DispatchMode => WorkflowSignalDriverDispatchMode.PostCommitNotification; + + public async Task NotifySignalAvailableAsync( + WorkflowSignalWakeNotification notification, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(notification); + + var subscriber = connectionMultiplexer.GetSubscriber(); + var payload = RedisWorkflowWakeMessageSerializer.Serialize(notification); + await subscriber.PublishAsync(RedisChannel.Literal(options.ChannelName), payload, CommandFlags.None); + } + + public async Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + var deadline = DateTime.UtcNow.AddSeconds(Math.Max(1, options.BlockingWaitSeconds)); + + while (true) + { + var lease = await signalClaimStore.TryClaimAsync(consumerName, cancellationToken); + if (lease is not null) + { + return lease; + } + + var remaining = deadline - DateTime.UtcNow; + if (remaining <= TimeSpan.Zero) + { + return null; + } + + _ = await wakeSubscription.WaitForNotificationAsync(remaining, cancellationToken); + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowSignalDriverOptions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowSignalDriverOptions.cs new file mode 100644 index 000000000..873ccbf4d --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowSignalDriverOptions.cs @@ -0,0 +1,10 @@ +namespace StellaOps.Workflow.Signaling.Redis; + +public sealed class RedisWorkflowSignalDriverOptions +{ + public const string SectionName = $"{StellaOps.Workflow.Abstractions.WorkflowSignalDriverOptions.SectionName}:Redis"; + + public string ChannelName { get; set; } = "stellaops:workflow:signals"; + public int BlockingWaitSeconds { get; set; } = 30; + public string PublisherConsumerName { get; set; } = "redis-wake-publisher"; +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowSignalingExtensions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowSignalingExtensions.cs new file mode 100644 index 000000000..181b844c4 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowSignalingExtensions.cs @@ -0,0 +1,32 @@ +using System; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace StellaOps.Workflow.Signaling.Redis; + +public static class RedisWorkflowSignalingExtensions +{ + public static IServiceCollection AddWorkflowRedisSignaling( + this IServiceCollection services, IConfiguration configuration) + { + services.AddWorkflowModule("workflow-signal-driver.redis", "1.0.0"); + services.AddSingleton( + new WorkflowSignalDriverRegistrationMarker(WorkflowSignalDriverNames.Redis)); + + if (!string.Equals(configuration.GetWorkflowSignalDriverProvider(), WorkflowSignalDriverNames.Redis, StringComparison.OrdinalIgnoreCase)) + { + return services; + } + + services.Configure(configuration.GetSection(RedisWorkflowSignalDriverOptions.SectionName)); + services.AddSingleton(); + services.AddScoped(); + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + + return services; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowWakeMessageSerializer.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowWakeMessageSerializer.cs new file mode 100644 index 000000000..13a55dd14 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowWakeMessageSerializer.cs @@ -0,0 +1,19 @@ +using System.Text.Json; + +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.Signaling.Redis; + +public static class RedisWorkflowWakeMessageSerializer +{ + public static string Serialize(WorkflowSignalWakeNotification notification) + { + return JsonSerializer.Serialize(notification); + } + + public static WorkflowSignalWakeNotification Deserialize(string payload) + { + return JsonSerializer.Deserialize(payload) + ?? throw new InvalidOperationException("Unable to deserialize Redis workflow wake notification."); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowWakeOutboxPublisherHostedService.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowWakeOutboxPublisherHostedService.cs new file mode 100644 index 000000000..d998b0b00 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowWakeOutboxPublisherHostedService.cs @@ -0,0 +1,47 @@ +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StellaOps.Workflow.Signaling.Redis; + +public sealed class RedisWorkflowWakeOutboxPublisherHostedService( + IServiceScopeFactory serviceScopeFactory, + ILogger logger, + IOptions driverOptions) : BackgroundService +{ + private readonly RedisWorkflowSignalDriverOptions options = driverOptions.Value; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + using var scope = serviceScopeFactory.CreateScope(); + var receiver = scope.ServiceProvider.GetRequiredService(); + var driver = scope.ServiceProvider.GetRequiredService(); + + try + { + await using var lease = await receiver.ReceiveAsync(options.PublisherConsumerName, stoppingToken); + if (lease is null) + { + continue; + } + + await driver.NotifySignalAvailableAsync(lease.Notification, stoppingToken); + await lease.CompleteAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Redis workflow wake outbox publisher iteration failed."); + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + } + } + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowWakeSubscription.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowWakeSubscription.cs new file mode 100644 index 000000000..84bcc7490 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/RedisWorkflowWakeSubscription.cs @@ -0,0 +1,133 @@ +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.Options; + +using StackExchange.Redis; + +namespace StellaOps.Workflow.Signaling.Redis; + +public sealed class RedisWorkflowWakeSubscription( + IConnectionMultiplexer connectionMultiplexer, + IOptions driverOptions) : IDisposable, IAsyncDisposable +{ + private readonly RedisWorkflowSignalDriverOptions options = driverOptions.Value; + private readonly SemaphoreSlim initializationLock = new(1, 1); + private readonly Channel notifications = Channel.CreateUnbounded( + new UnboundedChannelOptions + { + AllowSynchronousContinuations = false, + SingleReader = false, + SingleWriter = false, + }); + + private ISubscriber? subscriber; + private RedisChannel channel; + private bool initialized; + + public async Task WaitForNotificationAsync( + TimeSpan maxWait, + CancellationToken cancellationToken = default) + { + if (maxWait <= TimeSpan.Zero) + { + return null; + } + + await EnsureInitializedAsync(cancellationToken); + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linkedCts.CancelAfter(maxWait); + + try + { + while (await notifications.Reader.WaitToReadAsync(linkedCts.Token)) + { + if (notifications.Reader.TryRead(out var notification)) + { + return notification; + } + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return null; + } + + return null; + } + + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore(); + GC.SuppressFinalize(this); + } + + public void Dispose() + { + notifications.Writer.TryComplete(); + + if (initialized && subscriber is not null) + { + subscriber.Unsubscribe(channel); + } + + initializationLock.Dispose(); + GC.SuppressFinalize(this); + } + + private async Task EnsureInitializedAsync(CancellationToken cancellationToken) + { + if (initialized) + { + return; + } + + await initializationLock.WaitAsync(cancellationToken); + try + { + if (initialized) + { + return; + } + + subscriber = connectionMultiplexer.GetSubscriber(); + channel = RedisChannel.Literal(options.ChannelName); + await subscriber.SubscribeAsync( + channel, + (_, message) => + { + try + { + var notification = RedisWorkflowWakeMessageSerializer.Deserialize(message!); + notifications.Writer.TryWrite(notification); + } + catch + { + // Ignore malformed wake payloads; durable claim remains the source of truth. + } + }); + initialized = true; + } + finally + { + initializationLock.Release(); + } + } + + private async ValueTask DisposeAsyncCore() + { + notifications.Writer.TryComplete(); + + if (initialized && subscriber is not null) + { + await subscriber.UnsubscribeAsync(channel); + } + + initializationLock.Dispose(); + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/StellaOps.Workflow.Signaling.Redis.csproj b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/StellaOps.Workflow.Signaling.Redis.csproj new file mode 100644 index 000000000..98f3dd1cd --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Signaling.Redis/StellaOps.Workflow.Signaling.Redis.csproj @@ -0,0 +1,18 @@ + + + net10.0 + false + enable + enable + + + + + + + + + + + + diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoBulstradWorkflowIntegrationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoBulstradWorkflowIntegrationTests.cs new file mode 100644 index 000000000..09fd0f5e8 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoBulstradWorkflowIntegrationTests.cs @@ -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(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "QuoteOrAplCancel", + Payload = new Dictionary + { + ["srPolicyId"] = 996601L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using var resumedProvider = CreateProvider(transports); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + 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(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "InsisIntegrationNew", + Payload = new Dictionary + { + ["srPolicyId"] = 995601L, + ["srAnnexId"] = 885601L, + ["srCustId"] = 775601L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using var resumedProvider = CreateProvider(transports); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + 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(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "QuotationConfirm", + Payload = new Dictionary + { + ["srPolicyId"] = 996612L, + ["srAnnexId"] = 886612L, + ["srCustId"] = 776612L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using (var provider = CreateProvider(transports)) + { + var runtimeService = provider.GetRequiredService(); + 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 + { + ["answer"] = "confirm", + }, + }); + } + + using var resumedProvider = CreateProvider(transports); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + 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 state, string key) + { + return state[key] switch + { + bool boolean => boolean, + _ => bool.Parse(state[key]!.ToString()!), + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoDockerFixture.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoDockerFixture.cs new file mode 100644 index 000000000..eed2c81cd --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoDockerFixture.cs @@ -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 CanUseDockerAsync(CancellationToken cancellationToken) + { + return await RunDockerCommandAsync("version --format {{.Server.Version}}", ignoreErrors: true, cancellationToken) == 0; + } + + private static async Task 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(); + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoSerializerRegistrationCoordinator.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoSerializerRegistrationCoordinator.cs new file mode 100644 index 000000000..591f88ecc --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoSerializerRegistrationCoordinator.cs @@ -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; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowProjectionIntegrationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowProjectionIntegrationTests.cs new file mode 100644 index 000000000..2df400bba --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowProjectionIntegrationTests.cs @@ -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 + { + [$"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 + { + ["policyId"] = 42L, + }, + }; + var started = await store.CreateWorkflowAsync( + definition, + businessReference, + new WorkflowStartExecutionPlan + { + InstanceStatus = "Open", + WorkflowState = new Dictionary + { + ["phase"] = JsonSerializer.SerializeToElement("start"), + }, + Tasks = + [ + new WorkflowExecutionTaskPlan + { + TaskName = "Review", + TaskType = "Human", + Route = "review", + TaskRoles = ["uw.review"], + Payload = new Dictionary + { + ["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 { ["approved"] = true }, + new WorkflowTaskCompletionPlan + { + InstanceStatus = "Completed", + WorkflowState = new Dictionary + { + ["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 + { + [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(), + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowSignalIntegrationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowSignalIntegrationTests.cs new file mode 100644 index 000000000..4393925a2 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowSignalIntegrationTests.cs @@ -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 + { + [$"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 + { + ["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, + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowStoreIntegrationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowStoreIntegrationTests.cs new file mode 100644 index 000000000..bad111cb6 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowStoreIntegrationTests.cs @@ -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 + { + [$"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 + { + ["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(); + } + + [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), + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowWakeOutboxIntegrationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowWakeOutboxIntegrationTests.cs new file mode 100644 index 000000000..12921b637 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/MongoWorkflowWakeOutboxIntegrationTests.cs @@ -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 + { + [$"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, + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceCapacityTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceCapacityTests.cs new file mode 100644 index 000000000..978003e1d --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceCapacityTests.cs @@ -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(); + + 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 + { + ["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 + { + ["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(StringComparer.Ordinal) + { + ["workflowName"] = "MongoPerfSignalRoundTripWorkflow", + ["ladder"] = "1,4,8,16", + ["workerCount"] = workerCount.ToString(), + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceLatencyTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceLatencyTests.cs new file mode 100644 index 000000000..a7d2b2352 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceLatencyTests.cs @@ -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(); + var startLatencies = new ConcurrentBag(); + var signalPublishLatencies = new ConcurrentBag(); + var signalToCompletionLatencies = new ConcurrentBag(); + var signalToFirstCompletionLatencies = new ConcurrentBag(); + var drainToIdleOverhangLatencies = new ConcurrentBag(); + + 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 + { + ["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 + { + ["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 + { + ["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(StringComparer.Ordinal) + { + ["workflowName"] = "MongoPerfSignalRoundTripWorkflow", + ["workerCount"] = "1", + ["measurementKind"] = "serial-latency", + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceMetricsCollector.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceMetricsCollector.cs new file mode 100644 index 000000000..5ce0f53b0 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceMetricsCollector.cs @@ -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 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( + new BsonDocument("serverStatus", 1), + cancellationToken: cancellationToken); + var dbStats = await database.RunCommandAsync( + 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(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(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> ReadTopOperationsAsync( + IMongoDatabase adminDatabase, + CancellationToken cancellationToken) + { + try + { + var currentOp = await adminDatabase.RunCommandAsync( + 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() + .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 CounterStats { get; init; } + public required IReadOnlyDictionary DurationStats { get; init; } + public required IReadOnlyList 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 CounterDeltas { get; init; } + public required IReadOnlyDictionary DurationDeltas { get; init; } + public required IReadOnlyList 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, + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceNightlyTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceNightlyTests.cs new file mode 100644 index 000000000..a5be76357 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceNightlyTests.cs @@ -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(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(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(); + 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 + { + ["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 + { + ["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(), + })); + + 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(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(); + 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 + { + ["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 + { + ["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(StringComparer.Ordinal) + { + ["workflowName"] = "QuotationConfirm", + ["legacyRabbitInvocationCount"] = transports.LegacyRabbit.Invocations.Count.ToString(), + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceSmokeTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceSmokeTests.cs new file mode 100644 index 000000000..d17d12ef2 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceSmokeTests.cs @@ -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(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(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(); + 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 + { + ["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(StringComparer.Ordinal) + { + ["workflowName"] = "QuoteOrAplCancel", + ["legacyRabbitInvocationCount"] = transports.LegacyRabbit.Invocations.Count.ToString(), + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceSoakTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceSoakTests.cs new file mode 100644 index 000000000..9e29c4864 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceSoakTests.cs @@ -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(); + 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 + { + ["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 + { + ["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(StringComparer.Ordinal) + { + ["workflowName"] = "MongoPerfSignalRoundTripWorkflow", + ["waveCount"] = waveCount.ToString(), + ["workflowsPerWave"] = workflowsPerWave.ToString(), + ["workerCount"] = workerCount.ToString(), + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceTestSupport.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceTestSupport.cs new file mode 100644 index 000000000..f9a276c2e --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceTestSupport.cs @@ -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 + { + ["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(); + services.AddWorkflowRegistration(); + + 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(_ => transports.LegacyRabbit)); + services.Replace(ServiceDescriptor.Scoped(_ => transports.Microservice)); + services.Replace(ServiceDescriptor.Scoped(_ => transports.Graphql)); + services.Replace(ServiceDescriptor.Scoped(_ => transports.Http)); + + var provider = services.BuildServiceProvider(); + ServiceProviderAccessor.Initialize(provider); + return provider; + } + + public static async Task 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 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 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(); + 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( + string connectionString, + string databaseName, + Func> 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 RunImmediateTransportBurstAsync( + IServiceProvider provider, + int messageCount, + TimeSpan timeout, + string correlationPrefix) + { + using var scope = provider.CreateScope(); + var signalBus = scope.ServiceProvider.GetRequiredService(); + return await RunTransportBurstAsync(signalBus, scheduleBus: null, messageCount, delaySeconds: 0, timeout, correlationPrefix); + } + + public static async Task RunDelayedTransportBurstAsync( + IServiceProvider provider, + int messageCount, + int delaySeconds, + TimeSpan timeout, + string correlationPrefix) + { + using var scope = provider.CreateScope(); + var signalBus = scope.ServiceProvider.GetRequiredService(); + var scheduleBus = scope.ServiceProvider.GetRequiredService(); + return await RunTransportBurstAsync(signalBus, scheduleBus, messageCount, delaySeconds, timeout, correlationPrefix); + } + + private static async Task RunTransportBurstAsync( + IWorkflowSignalBus signalBus, + IWorkflowScheduleBus? scheduleBus, + int messageCount, + int delaySeconds, + TimeSpan timeout, + string correlationPrefix) + { + var publishedAt = new Dictionary(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(messageCount); + var processedCorrelations = new HashSet(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 + { + ["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 + { + 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 WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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( + "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 + { + 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 WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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 ReceiveLatencies { get; init; } + public required IReadOnlyCollection ProcessedCorrelations { get; init; } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceThroughputTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceThroughputTests.cs new file mode 100644 index 000000000..4f18de3a8 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoPerformanceThroughputTests.cs @@ -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 + { + ["srPolicyId"] = 998999L, + }, + })); + await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync( + provider, + runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = warmupResponse.WorkflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = 868999L, + }, + })); + await MongoPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(provider, TimeSpan.FromSeconds(20), workerCount: 2); + + var startedAtUtc = DateTime.UtcNow; + var endToEndLatencies = new ConcurrentBag(); + var startLatencies = new ConcurrentBag(); + var signalPublishLatencies = new ConcurrentBag(); + var signalToCompletionLatencies = new ConcurrentBag(); + + 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 + { + ["srPolicyId"] = 998000L + index, + }, + })); + startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc); + return (Index: index, Response: response, StartedAtUtc: operationStartedAtUtc); + }); + + var signalRaisedAtUtc = new ConcurrentDictionary(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 + { + ["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 + { + ["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!, + ["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!, + ["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!, + }, + BackendMetrics = mongoMetrics.ToBackendMetrics(), + ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(), + Metadata = new Dictionary(StringComparer.Ordinal) + { + ["workflowName"] = "MongoPerfSignalRoundTripWorkflow", + ["workerCount"] = workerCount.ToString(), + ["measurementKind"] = "steady-throughput", + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoRedisSignalDriverPerformanceTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoRedisSignalDriverPerformanceTests.cs new file mode 100644 index 000000000..a800c1cb8 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoRedisSignalDriverPerformanceTests.cs @@ -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(); + var startLatencies = new ConcurrentBag(); + var signalPublishLatencies = new ConcurrentBag(); + var signalToCompletionLatencies = new ConcurrentBag(); + var signalToFirstCompletionLatencies = new ConcurrentBag(); + var drainToIdleOverhangLatencies = new ConcurrentBag(); + + 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 + { + ["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 + { + ["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 + { + ["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(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 + { + ["srPolicyId"] = 999999L, + }, + })); + await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync( + provider, + runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = warmupResponse.WorkflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = 889999L, + }, + })); + await MongoPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(provider, TimeSpan.FromSeconds(20), workerCount: 2); + + var startedAtUtc = DateTime.UtcNow; + var endToEndLatencies = new ConcurrentBag(); + var startLatencies = new ConcurrentBag(); + var signalPublishLatencies = new ConcurrentBag(); + var signalToCompletionLatencies = new ConcurrentBag(); + + 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 + { + ["srPolicyId"] = 999100L + index, + }, + })); + startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc); + return (Index: index, Response: response, StartedAtUtc: operationStartedAtUtc); + }); + + var signalRaisedAtUtc = new ConcurrentDictionary(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 + { + ["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 + { + ["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!, + ["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!, + ["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!, + }, + BackendMetrics = mongoMetrics.ToBackendMetrics(), + ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(), + Metadata = new Dictionary(StringComparer.Ordinal) + { + ["workflowName"] = "MongoPerfSignalRoundTripWorkflow", + ["workerCount"] = workerCount.ToString(), + ["measurementKind"] = "steady-throughput", + ["signalDriver"] = WorkflowSignalDriverNames.Redis, + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoWorkflowPerformanceMetricsExtensions.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoWorkflowPerformanceMetricsExtensions.cs new file mode 100644 index 000000000..2812453ba --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/Performance/MongoWorkflowPerformanceMetricsExtensions.cs @@ -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(delta.CounterDeltas, StringComparer.OrdinalIgnoreCase), + DurationDeltas = new Dictionary(delta.DurationDeltas, StringComparer.OrdinalIgnoreCase), + Metadata = new Dictionary(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, + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/StellaOps.Workflow.DataStore.MongoDB.Tests.csproj b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/StellaOps.Workflow.DataStore.MongoDB.Tests.csproj new file mode 100644 index 000000000..3a0054655 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/StellaOps.Workflow.DataStore.MongoDB.Tests.csproj @@ -0,0 +1,51 @@ + + + net10.0 + false + enable + enable + false + true + false + + CS8601;CS8602;CS8604;NU1015 + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqBulstradWorkflowIntegrationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqBulstradWorkflowIntegrationTests.cs new file mode 100644 index 000000000..d0dadda4b --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqBulstradWorkflowIntegrationTests.cs @@ -0,0 +1,1737 @@ +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.HostedServices; +using StellaOps.Workflow.Engine.Services; + +using FluentAssertions; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using NUnit.Framework; + +using BulstradWorkflowRegistrator = StellaOps.Workflow.Engine.Workflows.Bulstrad.ServiceRegistrator; + +namespace StellaOps.Workflow.DataStore.Oracle.Tests; + +[TestFixture] +[Category("Integration")] +[NonParallelizable] +public class OracleAqBulstradWorkflowIntegrationTests +{ + [Test] + public async Task AssistantPrintInsisDocuments_WhenNotificationsExist_ShouldCompleteThroughRealOracleStore() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateAssistantPrintInsisDocumentsTransports(); + + using var provider = CreateProvider(queueSet, transports); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "AssistantPrintInsisDocuments", + Payload = new Dictionary + { + ["srPolicyId"] = 94011L, + ["srAnnexId"] = 94012L, + ["opBatchId"] = 94013L, + ["message"] = "Print annex documents", + ["signalResponse"] = true, + }, + }); + + 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.Status.Should().Be(WorkflowInstanceStatuses.Completed); + instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + instance.WorkflowState.Should().ContainKey("printInsisAttempt"); + instance.WorkflowState.Should().ContainKey("docsCount"); + openTasks.Tasks.Should().BeEmpty(); + transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}") + .Should().Equal( + "Envelope:bst_integration_print_batch_printout", + "Envelope:bst_integration_print_batch_printout_notif_params", + "MicroserviceConsumer:notifications_send_private_note", + "MicroserviceConsumer:notifications_send_email"); + } + + [Test] + public async Task QuoteOrAplCancel_WhenServicesSucceedAcrossRestartedProviders_ShouldCompleteWithoutTasks() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateQuoteOrAplCancelSuccessTransports(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "QuoteOrAplCancel", + Payload = new Dictionary + { + ["srPolicyId"] = 99601L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using var resumedProvider = CreateProvider(queueSet, transports); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + 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 QuoteOrAplCancel_WhenReleaseDocNumbersFailsAcrossRestartedProviders_ShouldStillCompleteAndCancelApplication() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateQuoteOrAplCancelReleaseFailureTransports(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "QuoteOrAplCancel", + Payload = new Dictionary + { + ["srPolicyId"] = 99602L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using var resumedProvider = CreateProvider(queueSet, transports); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + var instance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + + instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed); + ReadBool(instance.WorkflowState, "releaseDocNumbersFailed").Should().BeTrue(); + 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_WhenTransferSucceedsAcrossRestartedProviders_ShouldCompleteWithoutTasksThroughRealOracleStore() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateInsisIntegrationNewSuccessTransports(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "InsisIntegrationNew", + Payload = new Dictionary + { + ["srPolicyId"] = 99500L, + ["srAnnexId"] = 88500L, + ["srCustId"] = 77500L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using var resumedProvider = CreateProvider(queueSet, transports); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + 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.WorkflowState["nextStep"]!.ToString().Should().Be("SUCCESS"); + + transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}") + .Should().Equal( + "Envelope:bst_integration_processsendpolicyrequest", + "MicroserviceConsumer:pas_operations_perform"); + } + + [Test] + public async Task InsisIntegrationNew_WhenTransferRequiresRetryAcrossRestartedProviders_ShouldCreateRetryTaskThroughRealOracleStore() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateInsisIntegrationNewRetryTransports(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "InsisIntegrationNew", + Payload = new Dictionary + { + ["srPolicyId"] = 99501L, + ["srAnnexId"] = 88501L, + ["srCustId"] = 77501L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using var resumedProvider = CreateProvider(queueSet, transports); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + 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 InsisIntegrationNew_WhenTransferTimesOutAcrossRestartedProviders_ShouldContinueWithApproveApplicationThroughRealOracleAq() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateInsisIntegrationNewTimeoutTransports(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "InsisIntegrationNew", + Payload = new Dictionary + { + ["srPolicyId"] = 99502L, + ["srAnnexId"] = 88502L, + ["srCustId"] = 77502L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using var resumedProvider = CreateProvider(queueSet, transports); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + var processedSignals = await DrainSignalsUntilIdleAsync(resumedProvider, TimeSpan.FromSeconds(45)); + var instance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + var approveInstances = await resumedRuntimeService.GetInstancesAsync(new WorkflowInstancesGetRequest + { + WorkflowName = "ApproveApplication", + BusinessReferenceKey = "99502", + }); + var approveTasks = await resumedRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowName = "ApproveApplication", + BusinessReferenceKey = "99502", + Status = WorkflowTaskStatuses.Open, + }); + + processedSignals.Should().BeGreaterThan(0); + instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed); + instance.WorkflowState["nextStep"]!.ToString().Should().Be("TIMEOUT"); + approveInstances.Instances.Should().ContainSingle(); + approveTasks.Tasks.Should().ContainSingle(); + approveTasks.Tasks.Single().TaskName.Should().Be("Approve Application"); + approveTasks.Tasks.Single().BusinessReference!.Key.Should().Be("99502"); + approveTasks.Tasks.Single().Payload["srAnnexId"]!.ToString().Should().Be("88502"); + + transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}") + .Should().Equal("Envelope:bst_integration_processsendpolicyrequest"); + } + + [Test] + public async Task QuotationConfirm_WhenCancelledAcrossRestartedProviders_ShouldCompleteWithoutContinuations() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateQuotationConfirmCancelTransports(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "QuotationConfirm", + Payload = new Dictionary + { + ["srPolicyId"] = 99610L, + ["srAnnexId"] = 88610L, + ["srCustId"] = 77610L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var quotationTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + })).Tasks.Should().ContainSingle().Subject; + + quotationTask.TaskName.Should().Be("Confirm Quotation"); + quotationTask.Payload["taskDescription"]!.ToString().Should().Be("Quote short description"); + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = quotationTask.WorkflowTaskId, + ActorId = "user-1", + ActorRoles = ["DBA"], + Payload = new Dictionary + { + ["answer"] = "cancel", + }, + }); + } + + using var resumedProvider = CreateProvider(queueSet, transports); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + var instance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + var approveInstances = await resumedRuntimeService.GetInstancesAsync(new WorkflowInstancesGetRequest + { + WorkflowName = "ApproveApplication", + BusinessReferenceKey = "99610", + }); + var pdfInstances = await resumedRuntimeService.GetInstancesAsync(new WorkflowInstancesGetRequest + { + WorkflowName = "PdfGenerator", + BusinessReferenceKey = "99610", + }); + + instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed); + instance.WorkflowState["answer"]!.ToString().Should().Be("cancel"); + approveInstances.Instances.Should().BeEmpty(); + pdfInstances.Instances.Should().BeEmpty(); + + transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}") + .Should().Equal( + "Envelope:pas_polannexes_get", + "Envelope:pas_annexprocessing_cancelaplorqt"); + } + + [Test] + public async Task QuotationConfirm_WhenConvertedToApplicationAcrossRestartedProviders_ShouldContinueWithApproveApplication() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateQuotationConfirmConvertToApplicationTransports(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "QuotationConfirm", + Payload = new Dictionary + { + ["srPolicyId"] = 99611L, + ["srAnnexId"] = 88611L, + ["srCustId"] = 77611L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + 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 = "user-1", + ActorRoles = ["DBA"], + Payload = new Dictionary + { + ["answer"] = "confirm", + }, + }); + } + + using var resumedProvider = CreateProvider(queueSet, transports); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + var processedSignals = await DrainSignalsUntilIdleAsync(resumedProvider, TimeSpan.FromSeconds(45)); + var quotationInstance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + var approveTasks = await resumedRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowName = "ApproveApplication", + BusinessReferenceKey = "99611", + Status = WorkflowTaskStatuses.Open, + }); + var pdfInstances = await resumedRuntimeService.GetInstancesAsync(new WorkflowInstancesGetRequest + { + WorkflowName = "PdfGenerator", + BusinessReferenceKey = "99611", + }); + + processedSignals.Should().BeGreaterThan(0); + quotationInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed); + quotationInstance.WorkflowState["nextStep"]!.ToString().Should().Be("ConvertToApplication"); + approveTasks.Tasks.Should().ContainSingle(); + approveTasks.Tasks.Single().TaskName.Should().Be("Approve Application"); + approveTasks.Tasks.Single().BusinessReference!.Parts["customerId"]!.ToString().Should().Be("77611"); + pdfInstances.Instances.Should().BeEmpty(); + + transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}") + .Should().Equal( + "Envelope:pas_polannexes_get", + "Envelope:pas_polreg_checkuwrules", + "Envelope:pas_polreg_convertqttoapldefault"); + } + + [Test] + public async Task QuotationConfirm_WhenConvertedToPolicyAcrossRestartedProviders_ShouldContinueWithPdfGenerator() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateQuotationConfirmConvertToPolicyTransports(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "QuotationConfirm", + Payload = new Dictionary + { + ["srPolicyId"] = 99612L, + ["srAnnexId"] = 88612L, + ["srCustId"] = 77612L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + 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 = "user-1", + ActorRoles = ["DBA"], + Payload = new Dictionary + { + ["answer"] = "confirm", + }, + }); + } + + using var resumedProvider = CreateProvider(queueSet, transports); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + var processedSignals = await DrainSignalsUntilIdleAsync(resumedProvider, TimeSpan.FromSeconds(45)); + var quotationInstance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + var pdfInstances = await resumedRuntimeService.GetInstancesAsync(new WorkflowInstancesGetRequest + { + WorkflowName = "PdfGenerator", + BusinessReferenceKey = "99612", + }); + var pdfInstance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = pdfInstances.Instances.Should().ContainSingle().Subject.WorkflowInstanceId, + }); + var approveInstances = await resumedRuntimeService.GetInstancesAsync(new WorkflowInstancesGetRequest + { + WorkflowName = "ApproveApplication", + BusinessReferenceKey = "99612", + }); + + 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(); + approveInstances.Instances.Should().BeEmpty(); + + 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"); + } + + [Test] + public async Task OpenForChangePolicy_WhenApprovedAcrossRestartedProviders_ShouldCompleteNestedChainThroughRealOracleAq() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateOpenForChangePolicyTransports(); + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "OpenForChangePolicy", + Payload = new Dictionary + { + ["srPolicyId"] = 91001L, + ["srAnnexId"] = 91002L, + ["srCustId"] = 91003L, + ["annexType"] = "ADDVEH", + ["beginDate"] = "2026-03-14", + ["endDate"] = "2027-03-13", + ["issueDate"] = "2026-03-14", + }, + }); + + var rootInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + var confirmInstances = await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest + { + WorkflowName = "ContinueOnOpenedAnnex", + BusinessReferenceKey = "91001", + }); + var confirmInstance = confirmInstances.Instances.Should().ContainSingle().Subject; + var confirmDetails = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = confirmInstance.WorkflowInstanceId, + }); + var confirmTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowName = "ContinueOnOpenedAnnex", + Status = "Open", + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + rootInstance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + confirmInstance.Status.Should().Be(WorkflowInstanceStatuses.Open); + confirmTask.TaskName.Should().Be("Confirm Changes"); + confirmDetails.Tasks.Should().ContainSingle(x => x.TaskName == "Confirm Changes" && x.Status == WorkflowTaskStatuses.Open); + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var confirmTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowName = "ContinueOnOpenedAnnex", + Status = "Open", + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = confirmTask.WorkflowTaskId, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + Payload = new Dictionary + { + ["answer"] = "confirm", + }, + }); + + var reviewInstances = await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest + { + WorkflowName = "ReviewPolicyOpenForChange", + BusinessReferenceKey = "91001", + }); + var reviewInstance = reviewInstances.Instances.Should().ContainSingle().Subject; + var reviewDetails = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = reviewInstance.WorkflowInstanceId, + }); + var reviewTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowName = "ReviewPolicyOpenForChange", + Status = "Open", + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + reviewInstance.Status.Should().Be(WorkflowInstanceStatuses.Open); + reviewTask.TaskName.Should().Be("Review policy changes"); + reviewDetails.Tasks.Should().ContainSingle(x => x.TaskName == "Review policy changes" && x.Status == WorkflowTaskStatuses.Open); + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var reviewTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowName = "ReviewPolicyOpenForChange", + Status = "Open", + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = reviewTask.WorkflowTaskId, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + Payload = new Dictionary + { + ["answer"] = "approve", + }, + }); + + var processedSignals = await DrainSignalsUntilIdleAsync(provider, TimeSpan.FromSeconds(45)); + var instances = await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest()); + var openTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + Status = "Open", + }); + + openTasks.Tasks.Should().BeEmpty(); + instances.Instances.Should().ContainSingle(x => + x.WorkflowName == "OpenForChangePolicy" + && x.Status == WorkflowInstanceStatuses.Completed + && x.RuntimeProvider == WorkflowRuntimeProviderNames.Engine); + instances.Instances.Should().ContainSingle(x => + x.WorkflowName == "ContinueOnOpenedAnnex" + && x.Status == WorkflowInstanceStatuses.Completed); + instances.Instances.Should().ContainSingle(x => + x.WorkflowName == "ReviewPolicyOpenForChange" + && x.Status == WorkflowInstanceStatuses.Completed); + + var completedConfirmDetails = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = instances.Instances.Single(x => x.WorkflowName == "ContinueOnOpenedAnnex").WorkflowInstanceId, + }); + var completedReviewDetails = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = instances.Instances.Single(x => x.WorkflowName == "ReviewPolicyOpenForChange").WorkflowInstanceId, + }); + + completedConfirmDetails.Tasks.Should().ContainSingle(x => x.TaskName == "Confirm Changes" && x.Status == WorkflowTaskStatuses.Completed); + completedConfirmDetails.TaskEvents.Should().Contain(x => x.EventType == WorkflowTaskEventTypes.Completed); + completedReviewDetails.Tasks.Should().ContainSingle(x => x.TaskName == "Review policy changes" && x.Status == WorkflowTaskStatuses.Completed); + completedReviewDetails.TaskEvents.Should().Contain(x => x.EventType == WorkflowTaskEventTypes.Completed); + } + + transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}") + .Should().Equal( + "Envelope:pas_get_policy_product_info", + "Envelope:pas_annexprocessing_alterpolicy", + "Envelope:pas_polannexes_get", + "MicroserviceConsumer:pas_operations_perform", + "Envelope:pas_annexprocessing_convertannex", + "Envelope:bst_integration_transferannex"); + } + + [Test] + public async Task OpenForChangePolicy_WhenReviewTransferNeedsRetryAcrossRestartedProviders_ShouldReopenReviewTaskThroughProjectionReadPath() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateOpenForChangePolicyRetryTransports(); + string rootWorkflowInstanceId; + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "OpenForChangePolicy", + Payload = new Dictionary + { + ["srPolicyId"] = 91101L, + ["srAnnexId"] = 91102L, + ["srCustId"] = 91103L, + ["annexType"] = "ADDVEH", + ["beginDate"] = "2026-03-14", + ["endDate"] = "2027-03-13", + ["issueDate"] = "2026-03-14", + }, + }); + + rootWorkflowInstanceId = startResponse.WorkflowInstanceId; + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var confirmTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowName = "ContinueOnOpenedAnnex", + Status = WorkflowTaskStatuses.Open, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = confirmTask.WorkflowTaskId, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + Payload = new Dictionary + { + ["answer"] = "confirm", + }, + }); + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var reviewTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowName = "ReviewPolicyOpenForChange", + Status = WorkflowTaskStatuses.Open, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = reviewTask.WorkflowTaskId, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + Payload = new Dictionary + { + ["answer"] = "approve", + }, + }); + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var processedSignals = await DrainSignalsUntilIdleAsync(provider, TimeSpan.FromSeconds(45)); + var instances = await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest()); + var rootInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = rootWorkflowInstanceId, + }); + var confirmInstance = instances.Instances.Should().ContainSingle(x => x.WorkflowName == "ContinueOnOpenedAnnex").Subject; + var reviewInstance = instances.Instances.Should().ContainSingle(x => x.WorkflowName == "ReviewPolicyOpenForChange").Subject; + var reviewDetails = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = reviewInstance.WorkflowInstanceId, + }); + var openReviewTasks = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowName = "ReviewPolicyOpenForChange", + Status = WorkflowTaskStatuses.Open, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks; + + processedSignals.Should().Be(0); + rootInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Open); + rootInstance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + confirmInstance.Status.Should().Be(WorkflowInstanceStatuses.Completed); + reviewInstance.Status.Should().Be(WorkflowInstanceStatuses.Open); + openReviewTasks.Should().ContainSingle(); + openReviewTasks.Single().TaskName.Should().Be("Review policy changes"); + reviewDetails.TaskEvents.Should().Contain(x => x.EventType == WorkflowTaskEventTypes.Completed); + reviewDetails.Tasks.Should().Contain(x => x.TaskName == "Review policy changes" && x.Status == WorkflowTaskStatuses.Open); + reviewDetails.Tasks.Should().Contain(x => x.TaskName == "Review policy changes" && x.Status == WorkflowTaskStatuses.Completed); + } + + transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}") + .Should().Equal( + "Envelope:pas_get_policy_product_info", + "Envelope:pas_annexprocessing_alterpolicy", + "Envelope:pas_polannexes_get", + "MicroserviceConsumer:pas_operations_perform", + "Envelope:pas_annexprocessing_convertannex", + "Envelope:bst_integration_transferannex"); + } + + [Test] + public async Task ReviewPolicyOpenForChange_WhenTransferNeedsRetryAcrossRestartedProviders_ShouldReopenReviewTaskThroughRealOracleStore() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateReviewPolicyOpenForChangeRetryTransports(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ReviewPolicyOpenForChange", + Payload = new Dictionary + { + ["srPolicyId"] = 92001L, + ["srAnnexId"] = 92002L, + ["srCustId"] = 92003L, + ["productCode"] = "4704", + ["lob"] = "MOT", + ["contractType"] = "STANDARD", + ["taskDescription"] = "Review transferred annex", + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + + var reviewTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + reviewTask.TaskName.Should().Be("Review policy changes"); + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var reviewTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = reviewTask.WorkflowTaskId, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + Payload = new Dictionary + { + ["answer"] = "approve", + }, + }); + + var reopenedTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + reopenedTask.TaskName.Should().Be("Review policy changes"); + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + var reopenedTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Open); + instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + reopenedTask.TaskName.Should().Be("Review policy changes"); + instance.TaskEvents.Should().Contain(x => x.EventType == WorkflowTaskEventTypes.Completed); + } + + transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}") + .Should().Equal( + "MicroserviceConsumer:pas_operations_perform", + "Envelope:pas_annexprocessing_convertannex", + "Envelope:bst_integration_transferannex"); + } + + [Test] + public async Task ReviewPolicyOpenForChange_WhenHealthRejectsAcrossRestartedProviders_ShouldCancelAnnexAndReleaseLockThroughRealOracleStore() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateReviewPolicyOpenForChangeHealthRejectTransports(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ReviewPolicyOpenForChange", + Payload = new Dictionary + { + ["srPolicyId"] = 92021L, + ["srAnnexId"] = 92022L, + ["srCustId"] = 92023L, + ["productCode"] = "HLT01", + ["lob"] = "HLT", + ["contractType"] = "MASTER", + ["taskDescription"] = "Review health annex rejection", + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var reviewTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = reviewTask.WorkflowTaskId, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + Payload = new Dictionary + { + ["answer"] = "reject", + }, + }); + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + var openTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + + instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed); + instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + openTasks.Tasks.Should().BeEmpty(); + instance.Tasks.Should().ContainSingle(x => x.TaskName == "Review policy changes" && x.Status == WorkflowTaskStatuses.Completed); + instance.TaskEvents.Should().Contain(x => x.EventType == WorkflowTaskEventTypes.Completed); + } + + transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}") + .Should().Equal( + "Envelope:pas_annexprocessing_cancelannex", + "MicroserviceConsumer:pas_health_manager.release_policy_lock"); + } + + [Test] + public async Task AssistantAddAnnex_WhenBenefitAnnexOnIpalAcrossRestartedProviders_ShouldContinueToReviewThroughRealOracleStore() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateAssistantAddAnnexTransports(); + string rootWorkflowInstanceId; + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "AssistantAddAnnex", + Payload = new Dictionary + { + ["srPolicyId"] = 93001L, + ["srAnnexId"] = 93002L, + ["policyExistsOnIPAL"] = true, + ["annexPreviouslyOpened"] = false, + ["annexType"] = "BENEF", + ["entityData"] = new { srCustId = 93003L, fullName = "Added Participant" }, + }, + }); + + rootWorkflowInstanceId = startResponse.WorkflowInstanceId; + } + + using var verificationProvider = CreateProvider(queueSet, transports); + var verificationRuntimeService = verificationProvider.GetRequiredService(); + var processedSignals = await DrainSignalsUntilIdleAsync(verificationProvider, TimeSpan.FromSeconds(45)); + var rootInstance = await verificationRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = rootWorkflowInstanceId, + }); + var reviewInstances = await verificationRuntimeService.GetInstancesAsync(new WorkflowInstancesGetRequest + { + WorkflowName = "ReviewPolicyOpenForChange", + }); + var reviewInstance = reviewInstances.Instances.Should().ContainSingle().Subject; + var reviewDetails = await verificationRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = reviewInstance.WorkflowInstanceId, + }); + var reviewTask = (await verificationRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = reviewInstance.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + processedSignals.Should().BeGreaterThanOrEqualTo(1); + rootInstance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + reviewInstance.Status.Should().Be(WorkflowInstanceStatuses.Open); + reviewTask.TaskName.Should().Be("Review policy changes"); + reviewDetails.Tasks.Should().ContainSingle(x => x.TaskName == "Review policy changes" && x.Status == WorkflowTaskStatuses.Open); + transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}") + .Should().Equal( + "Envelope:pas_annexprocessing_alterpolicy", + "Envelope:pas_polclmparticipants_create", + "MicroserviceConsumer:pas_premium_calculate_for_object", + "Envelope:pas_polannexes_get"); + } + + [Test] + public async Task AnnexCancellation_WhenMasterStandardPolicyIsApprovedAcrossRestartedProviders_ShouldRunBatchAndTransfer() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateAnnexCancellationStandardTransports(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "AnnexCancellation", + Payload = new Dictionary + { + ["srPolicyId"] = 95001L, + ["srAnnexId"] = 95002L, + ["srCustId"] = 95003L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + + var cancelTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + cancelTask.TaskName.Should().Be("Cancel Annex"); + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var cancelTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = cancelTask.WorkflowTaskId, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + Payload = new Dictionary + { + ["answer"] = "approve", + }, + }); + + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + + instance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed); + instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + } + + transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}") + .Should().Equal( + "Envelope:pas_get_policy_product_info", + "Envelope:pas_annexprocessing_cancelannex", + "Envelope:pas_batch_policies_process_annex", + "Envelope:bst_integration_transferannex"); + } + + [Test] + public async Task AnnexCancellation_WhenHealthMasterPolicyIsApproved_ShouldAcquireLockAndPropagateCensusThroughRealOracleStore() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateAnnexCancellationHealthTransports(); + + using var provider = CreateProvider(queueSet, transports); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "AnnexCancellation", + Payload = new Dictionary + { + ["srPolicyId"] = 96001L, + ["srAnnexId"] = 96002L, + ["srCustId"] = 96003L, + }, + }); + + var cancelTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + })).Tasks.Should().ContainSingle().Subject; + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = cancelTask.WorkflowTaskId, + ActorId = "policy-change-user", + ActorRoles = ["DBA"], + Payload = new Dictionary + { + ["answer"] = "approve", + }, + }); + + 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.Status.Should().Be(WorkflowInstanceStatuses.Completed); + instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + openTasks.Tasks.Should().BeEmpty(); + transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}") + .Should().Equal( + "Envelope:pas_get_policy_product_info", + "MicroserviceConsumer:pas_health_manager.acquire_policy_lock", + "MicroserviceConsumer:pas_health_manager.cancel_annex", + "MicroserviceConsumer:pas_health_manager.propagate_cancel_annex_to_members", + "Envelope:bst_integration_transferannex"); + } + + [Test] + public async Task AssistantPolicyReinstate_WhenApproveApplicationCompletesAcrossRestartedProviders_ShouldCompleteParentThroughRealOracleAq() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateAssistantPolicyReinstateTransports(); + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + + await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "AssistantPolicyReinstate", + Payload = new Dictionary + { + ["srPolicyId"] = 98001L, + ["srAnnexId"] = 98002L, + ["srCustId"] = 98003L, + ["policyNo"] = "POL-98001", + ["beginDate"] = "2026-03-14", + ["policyExistsOnIPAL"] = false, + }, + }); + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var processedSignals = await DrainSignalsUntilIdleAsync(provider, TimeSpan.FromSeconds(45)); + var approveInstances = await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest + { + WorkflowName = "ApproveApplication", + BusinessReferenceKey = "98001", + }); + var approveInstance = approveInstances.Instances.Should().ContainSingle().Subject; + var approveDetails = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = approveInstance.WorkflowInstanceId, + }); + var approveTask = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowName = "ApproveApplication", + Status = "Open", + })).Tasks.Should().ContainSingle().Subject; + + processedSignals.Should().BeGreaterThanOrEqualTo(1); + approveInstance.Status.Should().Be(WorkflowInstanceStatuses.Open); + approveTask.WorkflowName.Should().Be("ApproveApplication"); + approveDetails.Tasks.Should().ContainSingle(x => x.TaskName == "Approve Application" && x.Status == WorkflowTaskStatuses.Open); + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = approveTask.WorkflowTaskId, + ActorId = "approver-1", + ActorRoles = ["APR_APPL"], + Payload = new Dictionary + { + ["answer"] = "approve", + }, + }); + } + + using (var provider = CreateProvider(queueSet, transports)) + { + var runtimeService = provider.GetRequiredService(); + var processedSignals = await DrainSignalsUntilIdleAsync(provider, TimeSpan.FromSeconds(45)); + var instances = await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest()); + var openTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + Status = "Open", + }); + + openTasks.Tasks.Should().BeEmpty(); + instances.Instances.Should().ContainSingle(x => + x.WorkflowName == "AssistantPolicyReinstate" + && x.Status == WorkflowInstanceStatuses.Completed + && x.RuntimeProvider == WorkflowRuntimeProviderNames.Engine); + instances.Instances.Should().ContainSingle(x => + x.WorkflowName == "ApproveApplication" + && x.Status == WorkflowInstanceStatuses.Completed); + + var approveDetails = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = instances.Instances.Single(x => x.WorkflowName == "ApproveApplication").WorkflowInstanceId, + }); + + approveDetails.Tasks.Should().ContainSingle(x => x.TaskName == "Approve Application" && x.Status == WorkflowTaskStatuses.Completed); + approveDetails.TaskEvents.Should().Contain(x => x.EventType == WorkflowTaskEventTypes.Completed); + } + + transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}") + .Should().Equal( + "Envelope:pas_annexprocessing_reinstatepolicy", + "Envelope:bst_integration_transferannex", + "MicroserviceConsumer:pas_operations_perform", + "Envelope:pas_polreg_convertapltopol", + "Envelope:pas_annexprocessing_generatepolicyno", + "Envelope:pas_get_policy_product_info"); + } + + [Test] + public async Task AssistantPolicyCancellation_WhenPolicyExistsOnIpal_ShouldCompleteThroughRealOracleStore() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var transports = CreateAssistantPolicyCancellationTransports(); + + using var provider = CreateProvider(queueSet, transports); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "AssistantPolicyCancellation", + Payload = new Dictionary + { + ["srPolicyId"] = 97001L, + ["srAnnexId"] = 97002L, + ["policyNo"] = "POL-97001", + ["cancellationDate"] = "2026-03-14", + ["cancellationReason"] = "TEST", + ["policyExistsOnIPAL"] = true, + }, + }); + + 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.Status.Should().Be(WorkflowInstanceStatuses.Completed); + instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + openTasks.Tasks.Should().BeEmpty(); + transports.LegacyRabbit.Invocations.Select(x => $"{x.Mode}:{x.Command}") + .Should().Equal( + "Envelope:pas_annexprocessing_cancelpolicy", + "Envelope:pas_polannexes_get", + "Envelope:pas_annexprocessing_cancelannex", + "MicroserviceConsumer:pas_premium_calculate_for_object", + "Envelope:bst_policy_cancel2"); + } + + private static WorkflowTransportScripts CreateOpenForChangePolicyTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Respond("pas_get_policy_product_info", new { productCode = "4704", lob = "MOT", contractType = "STANDARD" }) + .Respond("pas_annexprocessing_alterpolicy", new + { + srPolicyId = 91001L, + srAnnexId = 91002L, + lob = "MOT", + contractType = "STANDARD", + previouslyOpened = false, + }) + .Respond("pas_polannexes_get", new + { + shortDescription = "Review casco change", + policyNo = "CASCO-91001", + }) + .Respond("pas_operations_perform", new { nextStep = "CONTINUE" }, WorkflowLegacyRabbitMode.MicroserviceConsumer) + .Respond("pas_annexprocessing_convertannex", new { ok = true }) + .Respond("bst_integration_transferannex", new { responseStatus = "APPROVED" }); + + return transports; + } + + private static WorkflowTransportScripts CreateAssistantPrintInsisDocumentsTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Respond("bst_integration_print_batch_printout", new + { + policyNo = "CASCO-94011", + documentsStatus = new[] + { + new { srDocsId = 94020L, fileName = "policy.pdf" }, + }, + }) + .Respond("bst_integration_print_batch_printout_notif_params", new + { + skipSystemNotification = false, + note = "Documents are ready", + title = "Generated documents", + toEmails = new[] { "workflow@example.com" }, + }) + .Respond("notifications_send_private_note", new { ok = true }, WorkflowLegacyRabbitMode.MicroserviceConsumer) + .Respond("notifications_send_email", new { ok = true }, WorkflowLegacyRabbitMode.MicroserviceConsumer); + + return transports; + } + + 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; + } + + private static WorkflowTransportScripts CreateQuoteOrAplCancelReleaseFailureTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Fail("bst_blanknumbersrelease", "Blank number release failed.") + .Respond("pas_annexprocessing_cancelaplorqt", new { cancelled = true }); + + return transports; + } + + private static WorkflowTransportScripts CreateInsisIntegrationNewSuccessTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Respond("bst_integration_processsendpolicyrequest", new + { + policySubstatus = "APPROVED", + }) + .Respond("pas_operations_perform", new + { + Passed = true, + StageFailures = new Dictionary(StringComparer.OrdinalIgnoreCase), + ErrorsBypassRoles = Array.Empty(), + }, WorkflowLegacyRabbitMode.MicroserviceConsumer); + + return 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 WorkflowTransportScripts CreateInsisIntegrationNewTimeoutTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Timeout("bst_integration_processsendpolicyrequest"); + + return transports; + } + + private static WorkflowTransportScripts CreateQuotationConfirmCancelTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Respond("pas_polannexes_get", new + { + shortDescription = "Quote short description", + }) + .Respond("pas_annexprocessing_cancelaplorqt", new + { + cancelled = true, + }); + + return transports; + } + + private static WorkflowTransportScripts CreateQuotationConfirmConvertToApplicationTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Respond("pas_polannexes_get", new + { + shortDescription = "Quote for application conversion", + }) + .Respond("pas_polreg_checkuwrules", new + { + nextStep = "ConvertToApplication", + }) + .Respond("pas_polreg_convertqttoapldefault", new + { + converted = true, + }); + + return transports; + } + + 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; + } + + private static WorkflowTransportScripts CreateAssistantAddAnnexTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Respond("pas_annexprocessing_alterpolicy", new + { + srPolicyId = 93001L, + srAnnexId = 93002L, + lob = "MOT", + contractType = "STANDARD", + previouslyOpened = false, + }) + .Respond("pas_polclmparticipants_create", new { ok = true }) + .Respond("pas_premium_calculate_for_object", new { ok = true }, WorkflowLegacyRabbitMode.MicroserviceConsumer) + .Respond("pas_polannexes_get", new + { + shortDescription = "Add beneficiary", + policyNo = "CASCO-93001", + }); + + return transports; + } + + private static WorkflowTransportScripts CreateAnnexCancellationStandardTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Respond("pas_get_policy_product_info", new { productCode = "4704", lob = "MOT", contractType = "MASTER" }) + .Respond("pas_annexprocessing_cancelannex", new { ok = true }) + .Respond("pas_batch_policies_process_annex", new { ok = true }) + .Respond("bst_integration_transferannex", new { responseStatus = "OK" }); + + return transports; + } + + private static WorkflowTransportScripts CreateReviewPolicyOpenForChangeRetryTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Respond("pas_operations_perform", new { nextStep = "CONTINUE" }, WorkflowLegacyRabbitMode.MicroserviceConsumer) + .Respond("pas_annexprocessing_convertannex", new { ok = true }) + .Respond("bst_integration_transferannex", new { responseStatus = "RETRY" }); + + return transports; + } + + private static WorkflowTransportScripts CreateReviewPolicyOpenForChangeHealthRejectTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Respond("pas_annexprocessing_cancelannex", new { ok = true }) + .Respond("pas_health_manager.release_policy_lock", new { ok = true }, WorkflowLegacyRabbitMode.MicroserviceConsumer); + + return transports; + } + + private static WorkflowTransportScripts CreateOpenForChangePolicyRetryTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Respond("pas_get_policy_product_info", new { productCode = "4704", lob = "MOT", contractType = "STANDARD" }) + .Respond("pas_annexprocessing_alterpolicy", new + { + srPolicyId = 91101L, + srAnnexId = 91102L, + lob = "MOT", + contractType = "STANDARD", + previouslyOpened = false, + }) + .Respond("pas_polannexes_get", new + { + shortDescription = "Review casco change", + policyNo = "CASCO-91101", + }) + .Respond("pas_operations_perform", new { nextStep = "CONTINUE" }, WorkflowLegacyRabbitMode.MicroserviceConsumer) + .Respond("pas_annexprocessing_convertannex", new { ok = true }) + .Respond("bst_integration_transferannex", new { responseStatus = "RETRY" }); + + return transports; + } + + private static WorkflowTransportScripts CreateAnnexCancellationHealthTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Respond("pas_get_policy_product_info", new { productCode = "HLT01", lob = "HLT", contractType = "MASTER" }) + .Respond("pas_health_manager.acquire_policy_lock", new { ok = true }, WorkflowLegacyRabbitMode.MicroserviceConsumer) + .Respond("pas_health_manager.cancel_annex", new { ok = true }, WorkflowLegacyRabbitMode.MicroserviceConsumer) + .Respond("pas_health_manager.propagate_cancel_annex_to_members", new { ok = true }, WorkflowLegacyRabbitMode.MicroserviceConsumer) + .Respond("bst_integration_transferannex", new { responseStatus = "OK" }); + + return transports; + } + + private static WorkflowTransportScripts CreateAssistantPolicyReinstateTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Respond("pas_annexprocessing_reinstatepolicy", new { ok = true }) + .Respond("bst_integration_transferannex", new { responseStatus = "APPROVED" }) + .Respond("pas_operations_perform", new + { + Passed = true, + StageFailures = new Dictionary(), + ErrorsBypassRoles = Array.Empty(), + }, WorkflowLegacyRabbitMode.MicroserviceConsumer) + .Respond("pas_polreg_convertapltopol", new { Converted = true }) + .Respond("pas_annexprocessing_generatepolicyno", new { PolicyNo = "POL-98001" }) + .Respond("pas_get_policy_product_info", new + { + ProductCode = "4710", + Lob = "MTR", + ContractType = "STANDARD", + }); + + return transports; + } + + private static WorkflowTransportScripts CreateAssistantPolicyCancellationTransports() + { + var transports = new WorkflowTransportScripts(); + transports.LegacyRabbit + .Respond("pas_annexprocessing_cancelpolicy", new { ok = true }) + .Respond("pas_polannexes_get", new { shortDescription = "Cancellation" }) + .Respond("pas_annexprocessing_cancelannex", new { ok = true }) + .Respond("pas_premium_calculate_for_object", new { ok = true }, WorkflowLegacyRabbitMode.MicroserviceConsumer) + .Respond("bst_policy_cancel2", new { ok = true }); + + return transports; + } + + private static ServiceProvider CreateProvider( + OracleAqQueueSet queueSet, + WorkflowTransportScripts transports) + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = OracleAqIntegrationLifetime.Fixture.ConnectionString, + ["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", + ["GenericAssignmentPermissions:AdminRoles:0"] = "DBA", + ["GenericAssignmentPermissions:AdminRoles:1"] = "APR_APPL", + }) + .Build(); + + services.AddLogging(); + new BulstradWorkflowRegistrator().RegisterServices(services, configuration); + services.AddWorkflowEngineCoreServices(configuration); + services.AddWorkflowModule("transport.legacy-rabbit", "1.0.0"); + services.AddWorkflowOracleDataStore(configuration); + services.Replace(ServiceDescriptor.Scoped(_ => transports.LegacyRabbit)); + services.Replace(ServiceDescriptor.Scoped(_ => transports.Microservice)); + services.Replace(ServiceDescriptor.Scoped(_ => transports.Graphql)); + services.Replace(ServiceDescriptor.Scoped(_ => transports.Http)); + + var provider = services.BuildServiceProvider(); + ServiceProviderAccessor.Initialize(provider); + return provider; + } + + private static async Task DrainSignalsUntilIdleAsync(ServiceProvider provider, TimeSpan timeout) + { + var worker = provider.GetRequiredService(); + var timeoutAt = DateTime.UtcNow.Add(timeout); + var processedCount = 0; + var consecutiveEmptyPolls = 0; + + while (DateTime.UtcNow < timeoutAt && consecutiveEmptyPolls < 3) + { + if (await worker.RunOnceAsync("workflow-service", CancellationToken.None)) + { + processedCount++; + consecutiveEmptyPolls = 0; + continue; + } + + consecutiveEmptyPolls++; + } + + return processedCount; + } + + private static bool ReadBool(IDictionary state, string key) + { + return state[key] switch + { + bool boolean => boolean, + JsonElement jsonElement => jsonElement.Get(), + _ => throw new AssertionException($"Workflow state value '{key}' is not a boolean."), + }; + } + +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqIntegrationLifetime.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqIntegrationLifetime.cs new file mode 100644 index 000000000..b5c73623d --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqIntegrationLifetime.cs @@ -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(); + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqIntegrationSuiteFixture.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqIntegrationSuiteFixture.cs new file mode 100644 index 000000000..54ae78387 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqIntegrationSuiteFixture.cs @@ -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(); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceCapacityTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceCapacityTests.cs new file mode 100644 index 000000000..fab2e46f6 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceCapacityTests.cs @@ -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 + { + ["srPolicyId"] = 993999L, + }, + })); + await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync( + provider, + runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = warmupStart.WorkflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["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(); + 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 + { + ["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 + { + ["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(StringComparer.Ordinal) + { + ["workflowName"] = "OracleAqPerfSignalRoundTripWorkflow", + ["queueName"] = queueSet.SignalQueueName, + ["ladder"] = string.Join(",", concurrencyLadder), + ["workerCount"] = workerCount.ToString(), + }, + }); + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceLatencyTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceLatencyTests.cs new file mode 100644 index 000000000..2d2656f51 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceLatencyTests.cs @@ -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(); + var startLatencies = new ConcurrentBag(); + var signalPublishLatencies = new ConcurrentBag(); + var signalToCompletionLatencies = new ConcurrentBag(); + var signalToFirstCompletionLatencies = new ConcurrentBag(); + var drainToIdleOverhangLatencies = new ConcurrentBag(); + + 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 + { + ["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 + { + ["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 + { + ["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(StringComparer.Ordinal) + { + ["workflowName"] = "OracleAqPerfSignalRoundTripWorkflow", + ["queueName"] = queueSet.SignalQueueName, + ["workerCount"] = "1", + ["measurementKind"] = "serial-latency", + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceNightlyTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceNightlyTests.cs new file mode 100644 index 000000000..e5cfa199b --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceNightlyTests.cs @@ -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(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(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(); + 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 + { + ["srPolicyId"] = 999000L + index, + }, + })); + + return (Index: index, Response: response, StartedAtUtc: DateTime.UtcNow); + }); + + var raisedSignalsAt = new ConcurrentDictionary(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 + { + ["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(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(); + 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 + { + ["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 + { + ["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(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; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceSmokeTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceSmokeTests.cs new file mode 100644 index 000000000..918c76f22 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceSmokeTests.cs @@ -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(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(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(); + 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 + { + ["srPolicyId"] = 998000L + index, + }, + })); + stopwatch.Stop(); + startLatencies.Add(stopwatch.Elapsed); + return response; + }); + + var signalRaisedAt = new ConcurrentDictionary(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 + { + ["documentId"] = 889000L + Math.Abs(startResponse.WorkflowInstanceId.GetHashCode()), + }, + })); + return true; + }); + + var processedSignals = await OracleAqPerformanceTestSupport.DrainSignalsUntilIdleAsync(provider, TimeSpan.FromSeconds(45)); + var verificationMoments = new List(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(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(); + 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 + { + ["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(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; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceSoakTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceSoakTests.cs new file mode 100644 index 000000000..a87c0a522 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceSoakTests.cs @@ -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(); + 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 + { + ["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 + { + ["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(StringComparer.Ordinal) + { + ["workflowName"] = "OracleAqPerfSignalRoundTripWorkflow", + ["queueName"] = queueSet.SignalQueueName, + ["waveCount"] = waveCount.ToString(), + ["workflowsPerWave"] = workflowsPerWave.ToString(), + ["workerCount"] = workerCount.ToString(), + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceTestSupport.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceTestSupport.cs new file mode 100644 index 000000000..f515b9d39 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceTestSupport.cs @@ -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(); + services.AddWorkflowRegistration(); + 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(_ => transports.LegacyRabbit)); + services.Replace(ServiceDescriptor.Scoped(_ => transports.Microservice)); + services.Replace(ServiceDescriptor.Scoped(_ => transports.Graphql)); + services.Replace(ServiceDescriptor.Scoped(_ => transports.Http)); + + var provider = services.BuildServiceProvider(); + ServiceProviderAccessor.Initialize(provider); + return provider; + } + + public static async Task 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 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 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(); + 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 WithRuntimeServiceAsync( + IServiceProvider provider, + Func> action) + { + return await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(provider, action); + } + + public static async Task WithRuntimeServiceAsync( + IServiceProvider provider, + Func action) + { + await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync(provider, action); + } + + public static async Task> RunConcurrentAsync( + IEnumerable items, + int concurrency, + Func> action) + { + return await WorkflowEnginePerformanceSupport.RunConcurrentAsync(items, concurrency, action); + } + + public static async Task<(T Result, OraclePerformanceDelta OracleMetrics)> MeasureWithOracleMetricsAsync( + Func> 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 MeasureWithOracleMetricsAsync( + Func 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 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 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 + { + ["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 + { + 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 WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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( + "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 + { + 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 WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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 ReceiveLatencies { get; init; } + public required IReadOnlyCollection ProcessedCorrelations { get; init; } + } + + private static async Task 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(); + + var publishedAt = new Dictionary(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(messageCount); + var processedCorrelations = new HashSet(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, + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceThroughputTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceThroughputTests.cs new file mode 100644 index 000000000..9e1fc29b4 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqPerformanceThroughputTests.cs @@ -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 + { + ["srPolicyId"] = 991999L, + }, + })); + await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync( + provider, + runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = warmupResponse.WorkflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = 861999L, + }, + })); + await OracleAqPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(provider, TimeSpan.FromSeconds(20), workerCount: 2); + + var startedAtUtc = DateTime.UtcNow; + var endToEndLatencies = new ConcurrentBag(); + var startLatencies = new ConcurrentBag(); + var signalPublishLatencies = new ConcurrentBag(); + var signalToCompletionLatencies = new ConcurrentBag(); + + 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 + { + ["srPolicyId"] = 991000L + index, + }, + })); + startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc); + return (Index: index, Response: response, StartedAtUtc: operationStartedAtUtc); + }); + + var signalRaisedAtUtc = new ConcurrentDictionary(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 + { + ["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 + { + ["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!, + ["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!, + ["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!, + }, + BackendMetrics = oracleMetrics.ToBackendMetrics(), + ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(), + Metadata = new Dictionary(StringComparer.Ordinal) + { + ["workflowName"] = "OracleAqPerfSignalRoundTripWorkflow", + ["queueName"] = queueSet.SignalQueueName, + ["workerCount"] = workerCount.ToString(), + ["measurementKind"] = "steady-throughput", + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqRedisSignalDriverIntegrationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqRedisSignalDriverIntegrationTests.cs new file mode 100644 index 000000000..861210f3f --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqRedisSignalDriverIntegrationTests.cs @@ -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(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleRedisRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 940001L, + }, + }); + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + Task receiveTask; + using (var workerScope = provider.CreateScope()) + { + var worker = workerScope.ServiceProvider.GetRequiredService(); + 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(); + var raiseResponse = await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = workflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = 778001L, + }, + }); + + raiseResponse.Queued.Should().BeTrue(); + + var processed = await receiveTask; + processed.Should().BeTrue(); + } + + using var verifyScope = provider.CreateScope(); + var verifyRuntimeService = verifyScope.ServiceProvider.GetRequiredService(); + 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 + { + ["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(); + 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(), + _ => 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 + { + public const string WorkflowNameValue = "OracleRedisRecoveryWorkflow"; + + public string WorkflowName => WorkflowNameValue; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Oracle Redis Recovery Workflow"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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( + "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(); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqRuntimeIntegrationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqRuntimeIntegrationTests.cs new file mode 100644 index 000000000..3c0df0b9c --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqRuntimeIntegrationTests.cs @@ -0,0 +1,2027 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +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.Signaling.OracleAq; +using StellaOps.Workflow.Contracts; +using StellaOps.Workflow.Engine.Constants; +using StellaOps.Workflow.Engine.Execution; +using StellaOps.Workflow.Engine.Scheduling; +using StellaOps.Workflow.Engine.Signaling; +using StellaOps.Workflow.Engine.HostedServices; +using StellaOps.Workflow.Engine.Services; + +using WorkflowSignalEnvelopeSerializer = StellaOps.Workflow.Signaling.OracleAq.WorkflowSignalEnvelopeSerializer; + +using FluentAssertions; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using NUnit.Framework; + +namespace StellaOps.Workflow.DataStore.Oracle.Tests; + +[TestFixture] +[Category("Integration")] +[NonParallelizable] +public class OracleAqRuntimeIntegrationTests +{ + [Test] + public async Task OracleAqTransport_WhenImmediateMessageEnqueued_ShouldBecomeReceivable() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + using var provider = CreateProvider(queueSet); + + var transport = provider.GetRequiredService(); + var payload = JsonSerializer.SerializeToUtf8Bytes(new { message = "immediate" }); + + await transport.EnqueueAsync(new OracleAqEnqueueRequest + { + QueueName = queueSet.SignalQueueName, + Payload = payload, + Correlation = $"immediate-{Guid.NewGuid():N}", + DelaySeconds = 0, + ExceptionQueueName = queueSet.DeadLetterQueueName, + }); + + await using var lease = await transport.DequeueAsync(new OracleAqDequeueRequest + { + QueueName = queueSet.SignalQueueName, + WaitSeconds = 1, + }); + + lease.Should().NotBeNull(); + lease!.Message.Payload.Should().Equal(payload); + await lease.CommitAsync(CancellationToken.None); + } + + [Test] + public async Task OracleAqTransport_WhenDelayedMessageEnqueued_ShouldBecomeReceivableAfterDelay() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + using var provider = CreateProvider(queueSet); + + var transport = provider.GetRequiredService(); + var payload = JsonSerializer.SerializeToUtf8Bytes(new { message = "delayed" }); + + await transport.EnqueueAsync(new OracleAqEnqueueRequest + { + QueueName = queueSet.SignalQueueName, + Payload = payload, + Correlation = $"delay-{Guid.NewGuid():N}", + DelaySeconds = 2, + ExceptionQueueName = queueSet.DeadLetterQueueName, + }); + + await using var lease = await WaitForLeaseAsync( + () => transport.DequeueAsync( + new OracleAqDequeueRequest + { + QueueName = queueSet.SignalQueueName, + WaitSeconds = 1, + }), + TimeSpan.FromSeconds(45)); + + lease.Should().NotBeNull(); + lease!.Message.Payload.Should().Equal(payload); + await lease.CommitAsync(CancellationToken.None); + } + + [Test] + public async Task OracleAqTransport_WhenDelayedBacklogEnqueued_ShouldDrainWithinLatencyEnvelope() + { + const int messageCount = 6; + + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + using var provider = CreateProvider(queueSet); + + var transport = provider.GetRequiredService(); + var correlations = Enumerable.Range(0, messageCount) + .Select(index => $"delay-backlog-{index}-{Guid.NewGuid():N}") + .ToList(); + + foreach (var correlation in correlations) + { + await transport.EnqueueAsync(new OracleAqEnqueueRequest + { + QueueName = queueSet.SignalQueueName, + Payload = JsonSerializer.SerializeToUtf8Bytes(new { correlation }), + Correlation = correlation, + DelaySeconds = 2, + ExceptionQueueName = queueSet.DeadLetterQueueName, + }); + } + + var stopwatch = Stopwatch.StartNew(); + TimeSpan? firstReceiptAt = null; + var receivedCorrelations = new HashSet(StringComparer.Ordinal); + var timeoutAt = DateTime.UtcNow.AddSeconds(75); + + while (receivedCorrelations.Count < correlations.Count && DateTime.UtcNow < timeoutAt) + { + await using var lease = await transport.DequeueAsync(new OracleAqDequeueRequest + { + QueueName = queueSet.SignalQueueName, + WaitSeconds = 1, + }); + if (lease is null) + { + continue; + } + + firstReceiptAt ??= stopwatch.Elapsed; + lease.Message.Correlation.Should().NotBeNullOrWhiteSpace(); + receivedCorrelations.Add(lease.Message.Correlation!); + await lease.CommitAsync(CancellationToken.None); + } + + receivedCorrelations.Should().BeEquivalentTo(correlations); + firstReceiptAt.Should().NotBeNull(); + firstReceiptAt!.Value.Should().BeGreaterThan(TimeSpan.FromSeconds(1)); + stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(75)); + } + + [Test] + public async Task OracleAqTransport_WhenLeaseRolledBack_ShouldRedeliverMessage() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + using var provider = CreateProvider(queueSet); + + var transport = provider.GetRequiredService(); + var correlation = $"rollback-{Guid.NewGuid():N}"; + var payload = JsonSerializer.SerializeToUtf8Bytes(new { message = "rollback" }); + + await transport.EnqueueAsync(new OracleAqEnqueueRequest + { + QueueName = queueSet.SignalQueueName, + Payload = payload, + Correlation = correlation, + DelaySeconds = 0, + ExceptionQueueName = queueSet.DeadLetterQueueName, + }); + + await using (var lease = await transport.DequeueAsync(new OracleAqDequeueRequest + { + QueueName = queueSet.SignalQueueName, + WaitSeconds = 1, + })) + { + lease.Should().NotBeNull(); + lease!.Message.Correlation.Should().Be(correlation); + await lease.RollbackAsync(CancellationToken.None); + } + + await using var redeliveredLease = await WaitForLeaseAsync( + () => transport.DequeueAsync( + new OracleAqDequeueRequest + { + QueueName = queueSet.SignalQueueName, + WaitSeconds = 1, + }), + TimeSpan.FromSeconds(10)); + + redeliveredLease.Should().NotBeNull(); + redeliveredLease!.Message.Correlation.Should().Be(correlation); + redeliveredLease.Message.Payload.Should().Equal(payload); + await redeliveredLease.CommitAsync(CancellationToken.None); + } + + [Test] + public async Task OracleAqTransport_WhenLeaseDeadLettered_ShouldMoveMessageToDeadLetterQueue() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + using var provider = CreateProvider(queueSet); + + var transport = provider.GetRequiredService(); + var correlation = $"deadletter-{Guid.NewGuid():N}"; + var payload = JsonSerializer.SerializeToUtf8Bytes(new { message = "deadletter" }); + + await transport.EnqueueAsync(new OracleAqEnqueueRequest + { + QueueName = queueSet.SignalQueueName, + Payload = payload, + Correlation = correlation, + DelaySeconds = 0, + ExceptionQueueName = queueSet.DeadLetterQueueName, + }); + + await using (var lease = await transport.DequeueAsync(new OracleAqDequeueRequest + { + QueueName = queueSet.SignalQueueName, + WaitSeconds = 1, + })) + { + lease.Should().NotBeNull(); + await lease!.DeadLetterAsync(queueSet.DeadLetterQueueName, CancellationToken.None); + } + + var signalMessages = await transport.BrowseAsync(new OracleAqBrowseRequest + { + QueueName = queueSet.SignalQueueName, + MaxMessages = 10, + }); + var deadLetterMessages = await transport.BrowseAsync(new OracleAqBrowseRequest + { + QueueName = queueSet.DeadLetterQueueName, + Correlation = correlation, + MaxMessages = 10, + }); + + signalMessages.Should().BeEmpty(); + deadLetterMessages.Should().ContainSingle(); + deadLetterMessages.Single().Payload.Should().Equal(payload); + } + + [Test] + public async Task TimerWorkflow_WhenProviderRestarts_ShouldResumeThroughRealOracleAq() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet)) + { + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleTimerRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 930001L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + + var startedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + + startedInstance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + startedInstance.Instance.RuntimeStatus.Should().Be("WaitingForSignal"); + ReadString(startedInstance.WorkflowState["phase"]).Should().Be("waiting"); + ReadLong(startedInstance.RuntimeState!.State["version"]).Should().Be(1L); + } + + await Task.Delay(TimeSpan.FromSeconds(5)); + + using (var provider = CreateProvider(queueSet)) + { + var worker = provider.GetRequiredService(); + var runtimeService = provider.GetRequiredService(); + + var processed = await WaitForProcessedSignalAsync( + () => worker.RunOnceAsync("workflow-service", CancellationToken.None), + TimeSpan.FromSeconds(45)); + processed.Should().BeTrue(); + + var openTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = "Open", + }); + var openTask = openTasks.Tasks.Should().ContainSingle().Subject; + var resumedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + + openTask.TaskName.Should().Be("Oracle Timer Review"); + ReadString(openTask.Payload["phase"]).Should().Be("after-timer"); + ReadBool(openTask.Payload["timerFired"]).Should().BeTrue(); + resumedInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadLong(resumedInstance.RuntimeState!.State["version"]).Should().Be(2L); + } + } + + [Test] + public async Task TimerWorkflow_WhenOracleContainerRestartsBeforeDue_ShouldResumeAfterDatabaseComesBack() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet)) + { + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleTimerRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 9300011L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + await OracleAqIntegrationLifetime.Fixture.RestartAsync(); + + using var resumedProvider = CreateProvider(queueSet); + var resumedWorker = resumedProvider.GetRequiredService(); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + + var processed = await WaitForProcessedSignalAsync( + () => resumedWorker.RunOnceAsync("workflow-service", CancellationToken.None), + TimeSpan.FromSeconds(60)); + processed.Should().BeTrue(); + + var openTasks = await resumedRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var resumedInstance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + + openTasks.Tasks.Should().ContainSingle(); + openTasks.Tasks.Single().TaskName.Should().Be("Oracle Timer Review"); + resumedInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadLong(resumedInstance.RuntimeState!.State["version"]).Should().Be(2L); + } + + [Test] + public async Task TimerWorkflows_WhenBacklogIsScheduledBeforeProviderRestart_ShouldDrainAfterRestartWithoutDuplicates() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var workflowInstanceIds = new List(); + const int workflowCount = 6; + + using (var provider = CreateProvider(queueSet)) + { + var runtimeService = provider.GetRequiredService(); + + for (var index = 0; index < workflowCount; index++) + { + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleTimerRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 930050L + index, + }, + }); + + workflowInstanceIds.Add(startResponse.WorkflowInstanceId); + } + } + + await Task.Delay(TimeSpan.FromSeconds(5)); + + using var replayProvider = CreateProvider(queueSet); + var replayRuntimeService = replayProvider.GetRequiredService(); + var replayWorker = replayProvider.GetRequiredService(); + var replayTelemetryService = replayProvider.GetRequiredService(); + + var processedSignals = 0; + var timeoutAt = DateTime.UtcNow.AddSeconds(60); + while (processedSignals < workflowInstanceIds.Count && DateTime.UtcNow < timeoutAt) + { + if (await replayWorker.RunOnceAsync("workflow-service", CancellationToken.None)) + { + processedSignals++; + } + } + + processedSignals.Should().Be(workflowInstanceIds.Count); + replayTelemetryService.GetStats().Stats.ProcessedCount.Should().Be(workflowCount); + replayTelemetryService.GetStats().Stats.FailureCount.Should().Be(0); + replayTelemetryService.GetStats().Stats.DeadLetterCount.Should().Be(0); + replayTelemetryService.GetStats().Stats.SignalsByType.Should().ContainSingle(x => + x.SignalType == WorkflowSignalTypes.TimerDue + && x.ProcessedCount == workflowCount + && x.FailureCount == 0 + && x.DeadLetterCount == 0); + + foreach (var workflowInstanceId in workflowInstanceIds) + { + var openTasks = await replayRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var resumedInstance = await replayRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + + openTasks.Tasks.Should().ContainSingle(); + openTasks.Tasks.Single().TaskName.Should().Be("Oracle Timer Review"); + ReadString(openTasks.Tasks.Single().Payload["phase"]).Should().Be("after-timer"); + ReadBool(openTasks.Tasks.Single().Payload["timerFired"]).Should().BeTrue(); + resumedInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadLong(resumedInstance.RuntimeState!.State["version"]).Should().Be(2L); + } + } + + [Test] + public async Task TimerWorkflows_WhenOracleContainerRestartsBeforeDueWithScheduledBacklog_ShouldDrainAfterDatabaseRecoveryWithoutDuplicates() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var workflowInstanceIds = new List(); + const int workflowCount = 4; + + using (var provider = CreateProvider(queueSet)) + { + var runtimeService = provider.GetRequiredService(); + + for (var index = 0; index < workflowCount; index++) + { + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleTimerRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 930080L + index, + }, + }); + + workflowInstanceIds.Add(startResponse.WorkflowInstanceId); + } + } + + await OracleAqIntegrationLifetime.Fixture.RestartAsync(); + + using var resumedProvider = CreateProvider(queueSet); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + var resumedWorker = resumedProvider.GetRequiredService(); + var resumedTelemetryService = resumedProvider.GetRequiredService(); + + var processedSignals = 0; + var timeoutAt = DateTime.UtcNow.AddSeconds(75); + while (processedSignals < workflowInstanceIds.Count && DateTime.UtcNow < timeoutAt) + { + if (await resumedWorker.RunOnceAsync("workflow-service", CancellationToken.None)) + { + processedSignals++; + } + } + + processedSignals.Should().Be(workflowInstanceIds.Count); + resumedTelemetryService.GetStats().Stats.ProcessedCount.Should().Be(workflowCount); + resumedTelemetryService.GetStats().Stats.FailureCount.Should().Be(0); + resumedTelemetryService.GetStats().Stats.DeadLetterCount.Should().Be(0); + resumedTelemetryService.GetStats().Stats.SignalsByType.Should().ContainSingle(x => + x.SignalType == WorkflowSignalTypes.TimerDue + && x.ProcessedCount == workflowCount + && x.FailureCount == 0 + && x.DeadLetterCount == 0); + + foreach (var workflowInstanceId in workflowInstanceIds) + { + var openTasks = await resumedRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var resumedInstance = await resumedRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + + openTasks.Tasks.Should().ContainSingle(); + openTasks.Tasks.Single().TaskName.Should().Be("Oracle Timer Review"); + ReadString(openTasks.Tasks.Single().Payload["phase"]).Should().Be("after-timer"); + ReadBool(openTasks.Tasks.Single().Payload["timerFired"]).Should().BeTrue(); + resumedInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadLong(resumedInstance.RuntimeState!.State["version"]).Should().Be(2L); + } + } + + [Test] + public async Task ExternalSignalWorkflow_WhenRaisedAfterProviderRestart_ShouldResumeThroughRealOracleAq() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet)) + { + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleExternalSignalRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 930002L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using (var provider = CreateProvider(queueSet)) + { + var runtimeService = provider.GetRequiredService(); + var worker = provider.GetRequiredService(); + + var raiseResponse = await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = workflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = 777001L, + }, + }); + + 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 = "Open", + }); + var openTask = openTasks.Tasks.Should().ContainSingle().Subject; + var resumedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + + openTask.TaskName.Should().Be("Oracle External Review"); + ReadString(openTask.Payload["phase"]).Should().Be("after-external"); + ReadLong(openTask.Payload["documentId"]).Should().Be(777001L); + resumedInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadLong(resumedInstance.RuntimeState!.State["version"]).Should().Be(2L); + } + } + + [Test] + public async Task ExternalSignalWorkflow_WhenDuplicateSignalDelivered_ShouldAdvanceOnlyOnceThroughRealOracleAq() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + string workflowInstanceId; + + using (var provider = CreateProvider(queueSet)) + { + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleExternalSignalRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 930003L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using (var provider = CreateProvider(queueSet)) + { + var runtimeService = provider.GetRequiredService(); + var worker = provider.GetRequiredService(); + + var firstRaise = await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = workflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = 777101L, + }, + }); + var secondRaise = await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = workflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = 777101L, + }, + }); + + firstRaise.Queued.Should().BeTrue(); + secondRaise.Queued.Should().BeTrue(); + + var firstProcessed = await WaitForProcessedSignalAsync( + () => worker.RunOnceAsync("workflow-service", CancellationToken.None), + TimeSpan.FromSeconds(45)); + var secondProcessed = await WaitForProcessedSignalAsync( + () => worker.RunOnceAsync("workflow-service", CancellationToken.None), + TimeSpan.FromSeconds(15)); + + firstProcessed.Should().BeTrue(); + secondProcessed.Should().BeTrue(); + + var openTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = "Open", + }); + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + + openTasks.Tasks.Should().ContainSingle(); + openTasks.Tasks.Single().TaskName.Should().Be("Oracle External Review"); + ReadLong(openTasks.Tasks.Single().Payload["documentId"]).Should().Be(777101L); + instance.Tasks.Should().ContainSingle(x => x.TaskName == "Oracle External Review"); + instance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadLong(instance.RuntimeState!.State["version"]).Should().Be(2L); + } + } + + [Test] + public async Task ExternalSignalWorkflow_WhenConcurrentResumesRace_ShouldCommitOnlyOneProjectionMutation() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + using var provider = CreateProvider( + queueSet, + configureServices: services => + { + services.AddSingleton(new ResumeConcurrencyGate(requiredParticipants: 2)); + services.AddScoped(); + services.Replace(ServiceDescriptor.Scoped(serviceProvider => + new BarrierWorkflowRuntimeOrchestrator( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService()))); + }); + + string workflowInstanceId; + string waitingToken; + using (var scope = provider.CreateScope()) + { + var runtimeService = scope.ServiceProvider.GetRequiredService(); + var runtimeStateStore = scope.ServiceProvider.GetRequiredService(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleExternalSignalRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 930004L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + var runtimeState = await runtimeStateStore.GetAsync(workflowInstanceId, CancellationToken.None); + waitingToken = ReadWaitingToken(runtimeState!.StateJson); + } + + var signalA = new WorkflowSignalEnvelope + { + SignalId = $"sig-race-a-{Guid.NewGuid():N}", + WorkflowInstanceId = workflowInstanceId, + RuntimeProvider = WorkflowRuntimeProviderNames.Engine, + SignalType = WorkflowSignalTypes.ExternalSignal, + ExpectedVersion = 1, + WaitingToken = waitingToken, + Payload = new Dictionary + { + [WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey] = JsonSerializer.SerializeToElement("documents-uploaded"), + ["documentId"] = JsonSerializer.SerializeToElement(777201L), + }, + }; + var signalB = signalA with + { + SignalId = $"sig-race-b-{Guid.NewGuid():N}", + }; + + await Task.WhenAll( + ResumeAsync(provider, signalA), + ResumeAsync(provider, signalB)); + + using var verificationScope = provider.CreateScope(); + var verificationRuntimeService = verificationScope.ServiceProvider.GetRequiredService(); + var dbContext = verificationScope.ServiceProvider.GetRequiredService(); + + var openTasks = await verificationRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var instance = await verificationRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + + openTasks.Tasks.Should().ContainSingle(); + openTasks.Tasks.Single().TaskName.Should().Be("Oracle External Review"); + ReadLong(openTasks.Tasks.Single().Payload["documentId"]).Should().Be(777201L); + instance.Tasks.Should().ContainSingle(); + instance.TaskEvents.Should().ContainSingle(x => x.EventType == WorkflowTaskEventTypes.Created); + instance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadLong(instance.RuntimeState!.State["version"]).Should().Be(2L); + + var projectedTaskIds = await dbContext.WorkflowTasks + .Where(x => x.WorkflowInstanceId == workflowInstanceId) + .Select(x => x.WorkflowTaskId) + .ToArrayAsync(); + + projectedTaskIds.Should().ContainSingle(); + (await dbContext.WorkflowTaskEvents.CountAsync(x => projectedTaskIds.Contains(x.WorkflowTaskId))).Should().Be(1); + } + + [Test] + public async Task TimerWorkflow_WhenScheduledEnqueueFailsInsideMutation_ShouldRollbackOracleStateAndQueues() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + + using (var provider = CreateProvider( + queueSet, + configureServices: services => RegisterThrowAfterFirstScheduleBus(services))) + { + var runtimeService = provider.GetRequiredService(); + + var act = async () => await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleTimerRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 930005L, + }, + }); + + await act.Should().ThrowAsync() + .WithMessage("Synthetic AQ enqueue failure."); + } + + using var verificationProvider = CreateProvider(queueSet); + using var verificationScope = verificationProvider.CreateScope(); + var verificationDbContext = verificationScope.ServiceProvider.GetRequiredService(); + var verificationTransport = verificationScope.ServiceProvider.GetRequiredService(); + + (await verificationDbContext.WorkflowInstances.CountAsync()).Should().Be(0); + (await verificationDbContext.WorkflowTasks.CountAsync()).Should().Be(0); + (await verificationDbContext.WorkflowTaskEvents.CountAsync()).Should().Be(0); + (await verificationDbContext.WorkflowRuntimeStates.CountAsync()).Should().Be(0); + + var liveMessages = await verificationTransport.BrowseAsync(new OracleAqBrowseRequest + { + QueueName = queueSet.SignalQueueName, + MaxMessages = 10, + }); + var deadLetterMessages = await verificationTransport.BrowseAsync(new OracleAqBrowseRequest + { + QueueName = queueSet.DeadLetterQueueName, + MaxMessages = 10, + }); + + liveMessages.Should().BeEmpty(); + deadLetterMessages.Should().BeEmpty(); + } + + [Test] + public async Task ContinuationWorkflow_WhenSignalEnqueueFailsInsideMutation_ShouldRollbackOracleStateAndQueues() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + + using (var provider = CreateProvider( + queueSet, + configureServices: services => + { + services.AddWorkflowRegistration(); + services.Replace(ServiceDescriptor.Scoped()); + RegisterThrowAfterFirstSignalBus(services); + })) + { + var runtimeService = provider.GetRequiredService(); + + var act = async () => await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleContinuationDispatchWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 930006L, + }, + }); + + await act.Should().ThrowAsync() + .WithMessage("Synthetic AQ enqueue failure."); + } + + using var verificationProvider = CreateProvider(queueSet); + using var verificationScope = verificationProvider.CreateScope(); + var verificationDbContext = verificationScope.ServiceProvider.GetRequiredService(); + var verificationTransport = verificationScope.ServiceProvider.GetRequiredService(); + + (await verificationDbContext.WorkflowInstances.CountAsync()).Should().Be(0); + (await verificationDbContext.WorkflowTasks.CountAsync()).Should().Be(0); + (await verificationDbContext.WorkflowTaskEvents.CountAsync()).Should().Be(0); + (await verificationDbContext.WorkflowRuntimeStates.CountAsync()).Should().Be(0); + + var liveMessages = await verificationTransport.BrowseAsync(new OracleAqBrowseRequest + { + QueueName = queueSet.SignalQueueName, + MaxMessages = 10, + }); + var deadLetterMessages = await verificationTransport.BrowseAsync(new OracleAqBrowseRequest + { + QueueName = queueSet.DeadLetterQueueName, + MaxMessages = 10, + }); + + liveMessages.Should().BeEmpty(); + deadLetterMessages.Should().BeEmpty(); + } + + [Test] + public async Task DeadLetterReplay_WhenReadableEnvelopeExists_ShouldReplayBackToSignalQueue() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + using var provider = CreateProvider(queueSet); + + var serializer = provider.GetRequiredService(); + var transport = provider.GetRequiredService(); + var signalBus = provider.GetRequiredService(); + var deadLetterService = provider.GetRequiredService(); + var envelope = new WorkflowSignalEnvelope + { + SignalId = $"sig-{Guid.NewGuid():N}", + WorkflowInstanceId = "wf-dead-letter", + RuntimeProvider = WorkflowRuntimeProviderNames.Engine, + SignalType = WorkflowSignalTypes.ExternalSignal, + ExpectedVersion = 4, + Payload = new Dictionary + { + ["signalName"] = JsonSerializer.SerializeToElement("documents-uploaded"), + ["documentId"] = JsonSerializer.SerializeToElement(12345L), + }, + }; + + await transport.EnqueueAsync(new OracleAqEnqueueRequest + { + QueueName = queueSet.DeadLetterQueueName, + Payload = serializer.Serialize(envelope), + Correlation = envelope.SignalId, + ExceptionQueueName = queueSet.DeadLetterQueueName, + }); + + var messages = await deadLetterService.GetMessagesAsync(new WorkflowSignalDeadLettersGetRequest + { + MaxMessages = 10, + }); + messages.Messages.Should().ContainSingle(x => x.SignalId == envelope.SignalId); + + var replay = await deadLetterService.ReplayAsync(new WorkflowSignalDeadLetterReplayRequest + { + SignalId = envelope.SignalId, + }); + replay.Replayed.Should().BeTrue(); + + await using var lease = await signalBus.ReceiveAsync("workflow-service", CancellationToken.None); + lease.Should().NotBeNull(); + lease!.Envelope.SignalId.Should().Be(envelope.SignalId); + lease.Envelope.WorkflowInstanceId.Should().Be(envelope.WorkflowInstanceId); + await lease.CompleteAsync(CancellationToken.None); + } + + [Test] + public async Task DeadLetterReplay_WhenBacklogExistsAcrossProviderRestart_ShouldReplayAndDrainAllSignals() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var workflowSignals = new List<(string WorkflowInstanceId, string SignalId, long DocumentId)>(); + + using (var provider = CreateProvider( + queueSet, + maxDeliveryAttempts: 1, + configureServices: services => + { + services.Replace(ServiceDescriptor.Scoped()); + })) + { + var runtimeService = provider.GetRequiredService(); + var worker = provider.GetRequiredService(); + var deadLetterService = provider.GetRequiredService(); + var telemetryService = provider.GetRequiredService(); + + for (var index = 0; index < 3; index++) + { + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleExternalSignalRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 931000L + index, + }, + }); + var raiseResponse = await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = 778000L + index, + }, + }); + + workflowSignals.Add((startResponse.WorkflowInstanceId, raiseResponse.SignalId, 778000L + index)); + } + + var deadLetteredCount = 0; + var timeoutAt = DateTime.UtcNow.AddSeconds(30); + while (deadLetteredCount < workflowSignals.Count && DateTime.UtcNow < timeoutAt) + { + if (await worker.RunOnceAsync("workflow-service", CancellationToken.None)) + { + deadLetteredCount++; + } + } + + deadLetteredCount.Should().Be(workflowSignals.Count); + var deadLetters = await deadLetterService.GetMessagesAsync(new WorkflowSignalDeadLettersGetRequest + { + MaxMessages = 10, + }); + deadLetters.Messages.Should().HaveCount(workflowSignals.Count); + telemetryService.GetStats().Stats.FailureCount.Should().Be(0); + telemetryService.GetStats().Stats.DeadLetterCount.Should().Be(workflowSignals.Count); + telemetryService.GetStats().Stats.SignalsByType.Should().ContainSingle(x => + x.SignalType == WorkflowSignalTypes.ExternalSignal + && x.FailureCount == 0 + && x.DeadLetterCount == workflowSignals.Count); + } + + using var replayProvider = CreateProvider(queueSet); + var replayDeadLetterService = replayProvider.GetRequiredService(); + var replayWorker = replayProvider.GetRequiredService(); + var replayRuntimeService = replayProvider.GetRequiredService(); + var replayTelemetryService = replayProvider.GetRequiredService(); + + foreach (var signal in workflowSignals) + { + var replay = await replayDeadLetterService.ReplayAsync(new WorkflowSignalDeadLetterReplayRequest + { + SignalId = signal.SignalId, + }); + replay.Replayed.Should().BeTrue(); + } + + var remainingDeadLetters = await replayDeadLetterService.GetMessagesAsync(new WorkflowSignalDeadLettersGetRequest + { + MaxMessages = 10, + }); + remainingDeadLetters.Messages.Should().BeEmpty(); + + var processedCount = 0; + var drainTimeoutAt = DateTime.UtcNow.AddSeconds(30); + while (processedCount < workflowSignals.Count && DateTime.UtcNow < drainTimeoutAt) + { + if (await replayWorker.RunOnceAsync("workflow-service", CancellationToken.None)) + { + processedCount++; + } + } + + processedCount.Should().Be(workflowSignals.Count); + replayTelemetryService.GetStats().Stats.ProcessedCount.Should().Be(workflowSignals.Count); + replayTelemetryService.GetStats().Stats.FailureCount.Should().Be(0); + replayTelemetryService.GetStats().Stats.DeadLetterCount.Should().Be(0); + replayTelemetryService.GetStats().Stats.SignalsByType.Should().ContainSingle(x => + x.SignalType == WorkflowSignalTypes.ExternalSignal + && x.ProcessedCount == workflowSignals.Count + && x.FailureCount == 0 + && x.DeadLetterCount == 0); + + foreach (var signal in workflowSignals) + { + var openTasks = await replayRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = signal.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var instance = await replayRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = signal.WorkflowInstanceId, + }); + + openTasks.Tasks.Should().ContainSingle(); + openTasks.Tasks.Single().TaskName.Should().Be("Oracle External Review"); + ReadLong(openTasks.Tasks.Single().Payload["documentId"]).Should().Be(signal.DocumentId); + instance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadLong(instance.RuntimeState!.State["version"]).Should().Be(2L); + } + } + + [Test] + public async Task DeadLetterReplay_WhenBacklogExistsAcrossOracleRestart_ShouldReplayAndDrainAllSignals() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var workflowSignals = new List<(string WorkflowInstanceId, string SignalId, long DocumentId)>(); + + using (var provider = CreateProvider( + queueSet, + maxDeliveryAttempts: 1, + configureServices: services => + { + services.Replace(ServiceDescriptor.Scoped()); + })) + { + var runtimeService = provider.GetRequiredService(); + var worker = provider.GetRequiredService(); + var deadLetterService = provider.GetRequiredService(); + + for (var index = 0; index < 3; index++) + { + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleExternalSignalRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 932000L + index, + }, + }); + var raiseResponse = await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = 779000L + index, + }, + }); + + workflowSignals.Add((startResponse.WorkflowInstanceId, raiseResponse.SignalId, 779000L + index)); + } + + var deadLetteredCount = 0; + var timeoutAt = DateTime.UtcNow.AddSeconds(30); + while (deadLetteredCount < workflowSignals.Count && DateTime.UtcNow < timeoutAt) + { + if (await worker.RunOnceAsync("workflow-service", CancellationToken.None)) + { + deadLetteredCount++; + } + } + + deadLetteredCount.Should().Be(workflowSignals.Count); + var deadLetters = await deadLetterService.GetMessagesAsync(new WorkflowSignalDeadLettersGetRequest + { + MaxMessages = 10, + }); + deadLetters.Messages.Should().HaveCount(workflowSignals.Count); + } + + await OracleAqIntegrationLifetime.Fixture.RestartAsync(CancellationToken.None); + + using var replayProvider = CreateProvider(queueSet); + var replayDeadLetterService = replayProvider.GetRequiredService(); + var replayWorker = replayProvider.GetRequiredService(); + var replayRuntimeService = replayProvider.GetRequiredService(); + var replayTelemetryService = replayProvider.GetRequiredService(); + + var persistedDeadLetters = await replayDeadLetterService.GetMessagesAsync(new WorkflowSignalDeadLettersGetRequest + { + MaxMessages = 10, + }); + persistedDeadLetters.Messages.Should().HaveCount(workflowSignals.Count); + + foreach (var signal in workflowSignals) + { + var replay = await replayDeadLetterService.ReplayAsync(new WorkflowSignalDeadLetterReplayRequest + { + SignalId = signal.SignalId, + }); + replay.Replayed.Should().BeTrue(); + } + + var remainingDeadLetters = await replayDeadLetterService.GetMessagesAsync(new WorkflowSignalDeadLettersGetRequest + { + MaxMessages = 10, + }); + remainingDeadLetters.Messages.Should().BeEmpty(); + + var processedCount = 0; + var drainTimeoutAt = DateTime.UtcNow.AddSeconds(30); + while (processedCount < workflowSignals.Count && DateTime.UtcNow < drainTimeoutAt) + { + if (await replayWorker.RunOnceAsync("workflow-service", CancellationToken.None)) + { + processedCount++; + } + } + + processedCount.Should().Be(workflowSignals.Count); + replayTelemetryService.GetStats().Stats.ProcessedCount.Should().Be(workflowSignals.Count); + replayTelemetryService.GetStats().Stats.FailureCount.Should().Be(0); + replayTelemetryService.GetStats().Stats.DeadLetterCount.Should().Be(0); + replayTelemetryService.GetStats().Stats.SignalsByType.Should().ContainSingle(x => + x.SignalType == WorkflowSignalTypes.ExternalSignal + && x.ProcessedCount == workflowSignals.Count + && x.FailureCount == 0 + && x.DeadLetterCount == 0); + + foreach (var signal in workflowSignals) + { + var openTasks = await replayRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = signal.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var instance = await replayRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = signal.WorkflowInstanceId, + }); + + openTasks.Tasks.Should().ContainSingle(); + openTasks.Tasks.Single().TaskName.Should().Be("Oracle External Review"); + ReadLong(openTasks.Tasks.Single().Payload["documentId"]).Should().Be(signal.DocumentId); + instance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadLong(instance.RuntimeState!.State["version"]).Should().Be(2L); + } + } + + [Test] + public async Task WorkflowSignalPump_WhenProcessorKeepsFailing_ShouldDeadLetterSignalThroughRealOracleAq() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + using var provider = CreateProvider( + queueSet, + maxDeliveryAttempts: 1, + configureServices: services => + { + services.Replace(ServiceDescriptor.Scoped()); + }); + + var signalBus = provider.GetRequiredService(); + var worker = provider.GetRequiredService(); + var deadLetterService = provider.GetRequiredService(); + var transport = provider.GetRequiredService(); + var envelope = new WorkflowSignalEnvelope + { + SignalId = $"sig-poison-{Guid.NewGuid():N}", + WorkflowInstanceId = "wf-poison", + RuntimeProvider = WorkflowRuntimeProviderNames.Engine, + SignalType = WorkflowSignalTypes.ExternalSignal, + ExpectedVersion = 1, + Payload = new Dictionary + { + [WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey] = + JsonSerializer.SerializeToElement("documents-uploaded"), + }, + }; + + await signalBus.PublishAsync(envelope, CancellationToken.None); + + var deadLettered = await WaitForProcessedSignalAsync( + () => worker.RunOnceAsync("workflow-service", CancellationToken.None), + TimeSpan.FromSeconds(15)); + deadLettered.Should().BeTrue(); + + var liveMessages = await transport.BrowseAsync(new OracleAqBrowseRequest + { + QueueName = queueSet.SignalQueueName, + Correlation = envelope.SignalId, + MaxMessages = 10, + }); + var deadLetters = await deadLetterService.GetMessagesAsync(new WorkflowSignalDeadLettersGetRequest + { + MaxMessages = 10, + }); + + liveMessages.Should().BeEmpty(); + deadLetters.Messages.Should().ContainSingle(x => x.SignalId == envelope.SignalId); + } + + [Test] + public async Task ExternalSignalWorkflow_WhenFirstProviderAbandonsSignal_ShouldBeRecoveredBySecondProvider() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + string workflowInstanceId; + + using (var startProvider = CreateProvider(queueSet)) + { + var runtimeService = startProvider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleExternalSignalRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 930007L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using (var failingProvider = CreateProvider( + queueSet, + configureServices: services => + { + services.Replace(ServiceDescriptor.Scoped()); + })) + { + var runtimeService = failingProvider.GetRequiredService(); + var worker = failingProvider.GetRequiredService(); + var telemetryService = failingProvider.GetRequiredService(); + + var raiseResponse = await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = workflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = 777301L, + }, + }); + + raiseResponse.Queued.Should().BeTrue(); + + await FluentActions.Invoking(() => worker.RunOnceAsync("workflow-service-a", CancellationToken.None)) + .Should().ThrowAsync() + .WithMessage("Synthetic AQ processor failure."); + + telemetryService.GetStats().Stats.FailureCount.Should().Be(1); + telemetryService.GetStats().Stats.DeadLetterCount.Should().Be(0); + telemetryService.GetStats().Stats.SignalsByType.Should().ContainSingle(x => + x.SignalType == WorkflowSignalTypes.ExternalSignal + && x.FailureCount == 1 + && x.ProcessedCount == 0 + && x.DeadLetterCount == 0); + } + + using var recoveredProvider = CreateProvider(queueSet); + var recoveredWorker = recoveredProvider.GetRequiredService(); + var recoveredRuntimeService = recoveredProvider.GetRequiredService(); + var recoveredTelemetryService = recoveredProvider.GetRequiredService(); + + var processed = await WaitForProcessedSignalAsync( + () => recoveredWorker.RunOnceAsync("workflow-service-b", CancellationToken.None), + TimeSpan.FromSeconds(30)); + processed.Should().BeTrue(); + + var openTasks = await recoveredRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var instance = await recoveredRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + + openTasks.Tasks.Should().ContainSingle(); + openTasks.Tasks.Single().TaskName.Should().Be("Oracle External Review"); + ReadLong(openTasks.Tasks.Single().Payload["documentId"]).Should().Be(777301L); + instance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadLong(instance.RuntimeState!.State["version"]).Should().Be(2L); + recoveredTelemetryService.GetStats().Stats.ProcessedCount.Should().Be(1); + recoveredTelemetryService.GetStats().Stats.FailureCount.Should().Be(0); + recoveredTelemetryService.GetStats().Stats.DeadLetterCount.Should().Be(0); + recoveredTelemetryService.GetStats().Stats.SignalsByType.Should().ContainSingle(x => + x.SignalType == WorkflowSignalTypes.ExternalSignal + && x.ProcessedCount == 1 + && x.FailureCount == 0 + && x.DeadLetterCount == 0); + } + + [Test] + public async Task ExternalSignalWorkflows_WhenBacklogIsQueuedBeforeProviderRestart_ShouldDrainAfterRestartWithoutDuplicates() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + var expectedDocuments = new Dictionary(); + const int workflowCount = 8; + + using (var provider = CreateProvider(queueSet)) + { + var runtimeService = provider.GetRequiredService(); + + for (var index = 0; index < workflowCount; index++) + { + var srPolicyId = 930300L + index; + var documentId = 777600L + index; + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleExternalSignalRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = srPolicyId, + }, + }); + + expectedDocuments[startResponse.WorkflowInstanceId] = documentId; + + var raiseResponse = await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = documentId, + }, + }); + + raiseResponse.Queued.Should().BeTrue(); + } + } + + using var replayProvider = CreateProvider(queueSet); + var replayRuntimeService = replayProvider.GetRequiredService(); + var replayWorker = replayProvider.GetRequiredService(); + var replayTelemetryService = replayProvider.GetRequiredService(); + + var processedSignals = 0; + var timeoutAt = DateTime.UtcNow.AddSeconds(60); + while (processedSignals < expectedDocuments.Count && DateTime.UtcNow < timeoutAt) + { + if (await replayWorker.RunOnceAsync("workflow-service", CancellationToken.None)) + { + processedSignals++; + } + } + + processedSignals.Should().Be(expectedDocuments.Count); + replayTelemetryService.GetStats().Stats.ProcessedCount.Should().Be(workflowCount); + replayTelemetryService.GetStats().Stats.FailureCount.Should().Be(0); + replayTelemetryService.GetStats().Stats.DeadLetterCount.Should().Be(0); + replayTelemetryService.GetStats().Stats.SignalsByType.Should().ContainSingle(x => + x.SignalType == WorkflowSignalTypes.ExternalSignal + && x.ProcessedCount == workflowCount + && x.FailureCount == 0 + && x.DeadLetterCount == 0); + + foreach (var expectedDocument in expectedDocuments) + { + var instance = await replayRuntimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = expectedDocument.Key, + }); + var openTasks = await replayRuntimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = expectedDocument.Key, + Status = WorkflowTaskStatuses.Open, + }); + + openTasks.Tasks.Should().ContainSingle(); + openTasks.Tasks.Single().TaskName.Should().Be("Oracle External Review"); + ReadLong(openTasks.Tasks.Single().Payload["documentId"]).Should().Be(expectedDocument.Value); + instance.Tasks.Should().ContainSingle(); + instance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadLong(instance.RuntimeState!.State["version"]).Should().Be(2L); + } + } + + [Test] + public async Task ExternalSignalWorkflows_WhenBacklogOfQueuedResumesExists_ShouldDrainAllWithoutDuplicates() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + using var provider = CreateProvider(queueSet); + + var runtimeService = provider.GetRequiredService(); + var worker = provider.GetRequiredService(); + var telemetryService = provider.GetRequiredService(); + var workflowInstanceIds = new List(); + const int workflowCount = 12; + + for (var index = 0; index < workflowCount; index++) + { + var srPolicyId = 930100L + index; + var documentId = 777400L + index; + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = OracleExternalSignalRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = srPolicyId, + }, + }); + workflowInstanceIds.Add(startResponse.WorkflowInstanceId); + + var raiseResponse = await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = documentId, + }, + }); + + raiseResponse.Queued.Should().BeTrue(); + } + + var processedSignals = 0; + var timeoutAt = DateTime.UtcNow.AddSeconds(60); + while (processedSignals < workflowInstanceIds.Count && DateTime.UtcNow < timeoutAt) + { + if (await worker.RunOnceAsync("workflow-service", CancellationToken.None)) + { + processedSignals++; + } + } + + processedSignals.Should().Be(workflowInstanceIds.Count); + telemetryService.GetStats().Stats.ProcessedCount.Should().Be(workflowCount); + telemetryService.GetStats().Stats.FailureCount.Should().Be(0); + telemetryService.GetStats().Stats.DeadLetterCount.Should().Be(0); + telemetryService.GetStats().Stats.SignalsByType.Should().ContainSingle(x => + x.SignalType == WorkflowSignalTypes.ExternalSignal + && x.ProcessedCount == workflowCount + && x.FailureCount == 0 + && x.DeadLetterCount == 0); + + foreach (var (workflowInstanceId, index) in workflowInstanceIds.Select((id, i) => (id, i))) + { + var expectedDocumentId = 777400L + index; + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + }); + var openTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + + openTasks.Tasks.Should().ContainSingle(); + openTasks.Tasks.Single().TaskName.Should().Be("Oracle External Review"); + ReadLong(openTasks.Tasks.Single().Payload["documentId"]).Should().Be(expectedDocumentId); + instance.Tasks.Should().ContainSingle(); + instance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadLong(instance.RuntimeState!.State["version"]).Should().Be(2L); + } + } + + [Test] + public async Task OracleAqTransport_WhenAmbientOracleTransactionCommits_ShouldPublishVisibleMessage() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + using var provider = CreateProvider(queueSet); + + var dbContext = provider.GetRequiredService(); + var transport = provider.GetRequiredService(); + var correlation = $"ambient-commit-{Guid.NewGuid():N}"; + var payload = JsonSerializer.SerializeToUtf8Bytes(new { message = "ambient-commit" }); + + await using (var transaction = await dbContext.Database.BeginTransactionAsync()) + { + await transport.EnqueueAsync(new OracleAqEnqueueRequest + { + QueueName = queueSet.SignalQueueName, + Payload = payload, + Correlation = correlation, + DelaySeconds = 0, + ExceptionQueueName = queueSet.DeadLetterQueueName, + }); + + await transaction.CommitAsync(); + } + + await using var lease = await WaitForLeaseAsync( + () => transport.DequeueAsync(new OracleAqDequeueRequest + { + QueueName = queueSet.SignalQueueName, + WaitSeconds = 1, + }), + TimeSpan.FromSeconds(10)); + + lease.Should().NotBeNull(); + lease!.Message.Correlation.Should().Be(correlation); + lease.Message.Payload.Should().Equal(payload); + await lease.CommitAsync(CancellationToken.None); + } + + [Test] + public async Task OracleAqTransport_WhenAmbientOracleTransactionRollsBack_ShouldNotPublishVisibleMessage() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + using var provider = CreateProvider(queueSet); + + var dbContext = provider.GetRequiredService(); + var transport = provider.GetRequiredService(); + var correlation = $"ambient-rollback-{Guid.NewGuid():N}"; + + await using (var transaction = await dbContext.Database.BeginTransactionAsync()) + { + await transport.EnqueueAsync(new OracleAqEnqueueRequest + { + QueueName = queueSet.SignalQueueName, + Payload = JsonSerializer.SerializeToUtf8Bytes(new { message = "ambient-rollback" }), + Correlation = correlation, + DelaySeconds = 0, + ExceptionQueueName = queueSet.DeadLetterQueueName, + }); + + await transaction.RollbackAsync(); + } + + await using var lease = await transport.DequeueAsync(new OracleAqDequeueRequest + { + QueueName = queueSet.SignalQueueName, + Correlation = correlation, + WaitSeconds = 1, + }); + + lease.Should().BeNull(); + } + + [Test] + public async Task OracleAqTransport_WhenAmbientOracleTransactionCommitsDelayedMessage_ShouldPublishAfterDelay() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + using var provider = CreateProvider(queueSet); + + var dbContext = provider.GetRequiredService(); + var transport = provider.GetRequiredService(); + var correlation = $"ambient-delay-commit-{Guid.NewGuid():N}"; + var payload = JsonSerializer.SerializeToUtf8Bytes(new { message = "ambient-delay-commit" }); + + await using (var transaction = await dbContext.Database.BeginTransactionAsync()) + { + await transport.EnqueueAsync(new OracleAqEnqueueRequest + { + QueueName = queueSet.SignalQueueName, + Payload = payload, + Correlation = correlation, + DelaySeconds = 2, + ExceptionQueueName = queueSet.DeadLetterQueueName, + }); + + await transaction.CommitAsync(); + } + + await using var lease = await WaitForLeaseAsync( + () => transport.DequeueAsync(new OracleAqDequeueRequest + { + QueueName = queueSet.SignalQueueName, + Correlation = correlation, + WaitSeconds = 1, + }), + TimeSpan.FromSeconds(45)); + + lease.Should().NotBeNull(); + lease!.Message.Correlation.Should().Be(correlation); + lease.Message.Payload.Should().Equal(payload); + await lease.CommitAsync(CancellationToken.None); + } + + [Test] + public async Task OracleAqTransport_WhenAmbientOracleTransactionRollsBackDelayedMessage_ShouldNotPublishAfterDelay() + { + var queueSet = await OracleAqIntegrationLifetime.Fixture.CreateQueueSetAsync(); + using var provider = CreateProvider(queueSet); + + var dbContext = provider.GetRequiredService(); + var transport = provider.GetRequiredService(); + var correlation = $"ambient-delay-rollback-{Guid.NewGuid():N}"; + + await using (var transaction = await dbContext.Database.BeginTransactionAsync()) + { + await transport.EnqueueAsync(new OracleAqEnqueueRequest + { + QueueName = queueSet.SignalQueueName, + Payload = JsonSerializer.SerializeToUtf8Bytes(new { message = "ambient-delay-rollback" }), + Correlation = correlation, + DelaySeconds = 2, + ExceptionQueueName = queueSet.DeadLetterQueueName, + }); + + await transaction.RollbackAsync(); + } + + await using var lease = await WaitForLeaseAsync( + () => transport.DequeueAsync(new OracleAqDequeueRequest + { + QueueName = queueSet.SignalQueueName, + Correlation = correlation, + WaitSeconds = 1, + }), + TimeSpan.FromSeconds(5)); + + lease.Should().BeNull(); + } + + private static ServiceProvider CreateProvider( + OracleAqQueueSet queueSet, + int maxDeliveryAttempts = 3, + Action? configureServices = null) + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = OracleAqIntegrationLifetime.Fixture.ConnectionString, + ["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"] = maxDeliveryAttempts.ToString(), + }) + .Build(); + + services.AddLogging(); + services.AddWorkflowRegistration(); + services.AddWorkflowRegistration(); + services.AddWorkflowEngineCoreServices(configuration); + services.AddWorkflowOracleDataStore(configuration); + configureServices?.Invoke(services); + + 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(), + _ => throw new AssertionException("Value is not a string."), + }; + } + + private static bool ReadBool(object? value) + { + return value switch + { + bool boolean => boolean, + JsonElement jsonElement => jsonElement.Get(), + _ => 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 static async Task WaitForLeaseAsync( + Func> acquireAsync, + TimeSpan timeout) + { + var timeoutAt = DateTime.UtcNow.Add(timeout); + while (DateTime.UtcNow < timeoutAt) + { + var lease = await acquireAsync(); + if (lease is not null) + { + return lease; + } + } + + return null; + } + + private static async Task WaitForProcessedSignalAsync( + Func> runOnceAsync, + TimeSpan timeout) + { + var timeoutAt = DateTime.UtcNow.Add(timeout); + while (DateTime.UtcNow < timeoutAt) + { + if (await runOnceAsync()) + { + return true; + } + } + + return false; + } + + private static async Task ResumeAsync(ServiceProvider provider, WorkflowSignalEnvelope signal) + { + using var scope = provider.CreateScope(); + var runtimeService = scope.ServiceProvider.GetRequiredService(); + await runtimeService.ResumeSignalAsync(signal, CancellationToken.None); + } + + private static string ReadWaitingToken(string stateJson) + { + using var document = JsonDocument.Parse(stateJson); + if (document.RootElement.TryGetProperty("waitings", out var waitingsElement) + && waitingsElement.ValueKind == JsonValueKind.Array) + { + foreach (var waiting in waitingsElement.EnumerateArray()) + { + if (waiting.TryGetProperty("token", out var tokenElement) + && tokenElement.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(tokenElement.GetString())) + { + return tokenElement.GetString()!; + } + } + } + + if (document.RootElement.TryGetProperty("waiting", out var waitingElement) + && waitingElement.ValueKind == JsonValueKind.Object + && waitingElement.TryGetProperty("token", out var legacyTokenElement) + && legacyTokenElement.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(legacyTokenElement.GetString())) + { + return legacyTokenElement.GetString()!; + } + + throw new AssertionException("Workflow runtime state did not contain an external waiting token."); + } + + private sealed class ThrowingWorkflowSignalProcessor : IWorkflowSignalProcessor + { + public Task ProcessAsync(WorkflowSignalEnvelope envelope, CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("Synthetic AQ processor failure."); + } + } + + private sealed class BarrierWorkflowRuntimeOrchestrator( + ConfiguredWorkflowRuntimeOrchestrator inner, + ResumeConcurrencyGate gate) : IWorkflowRuntimeOrchestrator + { + public Task StartAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + StartWorkflowRequest request, + object startRequest, + CancellationToken cancellationToken = default) + { + return inner.StartAsync(registration, definition, businessReference, request, startRequest, cancellationToken); + } + + public Task CompleteAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default) + { + return inner.CompleteAsync(registration, definition, context, cancellationToken); + } + + public async Task ResumeAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowSignalExecutionContext context, + CancellationToken cancellationToken = default) + { + await gate.WaitAsync(cancellationToken); + return await inner.ResumeAsync(registration, definition, context, cancellationToken); + } + } + + private sealed class ResumeConcurrencyGate(int requiredParticipants) + { + private readonly TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int arrivals; + + public async Task WaitAsync(CancellationToken cancellationToken) + { + if (Interlocked.Increment(ref arrivals) >= requiredParticipants) + { + completion.TrySetResult(); + } + + await completion.Task.WaitAsync(cancellationToken); + } + } + + private sealed class ThrowAfterFirstPublishSignalBus(IWorkflowSignalBus inner) : IWorkflowSignalBus + { + private int remainingFailures = 1; + + public async Task PublishAsync(WorkflowSignalEnvelope envelope, CancellationToken cancellationToken = default) + { + await inner.PublishAsync(envelope, cancellationToken); + if (Interlocked.Exchange(ref remainingFailures, 0) == 1) + { + throw new InvalidOperationException("Synthetic AQ enqueue failure."); + } + } + + public Task PublishDeadLetterAsync( + WorkflowSignalEnvelope envelope, + CancellationToken cancellationToken = default) + { + return inner.PublishDeadLetterAsync(envelope, cancellationToken); + } + + public Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + return inner.ReceiveAsync(consumerName, cancellationToken); + } + } + + private sealed class ThrowAfterFirstScheduleBus(IWorkflowScheduleBus inner) : IWorkflowScheduleBus + { + private int remainingFailures = 1; + + public async Task ScheduleAsync( + WorkflowSignalEnvelope envelope, + DateTime dueAtUtc, + CancellationToken cancellationToken = default) + { + await inner.ScheduleAsync(envelope, dueAtUtc, cancellationToken); + if (Interlocked.Exchange(ref remainingFailures, 0) == 1) + { + throw new InvalidOperationException("Synthetic AQ enqueue failure."); + } + } + } + + private sealed class ContinuationDispatchRuntimeOrchestrator : IWorkflowRuntimeOrchestrator + { + public Task StartAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + StartWorkflowRequest request, + object startRequest, + CancellationToken cancellationToken = default) + { + var typedRequest = (OracleRecoveryStartRequest)startRequest; + + return Task.FromResult(new WorkflowRuntimeExecutionResult + { + RuntimeProvider = WorkflowRuntimeProviderNames.Engine, + RuntimeInstanceId = $"wf-continuation-{typedRequest.SrPolicyId}", + RuntimeStatus = "WaitingForSignal", + InstanceStatus = WorkflowInstanceStatuses.Open, + BusinessReference = businessReference, + WorkflowState = new Dictionary + { + ["phase"] = JsonSerializer.SerializeToElement("dispatching-continuation"), + }, + RuntimeState = CreateRuntimeState( + version: 1, + phase: "dispatching-continuation"), + Continuations = + [ + new WorkflowContinuationPlan + { + Request = new StartWorkflowRequest + { + WorkflowName = OracleTimerRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = typedRequest.SrPolicyId, + }, + }, + }, + ], + }); + } + + public Task CompleteAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task ResumeAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowSignalExecutionContext context, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } + + private sealed record OracleRecoveryStartRequest + { + [WorkflowBusinessId] + [WorkflowBusinessReferencePart("policyId")] + public long SrPolicyId { get; init; } + } + + private sealed class OracleContinuationDispatchWorkflow : ISerdicaWorkflow + { + public const string WorkflowNameValue = "OracleContinuationDispatchWorkflow"; + + public string WorkflowName => WorkflowNameValue; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Oracle Continuation Dispatch Workflow"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => []; + } + + private sealed class OracleTimerRecoveryWorkflow : IDeclarativeWorkflow + { + public const string WorkflowNameValue = "OracleTimerRecoveryWorkflow"; + + public string WorkflowName => WorkflowNameValue; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Oracle Timer Recovery Workflow"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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( + "Oracle Timer Review", + "OracleTimerReview", + "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 For Oracle Timer", WorkflowExpr.String("00:00:02")) + .Set("timerFired", true) + .Set("phase", "after-timer") + .ActivateTask("Oracle Timer Review")) + .Build(); + } + + private sealed class OracleExternalSignalRecoveryWorkflow : IDeclarativeWorkflow + { + public const string WorkflowNameValue = "OracleExternalSignalRecoveryWorkflow"; + + public string WorkflowName => WorkflowNameValue; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Oracle External Recovery Workflow"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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( + "Oracle External Review", + "OracleExternalReview", + "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("Oracle External Review")) + .Build(); + } + + private static void RegisterThrowAfterFirstSignalBus(IServiceCollection services) + { + services.AddScoped(); + services.Replace(ServiceDescriptor.Scoped(serviceProvider => + new ThrowAfterFirstPublishSignalBus( + serviceProvider.GetRequiredService()))); + } + + private static void RegisterThrowAfterFirstScheduleBus(IServiceCollection services) + { + services.AddScoped(); + services.Replace(ServiceDescriptor.Scoped(serviceProvider => + new ThrowAfterFirstScheduleBus( + serviceProvider.GetRequiredService()))); + } + + private static Dictionary CreateRuntimeState( + long version, + string phase) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["engineSchemaVersion"] = 1, + ["version"] = version, + ["status"] = WorkflowInstanceStatuses.Open, + ["workflowState"] = new Dictionary + { + ["phase"] = phase, + }, + ["waiting"] = null, + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqWorkflowScheduleBusTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqWorkflowScheduleBusTests.cs new file mode 100644 index 000000000..c28fe3dbd --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqWorkflowScheduleBusTests.cs @@ -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 + { + ["timer"] = JsonSerializer.SerializeToElement("follow-up"), + }, + }; + } + + private sealed class FakeOracleAqTransport : IOracleAqTransport + { + public List EnqueueRequests { get; } = []; + + public Task EnqueueAsync(OracleAqEnqueueRequest request, CancellationToken cancellationToken = default) + { + EnqueueRequests.Add(request); + return Task.CompletedTask; + } + + public Task> BrowseAsync(OracleAqBrowseRequest request, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task DequeueAsync(OracleAqDequeueRequest request, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqWorkflowSignalBusTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqWorkflowSignalBusTests.cs new file mode 100644 index 000000000..ee13c4d6c --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleAqWorkflowSignalBusTests.cs @@ -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.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(); + 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.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 + { + ["answer"] = JsonSerializer.SerializeToElement("approve"), + }, + }; + } + + private sealed class FakeOracleAqTransport : IOracleAqTransport + { + public List 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> BrowseAsync(OracleAqBrowseRequest request, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task DequeueAsync(OracleAqDequeueRequest request, CancellationToken cancellationToken = default) + { + LastDequeueRequest = request; + return Task.FromResult(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 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; + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleDockerFixture.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleDockerFixture.cs new file mode 100644 index 000000000..5b137bca6 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleDockerFixture.cs @@ -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 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 CanUseDockerAsync(CancellationToken cancellationToken) + { + return await RunDockerCommandAsync("version --format {{.Server.Version}}", ignoreErrors: true, cancellationToken) == 0; + } + + private static async Task 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; } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OraclePerformanceMetricsCollector.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OraclePerformanceMetricsCollector.cs new file mode 100644 index 000000000..3d8af08a3 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OraclePerformanceMetricsCollector.cs @@ -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 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> ReadNamedNumberMapAsync( + OracleConnection connection, + IReadOnlyCollection 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(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> 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(); + 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 SysStats { get; init; } + public required IReadOnlyDictionary TimeModel { get; init; } + public required IReadOnlyList 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 SysStatDeltas { get; init; } + public required IReadOnlyDictionary TimeModelDeltas { get; init; } + public required IReadOnlyList 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, + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleRedisSignalDriverPerformanceTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleRedisSignalDriverPerformanceTests.cs new file mode 100644 index 000000000..da2a1715c --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleRedisSignalDriverPerformanceTests.cs @@ -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(); + var startLatencies = new ConcurrentBag(); + var signalPublishLatencies = new ConcurrentBag(); + var signalToCompletionLatencies = new ConcurrentBag(); + var signalToFirstCompletionLatencies = new ConcurrentBag(); + var drainToIdleOverhangLatencies = new ConcurrentBag(); + + 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 + { + ["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 + { + ["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 + { + ["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(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 + { + ["srPolicyId"] = 993999L, + }, + })); + await OracleAqPerformanceTestSupport.WithRuntimeServiceAsync( + provider, + runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = warmupResponse.WorkflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = 863999L, + }, + })); + await OracleAqPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(provider, TimeSpan.FromSeconds(20), workerCount: 2); + + var startedAtUtc = DateTime.UtcNow; + var endToEndLatencies = new ConcurrentBag(); + var startLatencies = new ConcurrentBag(); + var signalPublishLatencies = new ConcurrentBag(); + var signalToCompletionLatencies = new ConcurrentBag(); + + 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 + { + ["srPolicyId"] = 993000L + index, + }, + })); + startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc); + return (Index: index, Response: response, StartedAtUtc: operationStartedAtUtc); + }); + + var signalRaisedAtUtc = new ConcurrentDictionary(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 + { + ["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 + { + ["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!, + ["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!, + ["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!, + }, + BackendMetrics = oracleMetrics.ToBackendMetrics(), + ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(), + Metadata = new Dictionary(StringComparer.Ordinal) + { + ["workflowName"] = "OracleAqPerfSignalRoundTripWorkflow", + ["queueName"] = queueSet.SignalQueueName, + ["workerCount"] = workerCount.ToString(), + ["measurementKind"] = "steady-throughput", + ["signalDriver"] = WorkflowSignalDriverNames.Redis, + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleWorkflowPerformanceMetricsExtensions.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleWorkflowPerformanceMetricsExtensions.cs new file mode 100644 index 000000000..b407692de --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleWorkflowPerformanceMetricsExtensions.cs @@ -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(delta.SysStatDeltas, StringComparer.OrdinalIgnoreCase), + DurationDeltas = new Dictionary(delta.TimeModelDeltas, StringComparer.OrdinalIgnoreCase), + TopWaitDeltas = delta.TopWaitDeltas + .Select(wait => new WorkflowPerformanceWaitMetric + { + Name = wait.EventName, + TotalCount = wait.TotalWaits, + DurationMicroseconds = wait.TimeWaitedMicroseconds, + }) + .ToArray(), + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleWorkflowRuntimeStateStoreTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleWorkflowRuntimeStateStoreTests.cs new file mode 100644 index 000000000..752b01b17 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/OracleWorkflowRuntimeStateStoreTests.cs @@ -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() + .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() + .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, + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/StellaOps.Workflow.DataStore.Oracle.Tests.csproj b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/StellaOps.Workflow.DataStore.Oracle.Tests.csproj new file mode 100644 index 000000000..d212749c0 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/StellaOps.Workflow.DataStore.Oracle.Tests.csproj @@ -0,0 +1,55 @@ + + + net10.0 + false + enable + enable + false + true + false + + CS8601;CS8602;CS8604;NU1015 + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceCapacityTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceCapacityTests.cs new file mode 100644 index 000000000..87fedda50 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceCapacityTests.cs @@ -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 + { + ["srPolicyId"] = 993999L, + }, + })); + await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync( + provider, + runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = warmupStart.WorkflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["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(); + + 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 + { + ["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 + { + ["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(StringComparer.Ordinal) + { + ["workflowName"] = "PostgresPerfSignalRoundTripWorkflow", + ["ladder"] = string.Join(",", concurrencyLadder), + ["workerCount"] = workerCount.ToString(), + }, + }); + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceLatencyTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceLatencyTests.cs new file mode 100644 index 000000000..fb31b294f --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceLatencyTests.cs @@ -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(); + var startLatencies = new ConcurrentBag(); + var signalPublishLatencies = new ConcurrentBag(); + var signalToCompletionLatencies = new ConcurrentBag(); + var signalToFirstCompletionLatencies = new ConcurrentBag(); + var drainToIdleOverhangLatencies = new ConcurrentBag(); + + 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 + { + ["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 + { + ["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 + { + ["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(StringComparer.Ordinal) + { + ["workflowName"] = "PostgresPerfSignalRoundTripWorkflow", + ["measurementKind"] = "serial-latency", + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceMetricsCollector.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceMetricsCollector.cs new file mode 100644 index 000000000..ea5df845b --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceMetricsCollector.cs @@ -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 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 CounterStats, Dictionary 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(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(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> 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(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 CounterStats { get; init; } + public required IReadOnlyDictionary DurationStats { get; init; } + public required IReadOnlyDictionary 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 CounterDeltas { get; init; } + public required IReadOnlyDictionary DurationDeltas { get; init; } + public required IReadOnlyList 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, + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceNightlyTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceNightlyTests.cs new file mode 100644 index 000000000..7b8b717a9 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceNightlyTests.cs @@ -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(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(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(); + 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 + { + ["srPolicyId"] = 999000L + index, + }, + })); + + return (Index: index, Response: response, StartedAtUtc: DateTime.UtcNow); + }); + + var raisedSignalsAt = new ConcurrentDictionary(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 + { + ["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(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(); + 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 + { + ["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 + { + ["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(StringComparer.Ordinal) + { + ["workflowName"] = "QuotationConfirm", + ["legacyRabbitInvocationCount"] = transports.LegacyRabbit.Invocations.Count.ToString(), + ["expectedInvocationCount"] = (workflowCount * 4).ToString(), + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceSmokeTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceSmokeTests.cs new file mode 100644 index 000000000..b28c441ff --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceSmokeTests.cs @@ -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(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(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(); + 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 + { + ["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(StringComparer.Ordinal) + { + ["workflowName"] = "QuoteOrAplCancel", + ["legacyRabbitInvocationCount"] = transports.LegacyRabbit.Invocations.Count.ToString(), + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceSoakTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceSoakTests.cs new file mode 100644 index 000000000..733d71faf --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceSoakTests.cs @@ -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(); + 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 + { + ["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 + { + ["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(StringComparer.Ordinal) + { + ["workflowName"] = "PostgresPerfSignalRoundTripWorkflow", + ["waveCount"] = waveCount.ToString(), + ["workflowsPerWave"] = workflowsPerWave.ToString(), + ["workerCount"] = workerCount.ToString(), + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceTestSupport.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceTestSupport.cs new file mode 100644 index 000000000..32bab441a --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceTestSupport.cs @@ -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 + { + ["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(); + services.AddWorkflowRegistration(); + + 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(_ => transports.LegacyRabbit)); + services.Replace(ServiceDescriptor.Scoped(_ => transports.Microservice)); + services.Replace(ServiceDescriptor.Scoped(_ => transports.Graphql)); + services.Replace(ServiceDescriptor.Scoped(_ => transports.Http)); + + var provider = services.BuildServiceProvider(); + ServiceProviderAccessor.Initialize(provider); + return provider; + } + + public static async Task 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 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 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(); + 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( + string connectionString, + Func> 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 RunImmediateTransportBurstAsync( + IServiceProvider provider, + int messageCount, + TimeSpan timeout, + string correlationPrefix) + { + using var scope = provider.CreateScope(); + var signalBus = scope.ServiceProvider.GetRequiredService(); + return await RunTransportBurstAsync(signalBus, scheduleBus: null, messageCount, delaySeconds: 0, timeout, correlationPrefix); + } + + public static async Task RunDelayedTransportBurstAsync( + IServiceProvider provider, + int messageCount, + int delaySeconds, + TimeSpan timeout, + string correlationPrefix) + { + using var scope = provider.CreateScope(); + var signalBus = scope.ServiceProvider.GetRequiredService(); + var scheduleBus = scope.ServiceProvider.GetRequiredService(); + return await RunTransportBurstAsync(signalBus, scheduleBus, messageCount, delaySeconds, timeout, correlationPrefix); + } + + private static async Task RunTransportBurstAsync( + IWorkflowSignalBus signalBus, + IWorkflowScheduleBus? scheduleBus, + int messageCount, + int delaySeconds, + TimeSpan timeout, + string correlationPrefix) + { + var publishedAt = new Dictionary(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(messageCount); + var processedCorrelations = new HashSet(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 + { + ["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 + { + 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 WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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( + "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 + { + 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 WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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 ReceiveLatencies { get; init; } + public required IReadOnlyCollection ProcessedCorrelations { get; init; } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceThroughputTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceThroughputTests.cs new file mode 100644 index 000000000..02d392693 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresPerformanceThroughputTests.cs @@ -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 + { + ["srPolicyId"] = 991999L, + }, + })); + await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync( + provider, + runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = warmupResponse.WorkflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = 861999L, + }, + })); + await PostgresPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(provider, TimeSpan.FromSeconds(20), workerCount: 2); + + var startedAtUtc = DateTime.UtcNow; + var endToEndLatencies = new ConcurrentBag(); + var startLatencies = new ConcurrentBag(); + var signalPublishLatencies = new ConcurrentBag(); + var signalToCompletionLatencies = new ConcurrentBag(); + + 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 + { + ["srPolicyId"] = 991000L + index, + }, + })); + startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc); + return (Index: index, Response: response, StartedAtUtc: operationStartedAtUtc); + }); + + var signalRaisedAtUtc = new ConcurrentDictionary(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 + { + ["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 + { + ["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!, + ["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!, + ["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!, + }, + BackendMetrics = postgresMetrics.ToBackendMetrics(), + ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(), + Metadata = new Dictionary(StringComparer.Ordinal) + { + ["workflowName"] = "PostgresPerfSignalRoundTripWorkflow", + ["workerCount"] = workerCount.ToString(), + ["measurementKind"] = "steady-throughput", + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresRedisSignalDriverPerformanceTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresRedisSignalDriverPerformanceTests.cs new file mode 100644 index 000000000..4addbbba0 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresRedisSignalDriverPerformanceTests.cs @@ -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(); + var startLatencies = new ConcurrentBag(); + var signalPublishLatencies = new ConcurrentBag(); + var signalToCompletionLatencies = new ConcurrentBag(); + var signalToFirstCompletionLatencies = new ConcurrentBag(); + var drainToIdleOverhangLatencies = new ConcurrentBag(); + + 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 + { + ["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 + { + ["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 + { + ["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(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 + { + ["srPolicyId"] = 992999L, + }, + })); + await WorkflowEnginePerformanceSupport.WithRuntimeServiceAsync( + provider, + runtimeService => runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = warmupResponse.WorkflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = 862999L, + }, + })); + await PostgresPerformanceTestSupport.DrainSignalsWithWorkersUntilIdleAsync(provider, TimeSpan.FromSeconds(20), workerCount: 2); + + var startedAtUtc = DateTime.UtcNow; + var endToEndLatencies = new ConcurrentBag(); + var startLatencies = new ConcurrentBag(); + var signalPublishLatencies = new ConcurrentBag(); + var signalToCompletionLatencies = new ConcurrentBag(); + + 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 + { + ["srPolicyId"] = 992000L + index, + }, + })); + startLatencies.Add(DateTime.UtcNow - startMeasureStartedAtUtc); + return (Index: index, Response: response, StartedAtUtc: operationStartedAtUtc); + }); + + var signalRaisedAtUtc = new ConcurrentDictionary(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 + { + ["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 + { + ["start"] = WorkflowPerformanceLatencySummary.FromSamples(startLatencies)!, + ["signalPublish"] = WorkflowPerformanceLatencySummary.FromSamples(signalPublishLatencies)!, + ["signalToCompletion"] = WorkflowPerformanceLatencySummary.FromSamples(signalToCompletionLatencies)!, + }, + BackendMetrics = postgresMetrics.ToBackendMetrics(), + ResourceSnapshot = WorkflowPerformanceResourceSnapshot.CaptureCurrent(), + Metadata = new Dictionary(StringComparer.Ordinal) + { + ["workflowName"] = "PostgresPerfSignalRoundTripWorkflow", + ["workerCount"] = workerCount.ToString(), + ["measurementKind"] = "steady-throughput", + ["signalDriver"] = WorkflowSignalDriverNames.Redis, + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresWorkflowPerformanceMetricsExtensions.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresWorkflowPerformanceMetricsExtensions.cs new file mode 100644 index 000000000..a397a10d2 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/Performance/PostgresWorkflowPerformanceMetricsExtensions.cs @@ -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(delta.CounterDeltas, StringComparer.OrdinalIgnoreCase), + DurationDeltas = new Dictionary(delta.DurationDeltas, StringComparer.OrdinalIgnoreCase), + Metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["activeSessions"] = delta.ActiveSessions.ToString(CultureInfo.InvariantCulture), + ["notificationQueueUsage"] = delta.NotificationQueueUsage.ToString("F6", CultureInfo.InvariantCulture), + }, + TopWaitDeltas = delta.TopWaits, + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresBulstradWorkflowIntegrationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresBulstradWorkflowIntegrationTests.cs new file mode 100644 index 000000000..bd747bf32 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresBulstradWorkflowIntegrationTests.cs @@ -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(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "QuoteOrAplCancel", + Payload = new Dictionary + { + ["srPolicyId"] = 896601L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using var resumedProvider = CreateProvider(transports); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + 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(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "InsisIntegrationNew", + Payload = new Dictionary + { + ["srPolicyId"] = 895501L, + ["srAnnexId"] = 885501L, + ["srCustId"] = 775501L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using var resumedProvider = CreateProvider(transports); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + 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(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "QuotationConfirm", + Payload = new Dictionary + { + ["srPolicyId"] = 896612L, + ["srAnnexId"] = 886612L, + ["srCustId"] = 776612L, + }, + }); + + workflowInstanceId = startResponse.WorkflowInstanceId; + } + + using (var provider = CreateProvider(transports)) + { + var runtimeService = provider.GetRequiredService(); + 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 + { + ["answer"] = "confirm", + }, + }); + } + + using var resumedProvider = CreateProvider(transports); + var resumedRuntimeService = resumedProvider.GetRequiredService(); + 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 state, string key) + { + return state[key] switch + { + bool boolean => boolean, + _ => bool.Parse(state[key]!.ToString()!), + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresDockerFixture.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresDockerFixture.cs new file mode 100644 index 000000000..55954de65 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresDockerFixture.cs @@ -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 CanUseDockerAsync(CancellationToken cancellationToken) + { + return await RunDockerCommandAsync("version --format {{.Server.Version}}", ignoreErrors: true, cancellationToken) == 0; + } + + private static async Task 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(); + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowProjectionIntegrationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowProjectionIntegrationTests.cs new file mode 100644 index 000000000..7cd2ebfdf --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowProjectionIntegrationTests.cs @@ -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 + { + [$"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 + { + ["policyId"] = 42L, + }, + }; + var startPlan = new WorkflowStartExecutionPlan + { + InstanceStatus = "Open", + WorkflowState = new Dictionary + { + ["phase"] = JsonSerializer.SerializeToElement("start"), + }, + Tasks = + [ + new WorkflowExecutionTaskPlan + { + TaskName = "Review", + TaskType = "Human", + Route = "review", + TaskRoles = ["uw.review"], + Payload = new Dictionary + { + ["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 { ["approved"] = true }, + new WorkflowTaskCompletionPlan + { + InstanceStatus = "Completed", + WorkflowState = new Dictionary + { + ["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 + { + [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(), + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowSignalIntegrationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowSignalIntegrationTests.cs new file mode 100644 index 000000000..c1849caea --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowSignalIntegrationTests.cs @@ -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 + { + [$"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 + { + ["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, + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowStoreIntegrationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowStoreIntegrationTests.cs new file mode 100644 index 000000000..fac8ed752 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowStoreIntegrationTests.cs @@ -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 + { + [$"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 + { + ["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(); + } + + [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()); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowWakeOutboxIntegrationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowWakeOutboxIntegrationTests.cs new file mode 100644 index 000000000..e7d2c4b38 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowWakeOutboxIntegrationTests.cs @@ -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 + { + [$"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, + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests.csproj b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests.csproj new file mode 100644 index 000000000..8ece0eba0 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests.csproj @@ -0,0 +1,51 @@ + + + net10.0 + false + enable + enable + false + true + false + + CS8601;CS8602;CS8604;NU1015 + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/CanonicalWorkflowExecutionHandlerTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/CanonicalWorkflowExecutionHandlerTests.cs new file mode 100644 index 000000000..906742c68 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/CanonicalWorkflowExecutionHandlerTests.cs @@ -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(), + }); + + 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(), + }); + + 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(), + }); + + 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(), + }); + + 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), + Definition = resolvedDescriptor, + BindStartRequest = payload => new Dictionary(payload, StringComparer.OrdinalIgnoreCase), + ExtractBusinessReference = _ => null, + }; + } + + private sealed class RecordingRabbitTransport : IWorkflowRabbitTransport + { + public List Requests { get; } = []; + + public WorkflowRabbitResponse Response { get; init; } = new() + { + Succeeded = true, + }; + + public Task ExecuteAsync( + WorkflowRabbitRequest request, + CancellationToken cancellationToken = default) + { + Requests.Add(request); + return Task.FromResult(Response); + } + } + + private sealed class NullMicroserviceTransport : IWorkflowMicroserviceTransport + { + public Task 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 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 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 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 arguments, + WorkflowCanonicalEvaluationContext context, + out object? result) + { + result = null; + return false; + } + } + + private sealed class StubWorkflowRegistrationCatalog : IWorkflowRegistrationCatalog + { + public IReadOnlyCollection GetRegistrations() => []; + + public WorkflowRegistration? GetRegistration(string workflowName, string? workflowVersion = null) => null; + } + + private sealed class StubWorkflowRuntimeDefinitionStore : IWorkflowRuntimeDefinitionStore + { + public IReadOnlyCollection 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; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/ConfiguredWorkflowRuntimeOrchestratorTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/ConfiguredWorkflowRuntimeOrchestratorTests.cs new file mode 100644 index 000000000..8159ad78b --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/ConfiguredWorkflowRuntimeOrchestratorTests.cs @@ -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() + .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), + 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 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 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 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", + }); + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/InMemoryWorkflowRuntimeStateStoreTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/InMemoryWorkflowRuntimeStateStoreTests.cs new file mode 100644 index 000000000..18edb4128 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/InMemoryWorkflowRuntimeStateStoreTests.cs @@ -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() + .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() + .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, + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/RecordingWorkflowHttpTransport.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/RecordingWorkflowHttpTransport.cs new file mode 100644 index 000000000..ca133cf7a --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/RecordingWorkflowHttpTransport.cs @@ -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 exceptions = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary responses = new(StringComparer.OrdinalIgnoreCase); + + public List 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 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(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}"; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/RecordingWorkflowLegacyRabbitTransport.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/RecordingWorkflowLegacyRabbitTransport.cs new file mode 100644 index 000000000..d2c9c183b --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/RecordingWorkflowLegacyRabbitTransport.cs @@ -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 exceptions = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> responses = new(StringComparer.OrdinalIgnoreCase); + + public List 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(); + 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(); + 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 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(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 queue) + { + if (queue.Count <= 1) + { + return queue.Peek(); + } + + return queue.Dequeue(); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/StellaOps.Workflow.Engine.Tests.csproj b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/StellaOps.Workflow.Engine.Tests.csproj new file mode 100644 index 000000000..12f8fe0d0 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/StellaOps.Workflow.Engine.Tests.csproj @@ -0,0 +1,54 @@ + + + net10.0 + false + enable + enable + false + true + false + + CS8601;CS8602;CS8604;NU1015 + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/TechnicalStyleWorkflowTestHelpers.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/TechnicalStyleWorkflowTestHelpers.cs new file mode 100644 index 000000000..1e56b0825 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/TechnicalStyleWorkflowTestHelpers.cs @@ -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 + { + ["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(); + services.AddSingleton(); + services.AddSingleton(); + } + services.AddDbContext(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddScoped(_ => transport); + + var provider = services.BuildServiceProvider(); + // ServiceProviderAccessor.Initialize(provider); + return provider; + } + + internal static async Task 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(); + services.AddWorkflowRegistration(); + services.AddWorkflowRegistration(); + services.AddWorkflowRegistration(); + services.AddWorkflowRegistration(); + } + + internal static object? GetPayloadValue(IDictionary 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 + { + ["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 + { + ["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 + { + ["policyInfo"] = new { duration = 12, installments = 1 }, + ["participants"] = Array.Empty(), + ["customer"] = new { id = customerId }, + ["srCustId"] = customerId, + ["insuredItems"] = new[] + { + new + { + objectCode, + objectValues = new[] { new { prmCode = "SI", prmCvalue = 100000 } }, + }, + }, + }, + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/TestWorkflowDefinitions.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/TestWorkflowDefinitions.cs new file mode 100644 index 000000000..f0d0a3c3f --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/TestWorkflowDefinitions.cs @@ -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? InitialTaskRoles { get; init; } +} + +internal sealed class TestApproveApplicationWorkflow + : IDeclarativeWorkflow +{ + public string WorkflowName => "ApproveApplication"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Approve Application"; + + public IReadOnlyCollection WorkflowRoles { get; } = + [ + "DBA", + "UR_UNDERWRITER", + "APR_APPL", + "UR_OPERATIONS", + "UR_EXCLUSIVE_AGENT", + "UR_AGENT", + "UR_ORG_ADMIN", + "UR_HEALTH", + ]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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( + "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( + "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( + "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 Tasks => Spec.TaskDescriptors; + + private sealed record OperationsResponse + { + public required bool Passed { get; init; } + public required Dictionary 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 +{ + public string WorkflowName => "AssistantPrintInsisDocuments"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Assistant Print INSIS Documents"; + + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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( + "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 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 +{ + public string WorkflowName => "AssistantPolicyReinstate"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Assistant Policy Reinstate"; + + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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( + "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 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 +{ + public string WorkflowName => "UpdateSrPolicyIdSrcAndCopyCovers"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Update Policy ID Source and Copy Covers"; + + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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 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 +{ + public string WorkflowName => "UserDataCheckConsistency"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "User Data Check Consistency"; + + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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 Tasks => Spec.TaskDescriptors; +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalCompilerImportTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalCompilerImportTests.cs new file mode 100644 index 000000000..8ccafaa72 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalCompilerImportTests.cs @@ -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(); + + 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().ToArray(); + var timerSteps = steps.OfType().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().Single(step => + string.Equals(step.DecisionName, "Continue or end process", global::System.StringComparison.Ordinal)); + var standaloneAssignments = EnumerateSteps(endDecision.WhenTrue).OfType().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() + .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() + .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().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() + .Should() + .Contain(step => string.Equals(step.StepName, "Get Annex Description", global::System.StringComparison.Ordinal)); + startSteps.OfType() + .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() + .Should() + .Contain(step => string.Equals(step.DecisionName, "Master contract?", global::System.StringComparison.Ordinal)); + confirmSteps.OfType() + .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() + .Should() + .Contain(step => string.Equals(step.StepName, "Calculate Premium For Object", global::System.StringComparison.Ordinal)); + startSteps.OfType() + .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() + .Should() + .Contain(step => string.Equals(step.DecisionName, "Retry transfer?", global::System.StringComparison.Ordinal)); + } + + [TestCaseSource(nameof(GetCanonicalizedWorkflowFactories))] + public void Compile_WhenWorkflowUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition( + string workflowName, + Func 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 GetCanonicalizedWorkflowFactories() + { + yield return new TestCaseData( + "PdfGenerator", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new PdfGeneratorWorkflow()))) + .SetName("Compile_WhenPdfGeneratorUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "QuoteOrAplCancel", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new QuoteOrAplCancelWorkflow()))) + .SetName("Compile_WhenQuoteOrAplCancelUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "UpdateSrPolicyIdSrcAndCopyCovers", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new UpdateSrPolicyIdSrcAndCopyCoversWorkflow()))) + .SetName("Compile_WhenUpdateSrPolicyIdSrcAndCopyCoversUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AgriculturalCropsPolicyIssue3502", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AgriculturalCropsPolicyIssue3502Workflow()))) + .SetName("Compile_WhenAgriculturalCropsPolicyIssue3502UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AgriculturalInsurancePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AgriculturalInsurancePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenAgriculturalInsurancePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AgriculturalInsuranceIssuePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AgriculturalInsuranceIssuePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenAgriculturalInsuranceIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "CargoOneTimePolicyIssue1100", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new CargoOneTimePolicyIssue1100Workflow()))) + .SetName("Compile_WhenCargoOneTimePolicyIssue1100UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "CargoPolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new CargoPolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenCargoPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "CargoIssuePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new CargoIssuePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenCargoIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "CompulsoryAccidentAtWorkInsurancePolicyIssue3607", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new CompulsoryAccidentAtWorkInsurancePolicyIssue3607Workflow()))) + .SetName("Compile_WhenCompulsoryAccidentAtWorkInsurancePolicyIssue3607UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "ConstructionAndAssemblyRisksPolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new ConstructionAndAssemblyRisksPolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenConstructionAndAssemblyRisksPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "GuestsAtTouristSitesOnTheTerritoryOfTheRepublicOfBulgariaPolicyIssue3635", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new GuestsAtTouristSitesOnTheTerritoryOfTheRepublicOfBulgariaPolicyIssue3635Workflow()))) + .SetName("Compile_WhenGuestsAtTouristSitesOnTheTerritoryOfTheRepublicOfBulgariaPolicyIssue3635UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "PersonTravelPackagePolicyIssueEmerald", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new PersonTravelPackagePolicyIssueEmeraldWorkflow()))) + .SetName("Compile_WhenPersonTravelPackagePolicyIssueEmeraldUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "PropertyCombinedPolicyIssue2200", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new PropertyCombinedPolicyIssue2200Workflow()))) + .SetName("Compile_WhenPropertyCombinedPolicyIssue2200UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "IndustrialPropertyPolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new IndustrialPropertyPolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenIndustrialPropertyPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "IndustrialPropertyIssuePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new IndustrialPropertyIssuePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenIndustrialPropertyIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "ApproveApplication", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new ApproveApplicationWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenApproveApplicationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "InsisIntegrationNew", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new InsisIntegrationNewWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenInsisIntegrationNewUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "ReviewPolicyCancellation", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new ReviewPolicyCancellationWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenReviewPolicyCancellationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "ReviewPolicyOpenForChange", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new ReviewPolicyOpenForChangeWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenReviewPolicyOpenForChangeUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "ReviewPolicyRenew", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new ReviewPolicyRenewWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenReviewPolicyRenewUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "TechnicalInsuranceIssuePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new TechnicalInsuranceIssuePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenTechnicalInsuranceIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "LiabilityPolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new LiabilityPolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenLiabilityPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "LiabilityPolicyForLiabilityObjectOnly", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new LiabilityPolicyForLiabilityObjectOnlyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenLiabilityPolicyForLiabilityObjectOnlyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "LiabilityIssuePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new LiabilityIssuePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenLiabilityIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "FarmAnimalsPolicyIssue3510", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new FarmAnimalsPolicyIssue3510Workflow()))) + .SetName("Compile_WhenFarmAnimalsPolicyIssue3510UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "MoneyPolicyIssue3300", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new MoneyPolicyIssue3300Workflow()))) + .SetName("Compile_WhenMoneyPolicyIssue3300UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "HomePresentOffer", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new HomePresentOfferWorkflow()))) + .SetName("Compile_WhenHomePresentOfferUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "HomeIssuePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new HomeIssuePolicyWorkflow()))) + .SetName("Compile_WhenHomeIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "HomeQuotation", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new HomeQuotationWorkflow()))) + .SetName("Compile_WhenHomeQuotationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "HomePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new HomePolicyWorkflow()))) + .SetName("Compile_WhenHomePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "RailVehiclesPolicyIssue4800", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new RailVehiclesPolicyIssue4800Workflow()))) + .SetName("Compile_WhenRailVehiclesPolicyIssue4800UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AirAndRailTransportPolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AirAndRailTransportPolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenAirAndRailTransportPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AirAndRailTransportIssuePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AirAndRailTransportIssuePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenAirAndRailTransportIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "GenericHealthGroupPolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new GenericHealthGroupPolicyWorkflow()))) + .SetName("Compile_WhenGenericHealthGroupPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "GenericHealthIndividualPolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new GenericHealthIndividualPolicyWorkflow()))) + .SetName("Compile_WhenGenericHealthIndividualPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "GroupPolicies", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new GroupPoliciesWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenGroupPoliciesUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "GroupPoliciesIssuePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new GroupPoliciesIssuePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenGroupPoliciesIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "HealthClaimReimbursement", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new HealthClaimReimbursementWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenHealthClaimReimbursementUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AutomatedHealthClaimReimbursement", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AutomatedHealthClaimReimbursementWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenAutomatedHealthClaimReimbursementUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "HealthClaimPar", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new HealthClaimParWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenHealthClaimParUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "HealthGroupPolicies", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new HealthGroupPoliciesWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenHealthGroupPoliciesUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "HealthGroupPoliciesIssuePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new HealthGroupPoliciesIssuePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenHealthGroupPoliciesIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "HealthIndividualPolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new HealthIndividualPolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenHealthIndividualPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "HealthIndividualIssuePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new HealthIndividualIssuePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenHealthIndividualIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VehicleCasco100", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleCasco100Workflow()))) + .SetName("Compile_WhenVehicleCasco100UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VehicleBorderlineMtplPolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleBorderlineMtplPolicyWorkflow()))) + .SetName("Compile_WhenVehicleBorderlineMtplPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VehicleCascoPolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleCascoPolicyWorkflow()))) + .SetName("Compile_WhenVehicleCascoPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VehicleCascoPolicyWithPhotos", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleCascoPolicyWithPhotosWorkflow()))) + .SetName("Compile_WhenVehicleCascoPolicyWithPhotosUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VesselInsurancePolicyIssue1126", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VesselInsurancePolicyIssue1126Workflow()))) + .SetName("Compile_WhenVesselInsurancePolicyIssue1126UsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantChangeVehicleOwnership", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantChangeVehicleOwnershipWorkflow()))) + .SetName("Compile_WhenAssistantChangeVehicleOwnershipUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantRegister2215Quote", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantRegister2215QuoteWorkflow()))) + .SetName("Compile_WhenAssistantRegister2215QuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantRegisterMtplQuote", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantRegisterMtplQuoteWorkflow()))) + .SetName("Compile_WhenAssistantRegisterMtplQuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantTransferToInsis", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantTransferToInsisWorkflow()))) + .SetName("Compile_WhenAssistantTransferToInsisUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "ChangeVehicleOwnership", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new ChangeVehicleOwnershipWorkflow()))) + .SetName("Compile_WhenChangeVehicleOwnershipUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "ChangeVehicleRegistration", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new ChangeVehicleRegistrationWorkflow()))) + .SetName("Compile_WhenChangeVehicleRegistrationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VehicleMtplPolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleMtplPolicyWorkflow()))) + .SetName("Compile_WhenVehicleMtplPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VehicleClaim", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleClaimWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenVehicleClaimUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VehicleSharedIssuePolicyCasco", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleSharedIssuePolicyCascoWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenVehicleSharedIssuePolicyCascoUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VehicleSharedPresentOffer", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleSharedPresentOfferWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenVehicleSharedPresentOfferUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VehicleSharedIssuePolicyMtpl", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleSharedIssuePolicyMtplWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenVehicleSharedIssuePolicyMtplUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VehicleSharedGetCarInfo", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleSharedGetCarInfoWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenVehicleSharedGetCarInfoUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VehicleMtplQuote", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleMtplQuoteWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenVehicleMtplQuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VehicleCascoQuote", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VehicleCascoQuoteWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenVehicleCascoQuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "CascoWithPhotoIssuePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new CascoWithPhotoIssuePolicyWorkflow()))) + .SetName("Compile_WhenCascoWithPhotoIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "NewVehicleSharedIssuePolicyCasco", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new NewVehicleSharedIssuePolicyCascoWorkflow()))) + .SetName("Compile_WhenNewVehicleSharedIssuePolicyCascoUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "ClaimNotification", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new ClaimNotificationWorkflow()))) + .SetName("Compile_WhenClaimNotificationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "ClaimProcessDocuments", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new ClaimProcessDocumentsWorkflow()))) + .SetName("Compile_WhenClaimProcessDocumentsUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "ClaimProcessEvaluations", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new ClaimProcessEvaluationsWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenClaimProcessEvaluationsUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "ClaimProcessNotification", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new ClaimProcessNotificationWorkflow()))) + .SetName("Compile_WhenClaimProcessNotificationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantAddAnnex", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantAddAnnexWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenAssistantAddAnnexUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantAddCover", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantAddCoverWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenAssistantAddCoverUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantClaimNotification", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantClaimNotificationWorkflow()))) + .SetName("Compile_WhenAssistantClaimNotificationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantPrintInsisDocuments", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantPrintInsisDocumentsWorkflow()))) + .SetName("Compile_WhenAssistantPrintInsisDocumentsUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantPolicyCancellation", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantPolicyCancellationWorkflow()))) + .SetName("Compile_WhenAssistantPolicyCancellationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantPolicyReinstate", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantPolicyReinstateWorkflow()))) + .SetName("Compile_WhenAssistantPolicyReinstateUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantCustomerOpenForChange", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantCustomerOpenForChangeWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenAssistantCustomerOpenForChangeUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantIssueApplication", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantIssueApplicationWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenAssistantIssueApplicationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantHomeIssueApplication", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantHomeIssueApplicationWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenAssistantHomeIssueApplicationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantHomeRegisterQuote", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantHomeRegisterQuoteWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenAssistantHomeRegisterQuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantPersonIssueApplication", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantPersonIssueApplicationWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenAssistantPersonIssueApplicationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantPersonRegisterQuote", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantPersonRegisterQuoteWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenAssistantPersonRegisterQuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AssistantRegisterQuote", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AssistantRegisterQuoteWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenAssistantRegisterQuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "PersonPolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new PersonPolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenPersonPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "PersonIssuePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new PersonIssuePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenPersonIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "PersonLifePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new PersonLifePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenPersonLifePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "PersonPolicyEmerald", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new PersonPolicyEmeraldWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenPersonPolicyEmeraldUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "CompulsoryAccidentAtWorkInsurancePolicyIssue3607Policy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new CompulsoryAccidentAtWorkInsurancePolicyIssue3607PolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenCompulsoryAccidentAtWorkInsurancePolicyIssue3607PolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "GuestsAtTouristSitesOnTheTerritoryOfTheRepublicOfBulgariaPolicyIssue3635Policy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new GuestsAtTouristSitesOnTheTerritoryOfTheRepublicOfBulgariaPolicyIssue3635PolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenGuestsAtTouristSitesOnTheTerritoryOfTheRepublicOfBulgariaPolicyIssue3635PolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "ApproveTreaty", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new ApproveTreatyWorkflow()))) + .SetName("Compile_WhenApproveTreatyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "ApproveFacultative", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new ApproveFacultativeWorkflow()))) + .SetName("Compile_WhenApproveFacultativeUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "UserDataCheckConsistency", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new UserDataCheckConsistencyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenUserDataCheckConsistencyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "AnnexCancellation", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new AnnexCancellationWorkflow()))) + .SetName("Compile_WhenAnnexCancellationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "FinanceInsurancePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new FinanceInsurancePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenFinanceInsurancePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "FinanceInsuranceIssuePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new FinanceInsuranceIssuePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenFinanceInsuranceIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "OpenForChangePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new OpenForChangePolicyWorkflow()))) + .SetName("Compile_WhenOpenForChangePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "PolicyCancellation", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new PolicyCancellationWorkflow()))) + .SetName("Compile_WhenPolicyCancellationUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "PolicyRenew", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new PolicyRenewWorkflow()))) + .SetName("Compile_WhenPolicyRenewUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "PolicyReinstate", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new PolicyReinstateWorkflow()))) + .SetName("Compile_WhenPolicyReinstateUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "CustomerOpenForChange", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new CustomerOpenForChangeWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenCustomerOpenForChangeUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "TechnicalInsurancePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new TechnicalInsurancePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenTechnicalInsurancePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "ContinueOnOpenedAnnex", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new ContinueOnOpenedAnnexWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenContinueOnOpenedAnnexUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "QuotationConfirm", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new QuotationConfirmWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenQuotationConfirmUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VesselGetInfo", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VesselGetInfoWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenVesselGetInfoUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VesselRegisterQuote", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VesselRegisterQuoteWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenVesselRegisterQuoteUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VesselPresentOffer", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VesselPresentOfferWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenVesselPresentOfferUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VesselIssuePolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VesselIssuePolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenVesselIssuePolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + yield return new TestCaseData( + "VesselPolicy", + new Func(() => WorkflowCanonicalDefinitionCompiler.Compile(new VesselPolicyWorkflow(), FunctionCatalog))) + .SetName("Compile_WhenVesselPolicyUsesExpressionBackedDsl_ShouldProduceImportableCanonicalDefinition"); + } + + private static IEnumerable EnumerateSteps(WorkflowStepSequenceDeclaration sequence) + { + foreach (var step in sequence.Steps) + { + yield return step; + + foreach (var nested in EnumerateNestedSteps(step)) + { + yield return nested; + } + } + } + + private static IEnumerable 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; + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalDefinitionTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalDefinitionTests.cs new file mode 100644 index 000000000..6ff892f5f --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalDefinitionTests.cs @@ -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 + { + private static readonly WorkflowHumanTaskDefinition ReviewTask = + WorkflowHumanTask.For( + "Review", + "ReviewTask", + "business/policies") + .WithPayload(context => new + { + srPolicyId = context.StateValues["srPolicyId"].Get(), + }) + .OnComplete(flow => flow + .Set( + "answer", + context => context.PayloadValues["answer"].Get()) + .Complete()); + + public string WorkflowName => "SampleDeclarativeWorkflow"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Sample Declarative Workflow"; + public IReadOnlyCollection WorkflowRoles => []; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState(startRequest => new + { + srPolicyId = startRequest.SrPolicyId, + answer = "open", + }) + .AddTask(ReviewTask) + .StartWith(ReviewTask) + .Build(); + + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalEmbeddedAssetTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalEmbeddedAssetTests.cs new file mode 100644 index 000000000..2f77c2bbd --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalEmbeddedAssetTests.cs @@ -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( + typeof(ClaimDetermineRiskCanonicalTemplate).Assembly, + "StellaOps.Workflow.Engine.Tests.CanonicalTemplates.ClaimDetermineRisk.task.template.json", + new global::System.Collections.Generic.Dictionary()); + + task.TaskName.Should().Be("Determine cover/Risk/Insured Object/Damaged Objects"); + task.OnComplete.Steps.Should().HaveCount(3); + task.OnComplete.Steps.OfType() + .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( + typeof(HomeWorkflowCanonicalTemplate).Assembly, + "StellaOps.Workflow.Engine.Tests.CanonicalTemplates.HomeWorkflow.register-home-quote.payload.json", + new global::System.Collections.Generic.Dictionary()); + + expression.Should().BeOfType(); + var objectExpression = (WorkflowObjectExpressionDefinition)expression; + objectExpression.Properties.Select(x => x.Name).Should().Contain( + [ + "ProductCode", + "AddressId", + "PolicyValues", + ]); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalExpressionRuntimeTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalExpressionRuntimeTests.cs new file mode 100644 index 000000000..91c0798b8 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalExpressionRuntimeTests.cs @@ -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 + { + ["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 + { + ["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 + { + ["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 + { + ["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("filters")[0]; + lookupFilter.GetRequiredProperty("prop").Should().Be("SrCust.CContacts.Details"); + lookupFilter.GetRequiredProperty("comparison").Should().Be("contains"); + lookupFilter.GetRequiredProperty("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("email").Should().Be("person@example.com"); + registerPayloadElement.GetRequiredProperty("custPid").Should().Be("8801010000"); + var contact = registerPayloadElement.GetRequiredProperty("cContacts")[0]; + contact.GetRequiredProperty("channelType").Should().Be("EMAIL"); + contact.GetRequiredProperty("details").Should().Be("person@example.com"); + contact.GetRequiredProperty("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("objectValues")[0] + .GetRequiredProperty("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("consistencyCheckUserResults"); + results.GetArrayLength().Should().Be(2); + results[0].GetRequiredProperty("accountExists").Should().BeTrue(); + results[1].GetRequiredProperty("accountExists").Should().BeTrue(); + results[0].GetRequiredProperty("passed").Should().BeTrue(); + results[1].GetRequiredProperty("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("file_id").Should().Be("70101"); + documents[0].GetRequiredProperty("provider").Should().Be("serdica"); + documents[0].GetRequiredProperty("mime_type").Should().Be("application/pdf"); + documents[0].GetRequiredProperty("metadata").GetRequiredProperty("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("file_id").Should().Be("73001"); + documents[1].GetRequiredProperty("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("toolName").Should().Be("motor_calculate_damage_cost"); + var parameters = payload.GetRequiredProperty("parameters"); + parameters.GetRequiredProperty("car_make").Should().Be("Toyota"); + parameters.GetRequiredProperty("car_model").Should().Be("Corolla"); + parameters.GetRequiredProperty("currency").Should().Be("BGN"); + parameters.GetRequiredProperty("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 + { + ["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("damageDetection").GetArrayLength().Should().Be(1); + payload.GetRequiredProperty("damageCost").GetRequiredProperty("amount").Should().Be(250.00m); + payload.GetRequiredProperty("damageCostBreakdown")[0].GetRequiredProperty("cost").Should().Be(250.00m); + payload.GetRequiredProperty("evaluationAmount").Should().Be(300.00m); + } + + private static WorkflowSpecExecutionContext CreateContext( + IWorkflowFunctionRuntime? functionRuntime = null) + { + return new WorkflowSpecExecutionContext( + "TestWorkflow", + new SampleStartRequest + { + SrPolicyId = 13, + }, + new Dictionary(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(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 arguments, + WorkflowCanonicalEvaluationContext context, + out object? result) + { + return provider.TryEvaluate(functionName, arguments, context, out result); + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalImportValidationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalImportValidationTests.cs new file mode 100644 index 000000000..66ef52611 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalImportValidationTests.cs @@ -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(); + + 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 + { + public string WorkflowName => "ExternalSignalImportWorkflow"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "External Signal Import Workflow"; + public IReadOnlyCollection WorkflowRoles => []; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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(); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalizationInventoryTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalizationInventoryTests.cs new file mode 100644 index 000000000..fc4197536 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowCanonicalizationInventoryTests.cs @@ -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 + { + 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<>)); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowDeclarativeBuilderTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowDeclarativeBuilderTests.cs new file mode 100644 index 000000000..cf4eb77ff --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowDeclarativeBuilderTests.cs @@ -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( + "Approve Application", + "ApproveQTApproveApplication", + "business/policies", + ["UR_UNDERWRITER", "APR_APPL"]) + .WithPayload(_ => new Dictionary(StringComparer.OrdinalIgnoreCase)) + .Build(); + + task.Descriptor.TaskRoles.Should().Equal("UR_UNDERWRITER", "APR_APPL"); + } + + [Test] + public void WorkflowSpec_WhenStartedWithServiceSequence_ShouldBuildWithoutInitialTaskAndWithoutTaskDescriptors() + { + var spec = WorkflowSpec.For() + .InitializeState(_ => new Dictionary(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() + .InitializeState(startRequest => new + { + startRequest.SrPolicyId, + isOpen = true, + }) + .StartWith(flow => flow.Complete()) + .Build(); + + var state = spec.InitializeState(new FakeStartRequest { SrPolicyId = 7100345L }); + + state["SrPolicyId"].Get().Should().Be(7100345L); + state["isOpen"].Get().Should().BeTrue(); + } + + [Test] + public void Call_WhenUsingAddressAndPayloadFactory_ShouldKeepPayloadOutsideAddress() + { + var task = CreateTask(flow => flow.Call( + "Perform Operations", + new Address("pas_operations", "pas_operations_perform"), + context => new + { + srPolicyId = context.StateValues["srPolicyId"].Get(), + operationType = "POLICY_ISSUING", + }, + "operations")); + + var step = task.OnComplete.Steps.Should().ContainSingle().Which + .Should().BeOfType>().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( + "Perform Operations", + new LegacyRabbitAddress("pas_operations_perform", WorkflowLegacyRabbitMode.MicroserviceConsumer), + context => new + { + srPolicyId = context.StateValues["srPolicyId"].Get(), + operationType = "POLICY_ISSUING", + }, + "operations")); + + var step = task.OnComplete.Steps.Should().ContainSingle().Which + .Should().BeOfType>().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(), + }, + whenFailure => whenFailure.Set("releaseDocNumbersFailed", true))); + + var step = task.OnComplete.Steps.Should().ContainSingle().Which + .Should().BeOfType>().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>(); + 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(), + }, + whenFailure => whenFailure.Set("releaseDocNumbersFailed", true))); + + var step = task.OnComplete.Steps.Should().ContainSingle().Which + .Should().BeOfType>().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>(); + step.FailureHandlers.WhenTimeout.Steps.Should().BeEmpty(); + } + + [Test] + public void Call_WhenUsingHttpAddress_ShouldBuildHttpStepDefinition() + { + var task = CreateTask(flow => flow.Call( + "Check Identity Data", + new HttpAddress("authority", "/api/account/consistencyCheckUsers"), + context => new + { + srPolicyId = context.StateValues["srPolicyId"].Get(), + }, + "identity")); + + var step = task.OnComplete.Steps.Should().ContainSingle().Which + .Should().BeOfType>().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( + "Check Identity Data", + new HttpAddress("authority", "/api/account/consistencyCheckUsers"), + context => new + { + srPolicyId = context.StateValues["srPolicyId"].Get(), + }, + whenFailure => whenFailure.Set("checkIdentityFailed", true), + whenTimeout => whenTimeout.Set("checkIdentityTimedOut", true), + resultKey: "identity")); + + var step = task.OnComplete.Steps.Should().ContainSingle().Which + .Should().BeOfType>().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(), + OptionDoSynchronousCall = true, + }, + whenFailure => whenFailure.Set("revertToApplication", true), + whenTimeout => whenTimeout.ContinueWith( + "Start Approve Application", + new WorkflowReference("ApproveApplication"), + context => new + { + srPolicyId = context.StateValues["srPolicyId"].Get(), + }))); + + var step = task.OnComplete.Steps.Should().ContainSingle().Which + .Should().BeOfType>().Subject; + + step.FailureHandlers.Should().NotBeNull(); + step.FailureHandlers!.WhenFailure.Steps.Should().ContainSingle(); + step.FailureHandlers.WhenTimeout.Steps.Should().ContainSingle() + .Which.Should().BeOfType>(); + } + + [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(), + }, + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete)); + + var step = task.OnComplete.Steps.Should().ContainSingle().Which + .Should().BeOfType>().Subject; + + step.FailureHandlers.Should().NotBeNull(); + step.FailureHandlers!.WhenFailure.Steps.Should().ContainSingle() + .Which.Should().BeOfType>(); + step.FailureHandlers.WhenTimeout.Steps.Should().ContainSingle() + .Which.Should().BeOfType>(); + } + + [Test] + public void QueryGraphql_WhenUsingAddressAndVariablesFactory_ShouldConvertVariablesToDictionary() + { + var task = CreateTask(flow => flow.QueryGraphql( + "Load Product", + new GraphqlAddress("pnc", "query Product($srPolicyId: Long!) { product(srPolicyId: $srPolicyId) { code } }", "Product"), + context => new + { + srPolicyId = context.StateValues["srPolicyId"].Get(), + }, + "product")); + + var step = task.OnComplete.Steps.Should().ContainSingle().Which + .Should().BeOfType>().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( + "Load Product", + new GraphqlAddress("pnc", "query Product($srPolicyId: Long!) { product(srPolicyId: $srPolicyId) { code } }", "Product"), + context => new + { + srPolicyId = context.StateValues["srPolicyId"].Get(), + }, + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete, + "product")); + + var step = task.OnComplete.Steps.Should().ContainSingle().Which + .Should().BeOfType>().Subject; + + step.FailureHandlers.Should().NotBeNull(); + step.FailureHandlers!.WhenFailure.Steps.Should().ContainSingle() + .Which.Should().BeOfType>(); + step.FailureHandlers.WhenTimeout.Steps.Should().ContainSingle() + .Which.Should().BeOfType>(); + } + + [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>().Subject; + + step.FailureHandlers.Should().NotBeNull(); + step.FailureHandlers!.WhenFailure.Steps.Should().ContainSingle() + .Which.Should().BeOfType>(); + step.FailureHandlers.WhenTimeout.Steps.Should().ContainSingle() + .Which.Should().BeOfType>(); + } + + [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(), + reason = "Approved", + }, + context => new WorkflowBusinessReference + { + Parts = new Dictionary + { + ["policyId"] = context.StateValues["srPolicyId"].Get(), + }, + })); + + var step = task.OnComplete.Steps.Should().ContainSingle().Which + .Should().BeOfType>().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(), "reject", StringComparison.OrdinalIgnoreCase), + whenTrue => whenTrue.Set("isRejected", true), + whenElse => whenElse.Set("isRejected", false))); + + var step = task.OnComplete.Steps.Should().ContainSingle().Which + .Should().BeOfType>().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().ToString(), + Parts = new Dictionary + { + ["policyId"] = context.StateValues["srPolicyId"].Get(), + }, + })); + + var step = task.OnComplete.Steps.Should().ContainSingle().Which + .Should().BeOfType>().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( + "Confirm Changes", + "CustomerChangeConfirmChanges", + "business/customers/person") + .WithRoute(context => string.Equals(context.StateValues["custType"].Get(), "P", StringComparison.OrdinalIgnoreCase) + ? "business/customers/person" + : "business/customers/legal-entity") + .WithPayload(context => new + { + srPolicyId = context.StateValues["srPolicyId"].Get(), + }) + .Build(); + + var context = new WorkflowSpecExecutionContext( + "CustomerOpenForChange", + new FakeStartRequest { SrPolicyId = 7100345L }, + new Dictionary(StringComparer.OrdinalIgnoreCase) + .Assign("srPolicyId", 7100345L) + .Assign("custType", "L"), + new Dictionary(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(), + }, + resultKey: "child")); + + var steps = task.OnComplete.Steps.ToArray(); + + steps[0].Should().BeOfType>(); + steps[1].Should().BeOfType>(); + steps[2].Should().BeOfType>(); + + var subWorkflow = (WorkflowSubWorkflowStepDefinition)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>(); + steps[1].Should().BeOfType>(); + + var signalStep = (WorkflowExternalSignalStepDefinition)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>().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 CreateTask( + Action> configure) + { + return WorkflowHumanTask.For( + "Approve Application", + "ApproveQTApproveApplication", + "business/policies") + .WithPayload(context => new + { + srPolicyId = context.StateValues["srPolicyId"].Get(), + }) + .OnComplete(configure); + } + + private static WorkflowSpecExecutionContext CreateContext(string answer = "approve") + { + return new WorkflowSpecExecutionContext( + "ApproveApplication", + new FakeStartRequest { SrPolicyId = 7100345L }, + new Dictionary(StringComparer.OrdinalIgnoreCase) + .Assign("srPolicyId", 7100345L), + new Dictionary(StringComparer.OrdinalIgnoreCase) + .Assign("answer", answer)); + } + + private sealed record FakeStartRequest + { + public long SrPolicyId { get; init; } + } + + private sealed record OperationsResponse; + + private sealed record GraphqlResponse; +} + + diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowDecompilerOutputTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowDecompilerOutputTests.cs new file mode 100644 index 000000000..85319e217 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowDecompilerOutputTests.cs @@ -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; + +/// +/// Renders decompiled C# source and canonical JSON for all workflows +/// into docs/decompiled-samples/ for visual inspection. +/// +[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(); + var catalog = provider.GetRequiredService(); + + 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"); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowHostedJobLockServiceTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowHostedJobLockServiceTests.cs new file mode 100644 index 000000000..9b11ad705 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowHostedJobLockServiceTests.cs @@ -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(); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRenderingPipelineTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRenderingPipelineTests.cs new file mode 100644 index 000000000..085906a39 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRenderingPipelineTests.cs @@ -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(); + + 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(); + var compiler = provider.GetRequiredService(); + + 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(); + var compiler = provider.GetRequiredService(); + + 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), + StartRequestType = typeof(Dictionary), + 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), + StartRequestType = typeof(Dictionary), + 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), + StartRequestType = typeof(Dictionary), + 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), + StartRequestType = typeof(Dictionary), + 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), + StartRequestType = typeof(Dictionary), + 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()); + // + // 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()); + // + // 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(); + var compiler = provider.GetRequiredService(); + var resolver = provider.GetRequiredService(); + + 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(); + var compiler = provider.GetRequiredService(); + var resolver = provider.GetRequiredService(); + + 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(); + var compiler = provider.GetRequiredService(); + + 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(); + var compiler = provider.GetRequiredService(); + + 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(); + var compiler = provider.GetRequiredService(); + + 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 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()); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRenderingTestHelpers.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRenderingTestHelpers.cs new file mode 100644 index 000000000..05fcd82b1 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRenderingTestHelpers.cs @@ -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() + .GetRegistrations() + .OrderBy(x => x.Definition.WorkflowName, StringComparer.OrdinalIgnoreCase) + .ThenByDescending(x => x.Definition.WorkflowVersion, WorkflowVersioning.SemanticComparer) + .ToArray(); + var store = provider.GetRequiredService(); + var selectedNames = new List(count ?? registrations.Length); + var seenNames = new HashSet(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 workflowNames, + string outputRoot, + CancellationToken cancellationToken = default) + { + var store = provider.GetRequiredService(); + var compiler = provider.GetRequiredService(); + var resolver = provider.GetRequiredService(); + 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()); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRetentionServiceTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRetentionServiceTests.cs new file mode 100644 index 000000000..ebb2814e4 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRetentionServiceTests.cs @@ -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(); + var dbContext = provider.GetRequiredService(); + var retentionService = provider.GetRequiredService(); + var runtimeStateStore = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["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(); + var dbContext = provider.GetRequiredService(); + var retentionService = provider.GetRequiredService(); + var runtimeStateStore = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["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 + { + ["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 + { + ["WorkflowRetention:OpenStaleAfterDays"] = "30", + ["WorkflowRetention:CompletedPurgeAfterDays"] = "180", + ["GenericAssignmentPermissions:AdminRoles:0"] = "DBA", + }) + .Build(); + + services.AddLogging(); + TechnicalStyleWorkflowTestHelpers.RegisterTestWorkflows(services); + services.AddWorkflowEngineCoreServices(configuration); + services.AddDbContext(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddScoped(_ => transport ?? new RecordingWorkflowLegacyRabbitTransport()); + + var provider = services.BuildServiceProvider(); + // ServiceProviderAccessor.Initialize(provider); + return provider; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRoundTripCompilerTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRoundTripCompilerTests.cs new file mode 100644 index 000000000..6827b9ab4 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRoundTripCompilerTests.cs @@ -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; + +/// +/// 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. +/// +[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(); + references = BuildMetadataReferences(); + } + + [OneTimeTearDown] + public void Teardown() + { + provider?.Dispose(); + } + + private static IEnumerable AllDeclarativeWorkflows() + { + var transport = new RecordingWorkflowLegacyRabbitTransport(); + using var tempProvider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider( + transport, + WorkflowRuntimeProviderNames.Engine); + var catalog = tempProvider.GetRequiredService(); + + 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, + }); + } + + /// + /// Strips startRequest (schema + contractName differ between original CLR type and dynamic type) + /// and requiredModules (inferred at compile time, may differ) for fair comparison. + /// + private static string NormalizeForComparison(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var dict = new Dictionary(); + + 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(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(); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeDefinitionStoreTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeDefinitionStoreTests.cs new file mode 100644 index 000000000..435172c21 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeDefinitionStoreTests.cs @@ -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()); + + 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()); + + var act = () => store.GetRequiredDefinition(InvalidRuntimeDefinitionWorkflow.WorkflowNameValue); + + act.Should().Throw() + .WithMessage("*WFCD001*"); + } + + [Test] + public void GetRequiredDefinition_WhenAnotherDeclarativeWorkflowIsInvalid_ShouldStillResolveUnrelatedDefinition() + { + var store = CreateStore( + BuildPlainRegistration("PlainRuntimeDefinitionWorkflow", "1.0.0"), + BuildRegistration()); + + 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() + where TWorkflow : class, ISerdicaWorkflow, 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), + 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 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 modules) : IWorkflowModuleCatalog + { + public IReadOnlyCollection GetInstalledModules() + { + return modules; + } + } + + private sealed class FakeCustomWorkflowExecutionHandler : IWorkflowExecutionHandler + { + public Task StartAsync( + WorkflowStartExecutionContext context, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task CompleteTaskAsync( + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } + + private sealed record RuntimeDefinitionStartRequest + { + public long SrPolicyId { get; init; } + } + + private sealed class ValidRuntimeDefinitionWorkflow : IDeclarativeWorkflow + { + public const string WorkflowNameValue = "ValidRuntimeDefinitionWorkflow"; + + public string WorkflowName => WorkflowNameValue; + + public string WorkflowVersion => "1.0.0"; + + public string DisplayName => "Valid Runtime Definition Workflow"; + + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState( + WorkflowExpr.Obj( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")))) + .StartWith( + WorkflowHumanTask.For( + "Review", + "ReviewRuntimeDefinition", + "workflow/review") + .WithPayload( + WorkflowExpr.Obj( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId")))) + .Build()) + .Build(); + } + + private sealed class InvalidRuntimeDefinitionWorkflow : IDeclarativeWorkflow + { + public const string WorkflowNameValue = "InvalidRuntimeDefinitionWorkflow"; + + public string WorkflowName => WorkflowNameValue; + + public string WorkflowVersion => "1.0.0"; + + public string DisplayName => "Invalid Runtime Definition Workflow"; + + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState(startRequest => new Dictionary + { + ["srPolicyId"] = JsonSerializer.SerializeToElement(startRequest.SrPolicyId), + }) + .StartWith( + WorkflowHumanTask.For( + "Review", + "ReviewRuntimeDefinition", + "workflow/review") + .WithPayload( + WorkflowExpr.Obj( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId")))) + .Build()) + .Build(); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeRecoveryTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeRecoveryTests.cs new file mode 100644 index 000000000..25039896f --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeRecoveryTests.cs @@ -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(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = TimerRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["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(); + var processed = await worker.RunOnceAsync("workflow-service", CancellationToken.None); + var runtimeService = provider.GetRequiredService(); + + 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(), + }); + + 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(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = ExternalSignalRecoveryWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["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(); + var worker = provider.GetRequiredService(); + + var raiseResponse = await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = workflowInstanceId, + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["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 + { + ["WorkflowRetention:OpenStaleAfterDays"] = "30", + ["WorkflowRetention:CompletedPurgeAfterDays"] = "180", + ["WorkflowRuntime:DefaultProvider"] = WorkflowRuntimeProviderNames.Engine, + ["WorkflowRuntime:EnabledProviders:0"] = WorkflowRuntimeProviderNames.Engine, + ["WorkflowAq:ConsumerName"] = "workflow-service", + }) + .Build(); + + services.AddLogging(); + services.AddWorkflowRegistration(); + services.AddWorkflowRegistration(); + services.AddWorkflowEngineCoreServices(configuration); + services.AddDbContext(options => options.UseInMemoryDatabase(databaseName, databaseRoot)); + services.Replace(ServiceDescriptor.Singleton( + _ => new PersistedWorkflowRuntimeStateStore(runtimeStateBacking))); + services.Replace(ServiceDescriptor.Scoped( + _ => new PersistedWorkflowSignalBus(signalBacking))); + services.Replace(ServiceDescriptor.Scoped( + _ => 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(), + _ => throw new AssertionException("Value is not a string."), + }; + } + + private static bool ReadBool(object? value) + { + return value switch + { + bool boolean => boolean, + JsonElement jsonElement => jsonElement.Get(), + _ => 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 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 GetAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default) + { + backing.Records.TryGetValue(workflowInstanceId, out var state); + return Task.FromResult(state); + } + + public Task> GetManyAsync( + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken = default) + { + return Task.FromResult>( + workflowInstanceIds + .Where(backing.Records.ContainsKey) + .Select(id => backing.Records[id]) + .ToArray()); + } + + public Task MarkStaleAsync( + IReadOnlyCollection 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 DeleteAsync( + IReadOnlyCollection 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 queuedSignals = []; + private readonly List<(WorkflowSignalEnvelope Signal, DateTime DueAtUtc)> scheduledSignals = []; + private readonly List completedSignals = []; + private readonly List deadLetterSignals = []; + + public IReadOnlyCollection<(WorkflowSignalEnvelope Signal, DateTime DueAtUtc)> ScheduledSignals + { + get + { + lock (gate) + { + return scheduledSignals + .Select(x => (CloneEnvelope(x.Signal), x.DueAtUtc)) + .ToArray(); + } + } + } + + public IReadOnlyCollection 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 ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + var signal = backing.TryReceive(); + return Task.FromResult( + 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 + { + 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 WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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( + "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 + { + 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 WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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( + "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), + }; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeServiceTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeServiceTests.cs new file mode 100644 index 000000000..f496fb734 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeServiceTests.cs @@ -0,0 +1,1162 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Engine.Constants; +using StellaOps.Workflow.Engine.Exceptions; +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 WorkflowRuntimeServiceTests +{ + [Test] + public async Task StartWorkflowAsync_WhenApproveApplicationStarted_ShouldPersistInstanceAndTaskProjection() + { + using var provider = CreateServiceProvider(); + var runtimeService = provider.GetRequiredService(); + + var response = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 1200345M, + ["srAnnexId"] = 9988M, + ["srCustId"] = 7788M, + }, + }); + + response.WorkflowName.Should().Be("ApproveApplication"); + response.WorkflowVersion.Should().Be("1.0.0"); + response.BusinessReference.Should().NotBeNull(); + response.BusinessReference!.Key.Should().Be("1200345"); + response.BusinessReference.Parts.Should().ContainKey("policyId").WhoseValue.Should().Be(1200345L); + + var tasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + BusinessReferenceKey = response.BusinessReference.Key, + ActorId = "user-1", + ActorRoles = ["APR_APPL"], + }); + + tasks.Tasks.Should().ContainSingle(); + tasks.Tasks.Single().WorkflowRoles.Should().Contain("APR_APPL"); + tasks.Tasks.Single().TaskRoles.Should().BeEmpty(); + tasks.Tasks.Single().EffectiveRoles.Should().Contain("APR_APPL"); + tasks.Tasks.Single().AllowedActions.Should().BeEquivalentTo(["AssignSelf", "Complete"]); + tasks.Tasks.Single().Payload.Should().ContainKey("srAnnexId"); + + var instances = await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest + { + BusinessReferenceKey = response.BusinessReference.Key, + }); + + instances.Instances.Should().ContainSingle(); + instances.Instances.Single().WorkflowInstanceId.Should().Be(response.WorkflowInstanceId); + instances.Instances.Single().Status.Should().Be("Open"); + instances.Instances.Single().RuntimeProvider.Should().Be("Stella.InProcess"); + instances.Instances.Single().RuntimeInstanceId.Should().Be(response.WorkflowInstanceId); + instances.Instances.Single().RuntimeStatus.Should().Be("Open"); + + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = response.WorkflowInstanceId, + }); + + instance.WorkflowState.Should().ContainKey("srPolicyId"); + instance.WorkflowState.Should().ContainKey("policySubstatus"); + instance.Tasks.Should().ContainSingle(); + instance.TaskEvents.Should().ContainSingle(x => x.EventType == "Created"); + instance.RuntimeState.Should().NotBeNull(); + instance.RuntimeState!.State.Should().ContainKey("srPolicyId"); + } + + [Test] + public async Task StartWorkflowAsync_WhenRuntimeStateDiffersFromProjectedState_ShouldPersistRuntimeSnapshotSeparately() + { + using var provider = CreateServiceProvider( + customOrchestrator: new FakeWorkflowRuntimeOrchestrator()); + var runtimeService = provider.GetRequiredService(); + + var response = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 9900345M, + ["srAnnexId"] = 10993M, + ["srCustId"] = 8793M, + }, + }); + + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = response.WorkflowInstanceId, + }); + + instance.WorkflowState.Should().ContainKey("projectedState"); + instance.RuntimeState.Should().NotBeNull(); + instance.RuntimeState!.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + instance.RuntimeState.State.Should().ContainKey("bookmarkCount"); + instance.RuntimeState.State.Should().ContainKey("subStatus"); + instance.RuntimeState.State.Should().NotContainKey("projectedState"); + } + + [Test] + public async Task StartWorkflowAsync_WhenEngineConfigured_ShouldPersistEngineRuntimeSnapshot() + { + using var provider = CreateServiceProvider(defaultRuntimeProvider: WorkflowRuntimeProviderNames.Engine); + var runtimeService = provider.GetRequiredService(); + + var response = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 8800345M, + ["srAnnexId"] = 18181M, + ["srCustId"] = 8811M, + }, + }); + + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = response.WorkflowInstanceId, + }); + + instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + instance.Instance.RuntimeInstanceId.Should().Be(response.WorkflowInstanceId); + instance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + instance.RuntimeState.Should().NotBeNull(); + instance.RuntimeState!.State.Should().ContainKey("engineSchemaVersion"); + instance.RuntimeState.State.Should().ContainKey("workflowState"); + instance.RuntimeState.State.Should().ContainKey("waiting"); + instance.Tasks.Should().ContainSingle(); + instance.Tasks.Single().Payload.Should().ContainKey(WorkflowRuntimePayloadKeys.RuntimeTaskTokenPayloadKey); + instance.Tasks.Single().Payload.Should().ContainKey(WorkflowRuntimePayloadKeys.RuntimeProviderPayloadKey); + } + + [Test] + public async Task StartWorkflowAsync_WhenEngineReturnsContinuation_ShouldPublishInternalContinueSignal() + { + var signalBus = new RecordingWorkflowSignalBus(); + using var provider = CreateServiceProvider( + customOrchestrator: new FakeContinuationWorkflowRuntimeOrchestrator( + WorkflowRuntimeProviderNames.Engine, + emitContinuationOnStart: true, + emitContinuationOnComplete: false), + customSignalBus: signalBus, + defaultRuntimeProvider: WorkflowRuntimeProviderNames.Engine); + var runtimeService = provider.GetRequiredService(); + + await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 9800345M, + ["srAnnexId"] = 19001M, + ["srCustId"] = 9901M, + }, + }); + + signalBus.PublishedEnvelopes.Should().ContainSingle(); + signalBus.PublishedEnvelopes[0].SignalType.Should().Be(WorkflowSignalTypes.InternalContinue); + signalBus.PublishedEnvelopes[0].RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + signalBus.ReadStartWorkflowRequest(signalBus.PublishedEnvelopes[0]) + .WorkflowName.Should().Be("ApproveApplication"); + + var instances = await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest()); + instances.Instances.Should().HaveCount(1); + } + + [Test] + public async Task StartWorkflowAsync_WhenNonEngineProviderReturnsContinuation_ShouldStartChildWorkflowImmediately() + { + var signalBus = new RecordingWorkflowSignalBus(); + using var provider = CreateServiceProvider( + customOrchestrator: new FakeContinuationWorkflowRuntimeOrchestrator( + WorkflowRuntimeProviderNames.InProcess, + emitContinuationOnStart: true, + emitContinuationOnComplete: false), + customSignalBus: signalBus); + var runtimeService = provider.GetRequiredService(); + + await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 9700345M, + ["srAnnexId"] = 19002M, + ["srCustId"] = 9902M, + }, + }); + + signalBus.PublishedEnvelopes.Should().BeEmpty(); + + var instances = await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest()); + instances.Instances.Should().HaveCount(2); + } + + [Test] + public async Task CompleteTaskAsync_WhenEngineReturnsContinuation_ShouldPublishInternalContinueSignal() + { + var signalBus = new RecordingWorkflowSignalBus(); + using var provider = CreateServiceProvider( + customOrchestrator: new FakeContinuationWorkflowRuntimeOrchestrator( + WorkflowRuntimeProviderNames.Engine, + emitContinuationOnStart: false, + emitContinuationOnComplete: true), + customSignalBus: signalBus, + defaultRuntimeProvider: WorkflowRuntimeProviderNames.Engine); + var runtimeService = provider.GetRequiredService(); + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 9600345M, + ["srAnnexId"] = 19003M, + ["srCustId"] = 9903M, + }, + }); + 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 + { + ["answer"] = "approve", + }, + }); + + signalBus.PublishedEnvelopes.Should().ContainSingle(); + signalBus.PublishedEnvelopes[0].SignalType.Should().Be(WorkflowSignalTypes.InternalContinue); + } + + [Test] + public async Task StartWorkflowAsync_WhenEngineReturnsScheduledContinuation_ShouldUseScheduleBus() + { + var signalBus = new RecordingWorkflowSignalBus(); + var scheduleBus = new RecordingWorkflowScheduleBus(); + using var provider = CreateServiceProvider( + customOrchestrator: new FakeContinuationWorkflowRuntimeOrchestrator( + WorkflowRuntimeProviderNames.Engine, + emitContinuationOnStart: true, + emitContinuationOnComplete: false, + continuationDueAtUtc: DateTime.UtcNow.AddMinutes(5)), + customSignalBus: signalBus, + customScheduleBus: scheduleBus, + defaultRuntimeProvider: WorkflowRuntimeProviderNames.Engine); + var runtimeService = provider.GetRequiredService(); + + await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 9550345M, + ["srAnnexId"] = 19005M, + ["srCustId"] = 9905M, + }, + }); + + signalBus.PublishedEnvelopes.Should().BeEmpty(); + scheduleBus.ScheduledSignals.Should().ContainSingle(); + scheduleBus.ScheduledSignals[0].Signal.SignalType.Should().Be(WorkflowSignalTypes.InternalContinue); + scheduleBus.ScheduledSignals[0].DueAtUtc.Should().BeAfter(DateTime.UtcNow.AddMinutes(4)); + } + + [Test] + public async Task RaiseExternalSignalAsync_WhenEngineInstanceExists_ShouldQueueExternalSignalEnvelope() + { + var signalBus = new RecordingWorkflowSignalBus(); + using var provider = CreateServiceProvider( + customSignalBus: signalBus, + defaultRuntimeProvider: WorkflowRuntimeProviderNames.Engine); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 9540345M, + ["srAnnexId"] = 19006M, + ["srCustId"] = 9906M, + }, + }); + + var response = await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + WaitingToken = "external-1", + SignalName = "documents-uploaded", + Payload = new Dictionary + { + ["documentId"] = 9001L, + ["uploadedBy"] = "user-1", + }, + }); + + response.WorkflowInstanceId.Should().Be(startResponse.WorkflowInstanceId); + response.SignalId.Should().NotBeNullOrWhiteSpace(); + response.Queued.Should().BeTrue(); + + signalBus.PublishedEnvelopes.Should().ContainSingle(); + signalBus.PublishedEnvelopes[0].SignalType.Should().Be(WorkflowSignalTypes.ExternalSignal); + signalBus.PublishedEnvelopes[0].RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + signalBus.PublishedEnvelopes[0].ExpectedVersion.Should().Be(1L); + signalBus.PublishedEnvelopes[0].WaitingToken.Should().Be("external-1"); + signalBus.PublishedEnvelopes[0].Payload.Should().ContainKey(WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey); + signalBus.PublishedEnvelopes[0].Payload.Should().ContainKey("documentId"); + signalBus.PublishedEnvelopes[0].Payload.Should().ContainKey("uploadedBy"); + signalBus.PublishedEnvelopes[0].Payload[WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey] + .GetString().Should().Be("documents-uploaded"); + signalBus.PublishedEnvelopes[0].Payload["documentId"].GetInt64().Should().Be(9001L); + signalBus.PublishedEnvelopes[0].Payload["uploadedBy"].GetString().Should().Be("user-1"); + } + + [Test] + public async Task GetTaskAsync_WhenActorContextProvided_ShouldReturnAllowedActionsForAdminAndTaskUser() + { + using var provider = CreateServiceProvider(); + var runtimeService = provider.GetRequiredService(); + + var response = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 1200456M, + ["srAnnexId"] = 9987M, + ["srCustId"] = 7787M, + }, + }); + + var taskId = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = response.WorkflowInstanceId, + })).Tasks.Single().WorkflowTaskId; + + var workflowUserTask = await runtimeService.GetTaskAsync(new WorkflowTaskGetRequest + { + WorkflowTaskId = taskId, + ActorId = "user-1", + ActorRoles = ["APR_APPL"], + }); + + workflowUserTask.Task.AllowedActions.Should().BeEquivalentTo(["AssignSelf", "Complete"]); + + var adminTask = await runtimeService.GetTaskAsync(new WorkflowTaskGetRequest + { + WorkflowTaskId = taskId, + ActorId = "admin-1", + ActorRoles = ["DBA"], + }); + + adminTask.Task.AllowedActions.Should().BeEquivalentTo(["AssignSelf", "AssignOther", "AssignRoles", "Release", "Complete"]); + } + + [Test] + public async Task CompleteTaskAsync_WhenOperationsFail_ShouldReopenApproveTaskWithRuntimeRoles() + { + var transport = new RecordingWorkflowLegacyRabbitTransport() + .Respond("pas_operations_perform", new + { + Passed = false, + StageFailures = new Dictionary + { + ["underwriting"] = 1, + ["confirmation"] = 0, + }, + ErrorsBypassRoles = new[] { "UR_UNDERWRITER", "APR_APPL" }, + }, WorkflowLegacyRabbitMode.MicroserviceConsumer); + + using var provider = CreateServiceProvider(transport); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 2200345M, + ["srAnnexId"] = 9989M, + ["srCustId"] = 7789M, + }, + }); + + var task = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + })).Tasks.Single(); + + var completeResponse = await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = task.WorkflowTaskId, + ActorId = "user-1", + ActorRoles = ["APR_APPL"], + Payload = new Dictionary + { + ["answer"] = "approve", + }, + }); + + completeResponse.Completed.Should().BeTrue(); + + var tasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + tasks.Tasks.Should().HaveCount(2); + tasks.Tasks.Should().Contain(x => x.WorkflowTaskId == task.WorkflowTaskId && x.Status == "Completed"); + tasks.Tasks.Should().Contain(x => + x.WorkflowTaskId != task.WorkflowTaskId + && x.Status == "Open" + && x.EffectiveRoles.Contains("UR_UNDERWRITER") + && x.Payload.ContainsKey("srAnnexId")); + + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + instance.Instance.Status.Should().Be("Open"); + instance.Instance.CompletedOnUtc.Should().BeNull(); + } + + [Test] + public async Task CompleteTaskAsync_WhenEngineOperationsFail_ShouldReopenApproveTaskWithRuntimeRoles() + { + var transport = new RecordingWorkflowLegacyRabbitTransport() + .Respond("pas_operations_perform", new + { + Passed = false, + StageFailures = new Dictionary + { + ["underwriting"] = 1, + ["confirmation"] = 0, + }, + ErrorsBypassRoles = new[] { "UR_UNDERWRITER", "APR_APPL" }, + }, WorkflowLegacyRabbitMode.MicroserviceConsumer); + + using var provider = CreateServiceProvider( + transport, + defaultRuntimeProvider: WorkflowRuntimeProviderNames.Engine); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 2210345M, + ["srAnnexId"] = 9989M, + ["srCustId"] = 7789M, + }, + }); + + 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 + { + ["answer"] = "approve", + }, + }); + + var tasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + tasks.Tasks.Should().HaveCount(2); + tasks.Tasks.Should().Contain(x => x.WorkflowTaskId == task.WorkflowTaskId && x.Status == "Completed"); + tasks.Tasks.Should().Contain(x => + x.WorkflowTaskId != task.WorkflowTaskId + && x.Status == "Open" + && x.RuntimeRoles.Contains("UR_UNDERWRITER") + && x.Payload.ContainsKey("srAnnexId")); + instance.Instance.Status.Should().Be("Open"); + instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + instance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + instance.RuntimeState!.State.Should().ContainKey("workflowState"); + } + + [Test] + public async Task AssignTaskAsync_WhenAssigningAnotherUserWithoutAdminRole_ShouldThrow() + { + using var provider = CreateServiceProvider(); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 3200345M, + ["srAnnexId"] = 9990M, + ["srCustId"] = 7790M, + }, + }); + + var task = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + })).Tasks.Single(); + + var action = async () => await runtimeService.AssignTaskAsync(new WorkflowTaskAssignRequest + { + WorkflowTaskId = task.WorkflowTaskId, + ActorId = "user-1", + TargetUserId = "user-2", + ActorRoles = ["APR_APPL"], + }); + + await action.Should().ThrowAsync() + .Where(x => x.MessageId == Constants.MessageKeys.WorkflowTaskActionDenied); + } + + [Test] + public async Task AssignTaskAsync_WhenAssigningToRoleGroupWithAdminRole_ShouldRecalculateEffectiveRolesAndReleaseTask() + { + using var provider = CreateServiceProvider(); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 3300345M, + ["srAnnexId"] = 9990M, + ["srCustId"] = 7790M, + }, + }); + + var task = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + })).Tasks.Single(); + + var assignResponse = await runtimeService.AssignTaskAsync(new WorkflowTaskAssignRequest + { + WorkflowTaskId = task.WorkflowTaskId, + ActorId = "admin-1", + ActorRoles = ["DBA"], + TargetRoles = ["UR_UNDERWRITER"], + }); + + assignResponse.Assignee.Should().BeNull(); + assignResponse.Status.Should().Be("Open"); + assignResponse.RuntimeRoles.Should().BeEquivalentTo(["UR_UNDERWRITER"]); + assignResponse.EffectiveRoles.Should().BeEquivalentTo(["UR_UNDERWRITER"]); + + var updatedTask = await runtimeService.GetTaskAsync(new WorkflowTaskGetRequest + { + WorkflowTaskId = task.WorkflowTaskId, + ActorId = "admin-1", + ActorRoles = ["DBA"], + }); + + updatedTask.Task.Assignee.Should().BeNull(); + updatedTask.Task.Status.Should().Be("Open"); + updatedTask.Task.RuntimeRoles.Should().BeEquivalentTo(["UR_UNDERWRITER"]); + updatedTask.Task.EffectiveRoles.Should().BeEquivalentTo(["UR_UNDERWRITER"]); + updatedTask.Task.AllowedActions.Should().BeEquivalentTo(["AssignOther", "AssignRoles", "Release", "Complete"]); + + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + instance.TaskEvents.Should().Contain(x => + x.WorkflowTaskId == task.WorkflowTaskId + && x.EventType == "Reassigned"); + } + + // TODO: Requires Serdica-specific types (StartWorkflowEndpoint, WorkflowTasksGetEndpoint from Engine.Endpoints) + // [Test] + // public async Task StartAndQueryEndpoints_WhenInvokedDirectly_ShouldReturnWorkflowData() + // { + // using var provider = CreateServiceProvider(); + // var runtimeService = provider.GetRequiredService(); + // var startEndpoint = new StartWorkflowEndpoint(runtimeService); + // var tasksEndpoint = new WorkflowTasksGetEndpoint(runtimeService); + // + // var startResponse = await startEndpoint.ConsumeAsync(new StartWorkflowRequest + // { + // WorkflowName = "ApproveApplication", + // Payload = new Dictionary + // { + // ["srPolicyId"] = 4200345M, + // ["srAnnexId"] = 9991M, + // ["srCustId"] = 7791M, + // }, + // }); + // + // var tasksResponse = await tasksEndpoint.ConsumeAsync(new WorkflowTasksGetRequest + // { + // WorkflowInstanceId = startResponse.WorkflowInstanceId, + // ActorId = "user-1", + // ActorRoles = ["APR_APPL"], + // }); + // + // tasksResponse.Tasks.Should().ContainSingle(); + // tasksResponse.Tasks.Single().BusinessReference!.Key.Should().Be("4200345"); + // tasksResponse.Tasks.Single().AllowedActions.Should().BeEquivalentTo(["AssignSelf", "Complete"]); + // } + + [Test] + public async Task AssignReleaseAndComplete_WhenHappyPathSucceeds_ShouldCompleteWorkflowInstance() + { + var transport = new RecordingWorkflowLegacyRabbitTransport() + .Respond("pas_operations_perform", new + { + Passed = true, + StageFailures = new Dictionary(), + ErrorsBypassRoles = Array.Empty(), + }, WorkflowLegacyRabbitMode.MicroserviceConsumer) + .Respond("pas_polreg_convertapltopol", new { Converted = true }) + .Respond("pas_annexprocessing_generatepolicyno", new { PolicyNo = "POL-0001" }) + .Respond("pas_get_policy_product_info", new + { + ProductCode = "4710", + Lob = "MTR", + ContractType = "STANDARD", + }); + + using var provider = CreateServiceProvider(transport); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 5200345M, + ["srAnnexId"] = 9992M, + ["srCustId"] = 7792M, + }, + }); + + var task = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + })).Tasks.Single(); + + var assignResponse = await runtimeService.AssignTaskAsync(new WorkflowTaskAssignRequest + { + WorkflowTaskId = task.WorkflowTaskId, + ActorId = "user-1", + ActorRoles = ["APR_APPL"], + }); + + assignResponse.Assignee.Should().Be("user-1"); + + var releaseResponse = await runtimeService.ReleaseTaskAsync(new WorkflowTaskReleaseRequest + { + WorkflowTaskId = task.WorkflowTaskId, + ActorId = "user-1", + ActorRoles = ["APR_APPL"], + }); + + releaseResponse.Released.Should().BeTrue(); + + var completeResponse = await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = task.WorkflowTaskId, + ActorId = "user-1", + ActorRoles = ["APR_APPL"], + Payload = new Dictionary + { + ["answer"] = "approve", + }, + }); + + completeResponse.Completed.Should().BeTrue(); + + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + instance.Instance.Status.Should().Be("Completed"); + instance.Instance.CompletedOnUtc.Should().NotBeNull(); + } + + [Test] + public async Task AssignReleaseAndComplete_WhenEngineHappyPathSucceeds_ShouldCompleteWorkflowInstance() + { + var transport = new RecordingWorkflowLegacyRabbitTransport() + .Respond("pas_operations_perform", new + { + Passed = true, + StageFailures = new Dictionary(), + ErrorsBypassRoles = Array.Empty(), + }, WorkflowLegacyRabbitMode.MicroserviceConsumer) + .Respond("pas_polreg_convertapltopol", new { Converted = true }) + .Respond("pas_annexprocessing_generatepolicyno", new { PolicyNo = "POL-0001" }) + .Respond("pas_get_policy_product_info", new + { + ProductCode = "4710", + Lob = "MTR", + ContractType = "STANDARD", + }); + + using var provider = CreateServiceProvider( + transport, + defaultRuntimeProvider: WorkflowRuntimeProviderNames.Engine); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 5210345M, + ["srAnnexId"] = 9992M, + ["srCustId"] = 7792M, + }, + }); + + var task = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + })).Tasks.Single(); + + await runtimeService.AssignTaskAsync(new WorkflowTaskAssignRequest + { + WorkflowTaskId = task.WorkflowTaskId, + ActorId = "user-1", + ActorRoles = ["APR_APPL"], + }); + + await runtimeService.ReleaseTaskAsync(new WorkflowTaskReleaseRequest + { + WorkflowTaskId = task.WorkflowTaskId, + ActorId = "user-1", + ActorRoles = ["APR_APPL"], + }); + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = task.WorkflowTaskId, + ActorId = "user-1", + ActorRoles = ["APR_APPL"], + Payload = new Dictionary + { + ["answer"] = "approve", + }, + }); + + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + instance.Instance.Status.Should().Be("Completed"); + instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + instance.Instance.RuntimeStatus.Should().Be("Completed"); + instance.Instance.CompletedOnUtc.Should().NotBeNull(); + instance.Tasks.Should().ContainSingle(x => x.Status == "Completed"); + } + + [Test] + public async Task CompleteTaskAsync_WhenRejected_ShouldCompleteWorkflowWithoutFurtherTasks() + { + var transport = new RecordingWorkflowLegacyRabbitTransport() + .Respond("pas_annexprocessing_cancelaplorqt", new { Cancelled = true }); + + using var provider = CreateServiceProvider(transport); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 6200345M, + ["srAnnexId"] = 9993M, + ["srCustId"] = 7793M, + }, + }); + + var task = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + })).Tasks.Single(); + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = task.WorkflowTaskId, + ActorId = "user-2", + ActorRoles = ["APR_APPL"], + Payload = new Dictionary + { + ["answer"] = "reject", + }, + }); + + var tasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + tasks.Tasks.Should().ContainSingle(x => x.Status == "Completed"); + + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + ActorId = "user-2", + ActorRoles = ["APR_APPL"], + }); + + instance.Instance.Status.Should().Be("Completed"); + instance.Instance.RuntimeProvider.Should().Be("Stella.InProcess"); + instance.Instance.RuntimeInstanceId.Should().Be(startResponse.WorkflowInstanceId); + instance.Instance.RuntimeStatus.Should().Be("Completed"); + instance.Tasks.Should().ContainSingle(); + instance.Tasks.Should().OnlyContain(x => x.AllowedActions.Count == 0); + } + + [Test] + public async Task CompleteTaskAsync_WhenEngineRejected_ShouldCompleteWorkflowWithoutFurtherTasks() + { + var transport = new RecordingWorkflowLegacyRabbitTransport() + .Respond("pas_annexprocessing_cancelaplorqt", new { Cancelled = true }); + + using var provider = CreateServiceProvider( + transport, + defaultRuntimeProvider: WorkflowRuntimeProviderNames.Engine); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 6210345M, + ["srAnnexId"] = 9993M, + ["srCustId"] = 7793M, + }, + }); + + var task = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + })).Tasks.Single(); + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = task.WorkflowTaskId, + ActorId = "user-2", + ActorRoles = ["APR_APPL"], + Payload = new Dictionary + { + ["answer"] = "reject", + }, + }); + + var tasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + var instance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + ActorId = "user-2", + ActorRoles = ["APR_APPL"], + }); + + tasks.Tasks.Should().ContainSingle(x => x.Status == "Completed"); + instance.Instance.Status.Should().Be("Completed"); + instance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + instance.Instance.RuntimeStatus.Should().Be("Completed"); + instance.Tasks.Should().ContainSingle(); + } + + private static ServiceProvider CreateServiceProvider( + RecordingWorkflowLegacyRabbitTransport? transport = null, + IWorkflowRuntimeOrchestrator? customOrchestrator = null, + IWorkflowSignalBus? customSignalBus = null, + IWorkflowScheduleBus? customScheduleBus = null, + string? defaultRuntimeProvider = null) + { + var services = new ServiceCollection(); + var settings = new Dictionary + { + ["WorkflowRetention:OpenStaleAfterDays"] = "30", + ["WorkflowRetention:CompletedPurgeAfterDays"] = "180", + ["GenericAssignmentPermissions:AdminRoles:0"] = "DBA", + }; + + if (!string.IsNullOrWhiteSpace(defaultRuntimeProvider)) + { + settings["WorkflowRuntime:DefaultProvider"] = defaultRuntimeProvider; + } + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(settings) + .Build(); + + services.AddLogging(); + TechnicalStyleWorkflowTestHelpers.RegisterTestWorkflows(services); + services.AddWorkflowEngineCoreServices(configuration); + services.AddWorkflowModule("transport.legacy-rabbit", "1.0.0"); + services.AddDbContext(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddScoped(_ => transport ?? new RecordingWorkflowLegacyRabbitTransport()); + + if (customOrchestrator is not null) + { + services.AddScoped(_ => customOrchestrator); + } + + if (customSignalBus is not null) + { + services.AddScoped(_ => customSignalBus); + } + + if (customScheduleBus is not null) + { + services.AddScoped(_ => customScheduleBus); + } + + var provider = services.BuildServiceProvider(); + // ServiceProviderAccessor.Initialize(provider); // TODO: Requires Serdica-specific types + return provider; + } + private sealed class FakeWorkflowRuntimeOrchestrator : IWorkflowRuntimeOrchestrator + { + public Task StartAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + StartWorkflowRequest request, + object startRequest, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new WorkflowRuntimeExecutionResult + { + RuntimeProvider = WorkflowRuntimeProviderNames.Engine, + RuntimeInstanceId = "engine-runtime-1", + RuntimeStatus = "Suspended", + InstanceStatus = "Open", + WorkflowState = new Dictionary + { + ["projectedState"] = JsonSerializer.SerializeToElement("oracle-projection"), + }, + RuntimeState = new Dictionary + { + ["bookmarkCount"] = 1, + ["subStatus"] = "Suspended", + }, + Tasks = + [ + new WorkflowExecutionTaskPlan + { + TaskName = "Approve Application", + TaskType = "ApproveQTApproveApplication", + Route = "business/policies", + }, + ], + }); + } + + public Task CompleteAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task ResumeAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowSignalExecutionContext context, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } + + private sealed class FakeContinuationWorkflowRuntimeOrchestrator( + string runtimeProvider, + bool emitContinuationOnStart, + bool emitContinuationOnComplete, + DateTime? continuationDueAtUtc = null) : IWorkflowRuntimeOrchestrator + { + private int startCalls; + private int completeCalls; + + public Task StartAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + StartWorkflowRequest request, + object startRequest, + CancellationToken cancellationToken = default) + { + startCalls++; + var shouldEmitContinuation = emitContinuationOnStart && startCalls == 1; + + return Task.FromResult(new WorkflowRuntimeExecutionResult + { + RuntimeProvider = runtimeProvider, + RuntimeInstanceId = shouldEmitContinuation ? $"runtime-start-{startCalls}" : null, + RuntimeStatus = shouldEmitContinuation ? "Completed" : "Open", + InstanceStatus = shouldEmitContinuation ? "Completed" : "Open", + BusinessReference = businessReference, + WorkflowState = new Dictionary + { + ["call"] = JsonSerializer.SerializeToElement(startCalls), + }, + Tasks = shouldEmitContinuation + ? [] + : + [ + new WorkflowExecutionTaskPlan + { + TaskName = "Approve Application", + TaskType = "ApproveQTApproveApplication", + Route = "business/policies", + }, + ], + Continuations = shouldEmitContinuation + ? [BuildContinuation(continuationDueAtUtc)] + : [], + }); + } + + public Task CompleteAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default) + { + completeCalls++; + var shouldEmitContinuation = emitContinuationOnComplete && completeCalls == 1; + + return Task.FromResult(new WorkflowRuntimeExecutionResult + { + RuntimeProvider = runtimeProvider, + RuntimeInstanceId = context.WorkflowInstanceId, + RuntimeStatus = "Completed", + InstanceStatus = "Completed", + BusinessReference = context.CurrentTask.BusinessReference, + WorkflowState = context.WorkflowState, + Continuations = shouldEmitContinuation + ? [BuildContinuation(continuationDueAtUtc)] + : [], + }); + } + + public Task ResumeAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowSignalExecutionContext context, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + private static WorkflowContinuationPlan BuildContinuation(DateTime? dueAtUtc) + { + return new WorkflowContinuationPlan + { + Request = new StartWorkflowRequest + { + WorkflowName = "ApproveApplication", + Payload = new Dictionary + { + ["srPolicyId"] = 9500345M, + ["srAnnexId"] = 19004M, + ["srCustId"] = 9904M, + }, + }, + DueAtUtc = dueAtUtc, + }; + } + } + + private sealed class RecordingWorkflowSignalBus : IWorkflowSignalBus + { + public List PublishedEnvelopes { get; } = []; + + public Task PublishAsync(WorkflowSignalEnvelope envelope, CancellationToken cancellationToken = default) + { + PublishedEnvelopes.Add(envelope); + return Task.CompletedTask; + } + + public Task PublishDeadLetterAsync(WorkflowSignalEnvelope envelope, CancellationToken cancellationToken = default) + { + PublishedEnvelopes.Add(envelope); + return Task.CompletedTask; + } + + public Task ReceiveAsync(string consumerName, CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } + + public StartWorkflowRequest ReadStartWorkflowRequest(WorkflowSignalEnvelope envelope) + { + return envelope.Payload[WorkflowSignalPayloadKeys.StartWorkflowRequestPayloadKey] + .Deserialize() + ?? throw new InvalidOperationException("Continuation payload missing start request."); + } + } + + private sealed class RecordingWorkflowScheduleBus : IWorkflowScheduleBus + { + public List<(WorkflowSignalEnvelope Signal, DateTime DueAtUtc)> ScheduledSignals { get; } = []; + + public Task ScheduleAsync( + WorkflowSignalEnvelope envelope, + DateTime dueAtUtc, + CancellationToken cancellationToken = default) + { + ScheduledSignals.Add((envelope, dueAtUtc)); + return Task.CompletedTask; + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeServiceTransactionTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeServiceTransactionTests.cs new file mode 100644 index 000000000..0ff66735e --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeServiceTransactionTests.cs @@ -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(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = TransactionalSignalWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["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(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = TransactionalSignalWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["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 + { + ["WorkflowRetention:OpenStaleAfterDays"] = "30", + ["WorkflowRetention:CompletedPurgeAfterDays"] = "180", + }) + .Build(); + + services.AddLogging(); + services.AddWorkflowRegistration(); + services.AddWorkflowEngineCoreServices(configuration); + services.AddDbContext(options => options.UseSqlite(connection)); + services.AddScoped(); + services.AddSingleton(runtimeStateStore); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + + var provider = services.BuildServiceProvider(); + // ServiceProviderAccessor.Initialize(provider); // TODO: Requires Serdica-specific types + + using var scope = provider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + 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(), + _ => 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 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 + { + ["phase"] = JsonSerializer.SerializeToElement("waiting"), + }, + RuntimeState = CreateRuntimeState( + version: 1, + phase: "waiting", + waitingToken: "external-wait"), + }); + } + + public Task CompleteAsync( + WorkflowRegistration registration, + WorkflowDefinitionDescriptor definition, + WorkflowTaskExecutionContext context, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task 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 + { + ["phase"] = JsonSerializer.SerializeToElement("resumed"), + }, + RuntimeState = CreateRuntimeState( + version: 2, + phase: "resumed"), + Tasks = + [ + new WorkflowExecutionTaskPlan + { + TaskName = "Review Documents", + TaskType = "ReviewDocuments", + Route = "workflow/review", + }, + ], + }); + } + + private static Dictionary CreateRuntimeState( + long version, + string phase, + string? waitingToken = null) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["engineSchemaVersion"] = 1, + ["version"] = version, + ["status"] = WorkflowInstanceStatuses.Open, + ["workflowState"] = new Dictionary + { + ["phase"] = phase, + }, + ["waiting"] = waitingToken is null + ? null + : new Dictionary + { + ["kind"] = "Signal", + ["signalType"] = WorkflowSignalTypes.ExternalSignal, + ["token"] = waitingToken, + ["resumeState"] = new Dictionary(), + }, + }; + } + } + + 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 GetAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default) + { + return Task.FromResult( + state is not null && string.Equals(state.WorkflowInstanceId, workflowInstanceId, StringComparison.OrdinalIgnoreCase) + ? state + : null); + } + + public Task> GetManyAsync( + IReadOnlyCollection workflowInstanceIds, + CancellationToken cancellationToken = default) + { + if (state is null || !workflowInstanceIds.Contains(state.WorkflowInstanceId, StringComparer.OrdinalIgnoreCase)) + { + return Task.FromResult>([]); + } + + return Task.FromResult>([state]); + } + + public Task MarkStaleAsync( + IReadOnlyCollection workflowInstanceIds, + DateTime updatedOnUtc, + CancellationToken cancellationToken = default) + { + return Task.FromResult(0); + } + + public Task DeleteAsync( + IReadOnlyCollection 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 ReceiveAsync(string consumerName, CancellationToken cancellationToken = default) + { + return Task.FromResult(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 + { + public const string WorkflowNameValue = "TransactionalSignalWorkflow"; + + public string WorkflowName => WorkflowNameValue; + + public string WorkflowVersion => "1.0.0"; + + public string DisplayName => "Transactional Signal Workflow"; + + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + + public IReadOnlyCollection Tasks => []; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeSignalResumeTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeSignalResumeTests.cs new file mode 100644 index 000000000..6a6252a06 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowRuntimeSignalResumeTests.cs @@ -0,0 +1,1155 @@ +using System; +using System.Collections.Generic; +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.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +using NUnit.Framework; + +namespace StellaOps.Workflow.Engine.Tests; + +[TestFixture] +public class WorkflowRuntimeSignalResumeTests +{ + [Test] + public async Task StartAndResumeTimerWorkflow_WhenSignalMatches_ShouldAdvanceSameInstanceAndComplete() + { + var signalBus = new RecordingWorkflowSignalBus(); + var scheduleBus = new RecordingWorkflowScheduleBus(); + using var provider = CreateServiceProvider(signalBus, scheduleBus); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = TimerResumeWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 730001L, + }, + }); + + startResponse.WorkflowName.Should().Be(TimerResumeWorkflow.WorkflowNameValue); + signalBus.PublishedEnvelopes.Should().BeEmpty(); + scheduleBus.ScheduledSignals.Should().ContainSingle(); + + var scheduledSignal = scheduleBus.ScheduledSignals[0].Signal; + scheduledSignal.SignalType.Should().Be(WorkflowSignalTypes.TimerDue); + scheduledSignal.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + scheduledSignal.ExpectedVersion.Should().Be(1L); + scheduledSignal.WaitingToken.Should().NotBeNullOrWhiteSpace(); + scheduleBus.ScheduledSignals[0].DueAtUtc.Should().BeAfter(DateTime.UtcNow.AddMinutes(4)); + + var waitingTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + + waitingTasks.Tasks.Should().BeEmpty(); + + var waitingInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + waitingInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Open); + waitingInstance.Instance.RuntimeProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + waitingInstance.Instance.RuntimeStatus.Should().Be("WaitingForSignal"); + ReadString(waitingInstance.WorkflowState["phase"]).Should().Be("waiting"); + ReadBool(waitingInstance.WorkflowState["timerFired"]).Should().BeFalse(); + ReadLong(waitingInstance.RuntimeState!.State["version"]).Should().Be(1L); + + await runtimeService.ResumeSignalAsync(scheduledSignal); + + var resumedTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var resumedTask = resumedTasks.Tasks.Should().ContainSingle().Subject; + + resumedTask.TaskName.Should().Be("After Timer Review"); + ReadString(resumedTask.Payload["phase"]).Should().Be("after-timer"); + ReadBool(resumedTask.Payload["timerFired"]).Should().BeTrue(); + + var resumedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + resumedInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Open); + resumedInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + resumedInstance.TaskEvents.Should().ContainSingle(x => x.EventType == WorkflowTaskEventTypes.Created); + ReadString(resumedInstance.WorkflowState["phase"]).Should().Be("after-timer"); + ReadBool(resumedInstance.WorkflowState["timerFired"]).Should().BeTrue(); + ReadLong(resumedInstance.RuntimeState!.State["version"]).Should().Be(2L); + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = resumedTask.WorkflowTaskId, + ActorId = "timer-user", + ActorRoles = ["DBA"], + Payload = new Dictionary(), + }); + + var completedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + var remainingOpenTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var allInstances = await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest()); + + completedInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed); + completedInstance.Instance.RuntimeStatus.Should().Be(WorkflowInstanceStatuses.Completed); + ReadLong(completedInstance.RuntimeState!.State["version"]).Should().Be(3L); + remainingOpenTasks.Tasks.Should().BeEmpty(); + completedInstance.Tasks.Should().ContainSingle(x => x.Status == WorkflowTaskStatuses.Completed); + allInstances.Instances.Should().ContainSingle(); + } + + [Test] + public async Task ResumeSignalAsync_WhenWaitingTokenDoesNotMatch_ShouldLeaveInstanceWaiting() + { + var signalBus = new RecordingWorkflowSignalBus(); + var scheduleBus = new RecordingWorkflowScheduleBus(); + using var provider = CreateServiceProvider(signalBus, scheduleBus); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = TimerResumeWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 730002L, + }, + }); + + var originalSignal = scheduleBus.ScheduledSignals.Should().ContainSingle().Subject.Signal; + + await runtimeService.ResumeSignalAsync(originalSignal with + { + SignalId = "ignored-signal", + WaitingToken = "other-token", + }); + + var waitingTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var waitingInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + var allInstances = await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest()); + + waitingTasks.Tasks.Should().BeEmpty(); + waitingInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Open); + waitingInstance.Instance.RuntimeStatus.Should().Be("WaitingForSignal"); + ReadString(waitingInstance.WorkflowState["phase"]).Should().Be("waiting"); + ReadBool(waitingInstance.WorkflowState["timerFired"]).Should().BeFalse(); + ReadLong(waitingInstance.RuntimeState!.State["version"]).Should().Be(1L); + allInstances.Instances.Should().ContainSingle(); + } + + [Test] + public async Task StartAndResumeRepeatedTimerWorkflow_WhenRepeatBodyWaits_ShouldScheduleNextIterationAndOpenTaskAfterLastResume() + { + var signalBus = new RecordingWorkflowSignalBus(); + var scheduleBus = new RecordingWorkflowScheduleBus(); + using var provider = CreateServiceProvider(signalBus, scheduleBus); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = RepeatTimerResumeWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 730101L, + }, + }); + + signalBus.PublishedEnvelopes.Should().BeEmpty(); + scheduleBus.ScheduledSignals.Should().ContainSingle(); + + var firstSignal = scheduleBus.ScheduledSignals[0].Signal; + firstSignal.SignalType.Should().Be(WorkflowSignalTypes.TimerDue); + firstSignal.ExpectedVersion.Should().Be(1L); + firstSignal.WaitingToken.Should().NotBeNullOrWhiteSpace(); + + var startedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + startedInstance.Instance.RuntimeStatus.Should().Be("WaitingForSignal"); + ReadString(startedInstance.WorkflowState["phase"]).Should().Be("repeat-loop"); + ReadLong(startedInstance.WorkflowState["printInsisAttempt"]).Should().Be(1L); + ReadLong(startedInstance.WorkflowState["lastCompletedAttempt"]).Should().Be(0L); + ReadLong(startedInstance.RuntimeState!.State["version"]).Should().Be(1L); + + await runtimeService.ResumeSignalAsync(firstSignal); + + scheduleBus.ScheduledSignals.Should().HaveCount(2); + var secondSignal = scheduleBus.ScheduledSignals[1].Signal; + secondSignal.SignalType.Should().Be(WorkflowSignalTypes.TimerDue); + secondSignal.ExpectedVersion.Should().Be(2L); + secondSignal.WaitingToken.Should().NotBe(firstSignal.WaitingToken); + + var afterFirstResumeTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var afterFirstResumeInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + afterFirstResumeTasks.Tasks.Should().BeEmpty(); + afterFirstResumeInstance.Instance.RuntimeStatus.Should().Be("WaitingForSignal"); + ReadString(afterFirstResumeInstance.WorkflowState["phase"]).Should().Be("repeat-loop"); + ReadLong(afterFirstResumeInstance.WorkflowState["printInsisAttempt"]).Should().Be(2L); + ReadLong(afterFirstResumeInstance.WorkflowState["lastCompletedAttempt"]).Should().Be(1L); + ReadLong(afterFirstResumeInstance.RuntimeState!.State["version"]).Should().Be(2L); + + await runtimeService.ResumeSignalAsync(secondSignal); + + var finalOpenTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var finalTask = finalOpenTasks.Tasks.Should().ContainSingle().Subject; + var beforeCompletionInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + finalTask.TaskName.Should().Be("After Repeat Review"); + ReadString(finalTask.Payload["phase"]).Should().Be("after-repeat"); + ReadLong(finalTask.Payload["printInsisAttempt"]).Should().Be(2L); + ReadLong(finalTask.Payload["lastCompletedAttempt"]).Should().Be(2L); + beforeCompletionInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadLong(beforeCompletionInstance.RuntimeState!.State["version"]).Should().Be(3L); + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = finalTask.WorkflowTaskId, + ActorId = "repeat-user", + ActorRoles = ["DBA"], + Payload = new Dictionary(), + }); + + var completedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + completedInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Completed); + completedInstance.Instance.RuntimeStatus.Should().Be(WorkflowInstanceStatuses.Completed); + ReadLong(completedInstance.RuntimeState!.State["version"]).Should().Be(4L); + completedInstance.Tasks.Should().ContainSingle(x => x.Status == WorkflowTaskStatuses.Completed); + } + + [Test] + public async Task StartAndResumeForkTimerWorkflow_WhenParallelTimerBranchCompletes_ShouldJoinAndOpenReviewTask() + { + var signalBus = new RecordingWorkflowSignalBus(); + var scheduleBus = new RecordingWorkflowScheduleBus(); + using var provider = CreateServiceProvider(signalBus, scheduleBus); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = ForkTimerResumeWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 740101L, + }, + }); + + scheduleBus.ScheduledSignals.Should().ContainSingle(); + var waitingSignal = scheduleBus.ScheduledSignals[0].Signal; + waitingSignal.SignalType.Should().Be(WorkflowSignalTypes.TimerDue); + + var startedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + startedInstance.Instance.RuntimeStatus.Should().Be("WaitingForSignal"); + ReadString(startedInstance.WorkflowState["phase"]).Should().Be("fork-started"); + ReadLong(startedInstance.RuntimeState!.State["version"]).Should().Be(1L); + + await runtimeService.ResumeSignalAsync(waitingSignal); + + var openTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var openTask = openTasks.Tasks.Should().ContainSingle().Subject; + + openTask.TaskName.Should().Be("Fork Timer Review"); + ReadString(openTask.Payload["phase"]).Should().Be("after-fork"); + ReadBool(openTask.Payload["leftDone"]).Should().BeTrue(); + ReadBool(openTask.Payload["rightDone"]).Should().BeTrue(); + + var joinedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + joinedInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadString(joinedInstance.WorkflowState["phase"]).Should().Be("after-fork"); + ReadBool(joinedInstance.WorkflowState["leftDone"]).Should().BeTrue(); + ReadBool(joinedInstance.WorkflowState["rightDone"]).Should().BeTrue(); + ReadLong(joinedInstance.RuntimeState!.State["version"]).Should().Be(2L); + } + + [Test] + public async Task StartAndCompleteForkTaskWorkflow_WhenSiblingAlreadyCompleted_ShouldJoinAndOpenFollowUpTask() + { + var signalBus = new RecordingWorkflowSignalBus(); + var scheduleBus = new RecordingWorkflowScheduleBus(); + using var provider = CreateServiceProvider(signalBus, scheduleBus); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = ForkTaskResumeWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 740201L, + }, + }); + + var initialOpenTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var branchTask = initialOpenTasks.Tasks.Should().ContainSingle().Subject; + + branchTask.TaskName.Should().Be("Left Fork Review"); + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = branchTask.WorkflowTaskId, + ActorId = "fork-task-user", + ActorRoles = ["DBA"], + Payload = new Dictionary(), + }); + + var nextOpenTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var nextTask = nextOpenTasks.Tasks.Should().ContainSingle().Subject; + + nextTask.TaskName.Should().Be("After Fork Task Review"); + ReadString(nextTask.Payload["phase"]).Should().Be("after-fork-task"); + ReadBool(nextTask.Payload["leftDone"]).Should().BeTrue(); + ReadBool(nextTask.Payload["rightDone"]).Should().BeTrue(); + + var joinedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + joinedInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadString(joinedInstance.WorkflowState["phase"]).Should().Be("after-fork-task"); + ReadBool(joinedInstance.WorkflowState["leftDone"]).Should().BeTrue(); + ReadBool(joinedInstance.WorkflowState["rightDone"]).Should().BeTrue(); + ReadLong(joinedInstance.RuntimeState!.State["version"]).Should().Be(2L); + } + + [Test] + public async Task StartCompleteTaskAndResumeTimer_WhenForkHasMixedOpenBranches_ShouldKeepCoordinatorUntilAllBranchesFinish() + { + var signalBus = new RecordingWorkflowSignalBus(); + var scheduleBus = new RecordingWorkflowScheduleBus(); + using var provider = CreateServiceProvider(signalBus, scheduleBus); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = ForkTaskTimerResumeWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 740301L, + }, + }); + + var initialOpenTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var branchTask = initialOpenTasks.Tasks.Should().ContainSingle().Subject; + var waitingSignal = scheduleBus.ScheduledSignals.Should().ContainSingle().Subject.Signal; + + branchTask.TaskName.Should().Be("Fork Approval"); + + await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest + { + WorkflowTaskId = branchTask.WorkflowTaskId, + ActorId = "fork-mixed-user", + ActorRoles = ["DBA"], + Payload = new Dictionary(), + }); + + var afterTaskTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var afterTaskInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + afterTaskTasks.Tasks.Should().BeEmpty(); + afterTaskInstance.Instance.RuntimeStatus.Should().Be("WaitingForSignal"); + ReadString(afterTaskInstance.WorkflowState["phase"]).Should().Be("mixed-start"); + ReadLong(afterTaskInstance.RuntimeState!.State["version"]).Should().Be(2L); + + await runtimeService.ResumeSignalAsync(waitingSignal); + + var afterSignalTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var joinedTask = afterSignalTasks.Tasks.Should().ContainSingle().Subject; + + joinedTask.TaskName.Should().Be("After Mixed Fork Review"); + ReadString(joinedTask.Payload["phase"]).Should().Be("after-mixed"); + ReadBool(joinedTask.Payload["approvalDone"]).Should().BeTrue(); + ReadBool(joinedTask.Payload["timerDone"]).Should().BeTrue(); + + var joinedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + joinedInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadString(joinedInstance.WorkflowState["phase"]).Should().Be("after-mixed"); + ReadBool(joinedInstance.WorkflowState["approvalDone"]).Should().BeTrue(); + ReadBool(joinedInstance.WorkflowState["timerDone"]).Should().BeTrue(); + ReadLong(joinedInstance.RuntimeState!.State["version"]).Should().Be(3L); + } + + [Test] + public async Task StartAndRaiseExternalSignal_WhenWorkflowWaitsForExternalSignal_ShouldResumeSameInstanceWithoutAutoDispatch() + { + var signalBus = new RecordingWorkflowSignalBus(); + var scheduleBus = new RecordingWorkflowScheduleBus(); + using var provider = CreateServiceProvider(signalBus, scheduleBus); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = ExternalSignalResumeWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 740001L, + }, + }); + + signalBus.PublishedEnvelopes.Should().BeEmpty(); + scheduleBus.ScheduledSignals.Should().BeEmpty(); + + var waitingInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + waitingInstance.Instance.Status.Should().Be(WorkflowInstanceStatuses.Open); + waitingInstance.Instance.RuntimeStatus.Should().Be("WaitingForSignal"); + ReadString(waitingInstance.WorkflowState["phase"]).Should().Be("waiting-external"); + ReadLong(waitingInstance.RuntimeState!.State["version"]).Should().Be(1L); + + var raiseResponse = await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Payload = new Dictionary + { + ["documentId"] = 4001L, + ["uploadedBy"] = "user-1", + }, + }); + + raiseResponse.Queued.Should().BeTrue(); + signalBus.PublishedEnvelopes.Should().ContainSingle(); + + var queuedSignal = signalBus.PublishedEnvelopes[0]; + queuedSignal.SignalType.Should().Be(WorkflowSignalTypes.ExternalSignal); + queuedSignal.ExpectedVersion.Should().Be(1L); + queuedSignal.WaitingToken.Should().NotBeNullOrWhiteSpace(); + queuedSignal.Payload.Should().ContainKey(WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey); + queuedSignal.Payload[WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey].GetString() + .Should().Be("documents-uploaded"); + queuedSignal.Payload["documentId"].GetInt64().Should().Be(4001L); + + await runtimeService.ResumeSignalAsync(queuedSignal); + + var resumedTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var resumedTask = resumedTasks.Tasks.Should().ContainSingle().Subject; + + resumedTask.TaskName.Should().Be("Review Uploaded Documents"); + ReadLong(resumedTask.Payload["documentId"]).Should().Be(4001L); + ReadString(resumedTask.Payload["phase"]).Should().Be("after-external"); + + var resumedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + resumedInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadString(resumedInstance.WorkflowState["phase"]).Should().Be("after-external"); + ReadLong(resumedInstance.WorkflowState["documentId"]).Should().Be(4001L); + ReadLong(resumedInstance.RuntimeState!.State["version"]).Should().Be(2L); + } + + [Test] + public async Task ResumeSignalAsync_WhenExternalSignalNameDoesNotMatch_ShouldLeaveInstanceWaiting() + { + var signalBus = new RecordingWorkflowSignalBus(); + var scheduleBus = new RecordingWorkflowScheduleBus(); + using var provider = CreateServiceProvider(signalBus, scheduleBus); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = ExternalSignalResumeWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 740002L, + }, + }); + + var waitingInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + var runtimeState = JsonSerializer.Serialize(waitingInstance.RuntimeState!.State); + var expectedVersion = ReadLong(waitingInstance.RuntimeState.State["version"]); + using var runtimeDocument = JsonDocument.Parse(runtimeState); + var waitingToken = runtimeDocument.RootElement + .GetProperty("waiting") + .GetProperty("token") + .GetString(); + + await runtimeService.ResumeSignalAsync(new WorkflowSignalEnvelope + { + SignalId = "external-mismatch", + WorkflowInstanceId = startResponse.WorkflowInstanceId, + RuntimeProvider = WorkflowRuntimeProviderNames.Engine, + SignalType = WorkflowSignalTypes.ExternalSignal, + ExpectedVersion = expectedVersion, + WaitingToken = waitingToken, + Payload = new Dictionary + { + [WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey] = JsonSerializer.SerializeToElement("other-signal"), + ["documentId"] = JsonSerializer.SerializeToElement(4002L), + }, + }); + + var resumedTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var resumedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + resumedTasks.Tasks.Should().BeEmpty(); + resumedInstance.Instance.RuntimeStatus.Should().Be("WaitingForSignal"); + ReadString(resumedInstance.WorkflowState["phase"]).Should().Be("waiting-external"); + ReadLong(resumedInstance.RuntimeState!.State["version"]).Should().Be(1L); + } + + [Test] + public async Task RaiseExternalSignalAsync_WhenForkHasMultipleExternalWaits_ShouldMatchBySignalNameAndJoinAfterSecondResume() + { + var signalBus = new RecordingWorkflowSignalBus(); + var scheduleBus = new RecordingWorkflowScheduleBus(); + using var provider = CreateServiceProvider(signalBus, scheduleBus); + var runtimeService = provider.GetRequiredService(); + + var startResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = ForkExternalSignalResumeWorkflow.WorkflowNameValue, + Payload = new Dictionary + { + ["srPolicyId"] = 740401L, + }, + }); + + await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + SignalName = "documents-right", + Payload = new Dictionary + { + ["documentId"] = 9102L, + }, + }); + + signalBus.PublishedEnvelopes.Should().ContainSingle(); + var rightSignal = signalBus.PublishedEnvelopes[0]; + rightSignal.Payload[WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey].GetString() + .Should().Be("documents-right"); + + await runtimeService.ResumeSignalAsync(rightSignal); + + var afterFirstSignalTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var afterFirstSignalInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + afterFirstSignalTasks.Tasks.Should().BeEmpty(); + afterFirstSignalInstance.Instance.RuntimeStatus.Should().Be("WaitingForSignal"); + ReadLong(afterFirstSignalInstance.RuntimeState!.State["version"]).Should().Be(2L); + + signalBus.PublishedEnvelopes.Clear(); + + await runtimeService.RaiseExternalSignalAsync(new WorkflowSignalRaiseRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + SignalName = "documents-left", + Payload = new Dictionary + { + ["documentId"] = 9101L, + }, + }); + + signalBus.PublishedEnvelopes.Should().ContainSingle(); + var leftSignal = signalBus.PublishedEnvelopes[0]; + leftSignal.Payload[WorkflowSignalPayloadKeys.ExternalSignalNamePayloadKey].GetString() + .Should().Be("documents-left"); + + await runtimeService.ResumeSignalAsync(leftSignal); + + var joinedTasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + Status = WorkflowTaskStatuses.Open, + }); + var joinedTask = joinedTasks.Tasks.Should().ContainSingle().Subject; + + joinedTask.TaskName.Should().Be("Review Dual Signals"); + ReadLong(joinedTask.Payload["leftDocumentId"]).Should().Be(9101L); + ReadLong(joinedTask.Payload["rightDocumentId"]).Should().Be(9102L); + ReadString(joinedTask.Payload["phase"]).Should().Be("after-dual-signal"); + + var joinedInstance = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = startResponse.WorkflowInstanceId, + }); + + joinedInstance.Instance.RuntimeStatus.Should().Be("WaitingForTask"); + ReadLong(joinedInstance.RuntimeState!.State["version"]).Should().Be(3L); + ReadLong(joinedInstance.WorkflowState["leftDocumentId"]).Should().Be(9101L); + ReadLong(joinedInstance.WorkflowState["rightDocumentId"]).Should().Be(9102L); + } + + private static ServiceProvider CreateServiceProvider( + RecordingWorkflowSignalBus signalBus, + RecordingWorkflowScheduleBus scheduleBus) + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["WorkflowRetention:OpenStaleAfterDays"] = "30", + ["WorkflowRetention:CompletedPurgeAfterDays"] = "180", + ["WorkflowRuntime:DefaultProvider"] = WorkflowRuntimeProviderNames.Engine, + ["WorkflowRuntime:EnabledProviders:0"] = WorkflowRuntimeProviderNames.Engine, + }) + .Build(); + + services.AddLogging(); + services.AddWorkflowRegistration(); + services.AddWorkflowRegistration(); + services.AddWorkflowRegistration(); + services.AddWorkflowRegistration(); + services.AddWorkflowRegistration(); + services.AddWorkflowRegistration(); + services.AddWorkflowRegistration(); + services.AddWorkflowEngineCoreServices(configuration); + services.AddDbContext(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddSingleton(signalBus); + services.AddSingleton(scheduleBus); + services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + + 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(), + _ => throw new AssertionException("Value is not a string."), + }; + } + + private static bool ReadBool(object? value) + { + return value switch + { + bool boolean => boolean, + JsonElement jsonElement => jsonElement.Get(), + _ => 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 RecordingWorkflowSignalBus : IWorkflowSignalBus + { + public List PublishedEnvelopes { get; } = []; + + public Task PublishAsync(WorkflowSignalEnvelope envelope, CancellationToken cancellationToken = default) + { + PublishedEnvelopes.Add(envelope); + return Task.CompletedTask; + } + + public Task PublishDeadLetterAsync(WorkflowSignalEnvelope envelope, CancellationToken cancellationToken = default) + { + PublishedEnvelopes.Add(envelope); + return Task.CompletedTask; + } + + public Task ReceiveAsync(string consumerName, CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } + } + + private sealed class RecordingWorkflowScheduleBus : IWorkflowScheduleBus + { + public List<(WorkflowSignalEnvelope Signal, DateTime DueAtUtc)> ScheduledSignals { get; } = []; + + public Task ScheduleAsync( + WorkflowSignalEnvelope envelope, + DateTime dueAtUtc, + CancellationToken cancellationToken = default) + { + ScheduledSignals.Add((envelope, dueAtUtc)); + return Task.CompletedTask; + } + } + + private sealed record TimerResumeStartRequest + { + [WorkflowBusinessId] + [WorkflowBusinessReferencePart("policyId")] + public long SrPolicyId { get; init; } + } + + private sealed class TimerResumeWorkflow : IDeclarativeWorkflow + { + public const string WorkflowNameValue = "TimerResumeWorkflow"; + public const string WorkflowVersionValue = "1.0.0"; + + public string WorkflowName => WorkflowNameValue; + public string WorkflowVersion => WorkflowVersionValue; + public string DisplayName => "Timer Resume Workflow"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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( + "After Timer Review", + "AfterTimerReview", + "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 Review", WorkflowExpr.String("00:05:00")) + .Set("timerFired", true) + .Set("phase", "after-timer") + .ActivateTask("After Timer Review")) + .Build(); + } + + private sealed record RepeatTimerResumeStartRequest + { + [WorkflowBusinessId] + [WorkflowBusinessReferencePart("policyId")] + public long SrPolicyId { get; init; } + } + + private sealed class RepeatTimerResumeWorkflow : IDeclarativeWorkflow + { + public const string WorkflowNameValue = "RepeatTimerResumeWorkflow"; + public const string WorkflowVersionValue = "1.0.0"; + + public string WorkflowName => WorkflowNameValue; + public string WorkflowVersion => WorkflowVersionValue; + public string DisplayName => "Repeat Timer Resume Workflow"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState( + WorkflowExpr.Obj( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")), + WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")), + WorkflowExpr.Prop("printInsisAttempt", WorkflowExpr.Number(0)), + WorkflowExpr.Prop("lastCompletedAttempt", WorkflowExpr.Number(0)))) + .AddTask( + WorkflowHumanTask.For( + "After Repeat Review", + "AfterRepeatReview", + "business/policies", + ["DBA"]) + .WithPayload( + WorkflowExpr.Obj( + WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase")), + WorkflowExpr.Prop("printInsisAttempt", WorkflowExpr.Path("state.printInsisAttempt")), + WorkflowExpr.Prop("lastCompletedAttempt", WorkflowExpr.Path("state.lastCompletedAttempt")))) + .OnComplete(flow => flow.Complete())) + .StartWith(flow => flow + .Set("phase", "repeat-loop") + .Repeat( + "Repeat Pending Print Wait", + WorkflowExpr.Number(3), + "printInsisAttempt", + WorkflowExpr.Lt( + WorkflowExpr.Path("state.printInsisAttempt"), + WorkflowExpr.Number(2)), + repeat => repeat + .Wait("Wait Between Print Attempts", WorkflowExpr.String("00:00:00")) + .Set("lastCompletedAttempt", WorkflowExpr.Path("state.printInsisAttempt"))) + .Set("phase", "after-repeat") + .ActivateTask("After Repeat Review")) + .Build(); + } + + private sealed record ForkTimerResumeStartRequest + { + [WorkflowBusinessId] + [WorkflowBusinessReferencePart("policyId")] + public long SrPolicyId { get; init; } + } + + private sealed class ForkTimerResumeWorkflow : IDeclarativeWorkflow + { + public const string WorkflowNameValue = "ForkTimerResumeWorkflow"; + public const string WorkflowVersionValue = "1.0.0"; + + public string WorkflowName => WorkflowNameValue; + public string WorkflowVersion => WorkflowVersionValue; + public string DisplayName => "Fork Timer Resume Workflow"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState( + WorkflowExpr.Obj( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")), + WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")), + WorkflowExpr.Prop("leftDone", WorkflowExpr.Bool(false)), + WorkflowExpr.Prop("rightDone", WorkflowExpr.Bool(false)))) + .AddTask( + WorkflowHumanTask.For( + "Fork Timer Review", + "ForkTimerReview", + "business/policies", + ["DBA"]) + .WithPayload( + WorkflowExpr.Obj( + WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase")), + WorkflowExpr.Prop("leftDone", WorkflowExpr.Path("state.leftDone")), + WorkflowExpr.Prop("rightDone", WorkflowExpr.Path("state.rightDone")))) + .OnComplete(flow => flow.Complete())) + .StartWith(flow => flow + .Set("phase", "fork-started") + .Fork( + "Parallel Timer Branches", + left => left + .Wait("Wait Left Branch", WorkflowExpr.String("00:00:00")) + .Set("leftDone", true), + right => right + .Set("rightDone", true)) + .Set("phase", "after-fork") + .ActivateTask("Fork Timer Review")) + .Build(); + } + + private sealed record ForkTaskResumeStartRequest + { + [WorkflowBusinessId] + [WorkflowBusinessReferencePart("policyId")] + public long SrPolicyId { get; init; } + } + + private sealed class ForkTaskResumeWorkflow : IDeclarativeWorkflow + { + public const string WorkflowNameValue = "ForkTaskResumeWorkflow"; + public const string WorkflowVersionValue = "1.0.0"; + + public string WorkflowName => WorkflowNameValue; + public string WorkflowVersion => WorkflowVersionValue; + public string DisplayName => "Fork Task Resume Workflow"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState( + WorkflowExpr.Obj( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")), + WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")), + WorkflowExpr.Prop("leftDone", WorkflowExpr.Bool(false)), + WorkflowExpr.Prop("rightDone", WorkflowExpr.Bool(false)))) + .AddTask( + WorkflowHumanTask.For( + "Left Fork Review", + "LeftForkReview", + "business/policies", + ["DBA"]) + .WithPayload( + WorkflowExpr.Obj( + WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase")))) + .OnComplete(flow => flow + .Set("leftDone", true))) + .AddTask( + WorkflowHumanTask.For( + "After Fork Task Review", + "AfterForkTaskReview", + "business/policies", + ["DBA"]) + .WithPayload( + WorkflowExpr.Obj( + WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase")), + WorkflowExpr.Prop("leftDone", WorkflowExpr.Path("state.leftDone")), + WorkflowExpr.Prop("rightDone", WorkflowExpr.Path("state.rightDone")))) + .OnComplete(flow => flow.Complete())) + .StartWith(flow => flow + .Set("phase", "fork-task-start") + .Fork( + "Parallel Task Branches", + left => left.ActivateTask("Left Fork Review"), + right => right.Set("rightDone", true)) + .Set("phase", "after-fork-task") + .ActivateTask("After Fork Task Review")) + .Build(); + } + + private sealed record ForkTaskTimerResumeStartRequest + { + [WorkflowBusinessId] + [WorkflowBusinessReferencePart("policyId")] + public long SrPolicyId { get; init; } + } + + private sealed class ForkTaskTimerResumeWorkflow : IDeclarativeWorkflow + { + public const string WorkflowNameValue = "ForkTaskTimerResumeWorkflow"; + public const string WorkflowVersionValue = "1.0.0"; + + public string WorkflowName => WorkflowNameValue; + public string WorkflowVersion => WorkflowVersionValue; + public string DisplayName => "Fork Task Timer Resume Workflow"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState( + WorkflowExpr.Obj( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")), + WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")), + WorkflowExpr.Prop("approvalDone", WorkflowExpr.Bool(false)), + WorkflowExpr.Prop("timerDone", WorkflowExpr.Bool(false)))) + .AddTask( + WorkflowHumanTask.For( + "Fork Approval", + "ForkApproval", + "business/policies", + ["DBA"]) + .WithPayload( + WorkflowExpr.Obj( + WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase")))) + .OnComplete(flow => flow + .Set("approvalDone", true))) + .AddTask( + WorkflowHumanTask.For( + "After Mixed Fork Review", + "AfterMixedForkReview", + "business/policies", + ["DBA"]) + .WithPayload( + WorkflowExpr.Obj( + WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase")), + WorkflowExpr.Prop("approvalDone", WorkflowExpr.Path("state.approvalDone")), + WorkflowExpr.Prop("timerDone", WorkflowExpr.Path("state.timerDone")))) + .OnComplete(flow => flow.Complete())) + .StartWith(flow => flow + .Set("phase", "mixed-start") + .Fork( + "Parallel Mixed Branches", + left => left.ActivateTask("Fork Approval"), + right => right + .Wait("Wait Mixed Branch", WorkflowExpr.String("00:00:00")) + .Set("timerDone", true)) + .Set("phase", "after-mixed") + .ActivateTask("After Mixed Fork Review")) + .Build(); + } + + private sealed record ForkExternalSignalResumeStartRequest + { + [WorkflowBusinessId] + [WorkflowBusinessReferencePart("policyId")] + public long SrPolicyId { get; init; } + } + + private sealed class ForkExternalSignalResumeWorkflow : IDeclarativeWorkflow + { + public const string WorkflowNameValue = "ForkExternalSignalResumeWorkflow"; + public const string WorkflowVersionValue = "1.0.0"; + + public string WorkflowName => WorkflowNameValue; + public string WorkflowVersion => WorkflowVersionValue; + public string DisplayName => "Fork External Signal Resume Workflow"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState( + WorkflowExpr.Obj( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")), + WorkflowExpr.Prop("phase", WorkflowExpr.String("starting")), + WorkflowExpr.Prop("leftDocumentId", WorkflowExpr.Number(0L)), + WorkflowExpr.Prop("rightDocumentId", WorkflowExpr.Number(0L)))) + .AddTask( + WorkflowHumanTask.For( + "Review Dual Signals", + "ReviewDualSignals", + "business/policies", + ["DBA"]) + .WithPayload( + WorkflowExpr.Obj( + WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase")), + WorkflowExpr.Prop("leftDocumentId", WorkflowExpr.Path("state.leftDocumentId")), + WorkflowExpr.Prop("rightDocumentId", WorkflowExpr.Path("state.rightDocumentId")))) + .OnComplete(flow => flow.Complete())) + .StartWith(flow => flow + .Set("phase", "waiting-dual-signal") + .Fork( + "Parallel External Signals", + left => left + .WaitForSignal("Wait Left Documents", WorkflowExpr.String("documents-left"), resultKey: "leftSignal") + .Set("leftDocumentId", WorkflowExpr.Path("result.leftSignal.documentId")), + right => right + .WaitForSignal("Wait Right Documents", WorkflowExpr.String("documents-right"), resultKey: "rightSignal") + .Set("rightDocumentId", WorkflowExpr.Path("result.rightSignal.documentId"))) + .Set("phase", "after-dual-signal") + .ActivateTask("Review Dual Signals")) + .Build(); + } + + private sealed record ExternalSignalResumeStartRequest + { + [WorkflowBusinessId] + [WorkflowBusinessReferencePart("policyId")] + public long SrPolicyId { get; init; } + } + + private sealed class ExternalSignalResumeWorkflow : IDeclarativeWorkflow + { + public const string WorkflowNameValue = "ExternalSignalResumeWorkflow"; + public const string WorkflowVersionValue = "1.0.0"; + + public string WorkflowName => WorkflowNameValue; + public string WorkflowVersion => WorkflowVersionValue; + public string DisplayName => "External Signal Resume Workflow"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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( + "Review Uploaded Documents", + "ReviewUploadedDocuments", + "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("Review Uploaded Documents")) + .Build(); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalBridgeTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalBridgeTests.cs new file mode 100644 index 000000000..aa2c846ed --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalBridgeTests.cs @@ -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 + { + ["name"] = JsonSerializer.SerializeToElement("documents-uploaded"), + }, + }; + } + + private sealed class RecordingSignalStore : IWorkflowSignalStore + { + public List Published { get; } = []; + public List 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 Notifications { get; } = []; + public List ConsumerNames { get; } = []; + public IWorkflowSignalLease? NextLease { get; init; } + + public Task NotifySignalAvailableAsync( + WorkflowSignalWakeNotification notification, + CancellationToken cancellationToken = default) + { + Notifications.Add(notification); + return Task.CompletedTask; + } + + public Task ReceiveAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + ConsumerNames.Add(consumerName); + return Task.FromResult(NextLease); + } + } + + private sealed class RecordingWakeOutbox : IWorkflowWakeOutbox + { + public List 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> PostCommitActions { get; } = []; + + public void RegisterPostCommitAction(Func 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; + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalEnvelopeSerializerTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalEnvelopeSerializerTests.cs new file mode 100644 index 000000000..af0372404 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalEnvelopeSerializerTests.cs @@ -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 + { + ["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(); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalOperationalTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalOperationalTests.cs new file mode 100644 index 000000000..24d5a3a1b --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalOperationalTests.cs @@ -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 + { + ["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(); + + 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(); + + 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(); + + 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(); + // var telemetryService = provider.GetRequiredService(); + // 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 + { + ["WorkflowAq:SignalQueueName"] = "WF_SIGNAL_Q", + ["WorkflowAq:DeadLetterQueueName"] = "WF_DLQ_Q", + ["WorkflowAq:ConsumerName"] = "workflow-service", + }) + .Build(); + + services.AddLogging(); + services.AddWorkflowEngineCoreServices(configuration); + services.Configure(configuration.GetSection(OracleAqOptions.SectionName)); + services.Replace(ServiceDescriptor.Scoped(_ => transport)); + services.Replace(ServiceDescriptor.Scoped(sp => + new OracleAqWorkflowSignalDeadLetterStore( + sp.GetRequiredService(), + new OracleAqSerializer(), + sp.GetRequiredService>()))); + + 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 + { + ["signalName"] = JsonSerializer.SerializeToElement("documents-uploaded"), + }, + }; + } + + private sealed class FakeOracleAqTransport : IOracleAqTransport + { + public IReadOnlyCollection BrowseMessages { get; init; } = []; + public FakeOracleAqMessageLease? ReplayLease { get; init; } + public OracleAqDequeueRequest? LastDequeueRequest { get; private set; } + public List EnqueueRequests { get; } = []; + + public Task EnqueueAsync(OracleAqEnqueueRequest request, CancellationToken cancellationToken = default) + { + EnqueueRequests.Add(request); + return Task.CompletedTask; + } + + public Task> 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>(filtered); + } + + public Task DequeueAsync( + OracleAqDequeueRequest request, + CancellationToken cancellationToken = default) + { + LastDequeueRequest = request; + return Task.FromResult( + 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; + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalProcessorTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalProcessorTests.cs new file mode 100644 index 000000000..8bb0cd877 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalProcessorTests.cs @@ -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 + { + ["srPolicyId"] = 1200345M, + }, + }; + + await processor.ProcessAsync( + new WorkflowSignalEnvelope + { + SignalId = "sig-1", + WorkflowInstanceId = "wf-1", + RuntimeProvider = WorkflowRuntimeProviderNames.Engine, + SignalType = WorkflowSignalTypes.InternalContinue, + ExpectedVersion = 0, + Payload = new Dictionary + { + [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(); + } + + [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(); + } + + [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 + { + [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 Requests { get; } = []; + public List 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; + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalPumpWorkerTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalPumpWorkerTests.cs new file mode 100644 index 000000000..1e8f7f902 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowSignalPumpWorkerTests.cs @@ -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(); + + 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(); + + 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(); + + var act = async () => await worker.RunOnceAsync("workflow-service", CancellationToken.None); + + await act.Should().ThrowAsync(); + 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(); + + 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(_ => signalBus); + services.AddScoped(_ => processor); + services.AddSingleton(); + return services.BuildServiceProvider(); + } + + private static WorkflowSignalPumpWorker CreateWorker(ServiceProvider provider, int maxDeliveryAttempts = 10) + { + return new WorkflowSignalPumpWorker( + provider.GetRequiredService(), + Options.Create(new WorkflowAqOptions + { + MaxDeliveryAttempts = maxDeliveryAttempts, + }), + provider.GetRequiredService(), + NullLogger.Instance); + } + + private sealed class FakeWorkflowSignalBus(FakeWorkflowSignalLease? lease = null) : IWorkflowSignalBus + { + public List 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 ReceiveAsync(string consumerName, CancellationToken cancellationToken = default) + { + ConsumerNames.Add(consumerName); + return Task.FromResult(lease); + } + } + + private sealed class FakeWorkflowSignalProcessor(Exception? exception = null) : IWorkflowSignalProcessor + { + public List 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; + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowVersioningTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowVersioningTests.cs new file mode 100644 index 000000000..4ad2a52be --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/WorkflowVersioningTests.cs @@ -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(); + + var latestResponse = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "VersionedWorkflow", + Payload = new Dictionary + { + ["businessKey"] = "B-100", + }, + }); + + var explicitV1Response = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "VersionedWorkflow", + WorkflowVersion = "1.0.0", + Payload = new Dictionary + { + ["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(); + + var response = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest + { + WorkflowName = "VersionedWorkflow", + WorkflowVersion = "1.0.0", + Payload = new Dictionary + { + ["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 + { + ["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 + { + ["WorkflowRetention:OpenStaleAfterDays"] = "30", + ["WorkflowRetention:CompletedPurgeAfterDays"] = "180", + }) + .Build(); + + services.AddLogging(); + services.AddWorkflowRegistration(); + services.AddWorkflowRegistration(); + services.AddWorkflowEngineCoreServices(configuration); + services.AddDbContext(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 + { + public string WorkflowName => "VersionedWorkflow"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Versioned Workflow V1"; + public IReadOnlyCollection WorkflowRoles => ["WF_USER"]; + public IReadOnlyCollection Tasks => + [ + new WorkflowTaskDescriptor + { + TaskName = "Review V1", + TaskType = "ReviewV1", + Route = "versioning/v1", + }, + ]; + } + + private sealed class VersionedWorkflowV2 : ISerdicaWorkflow + { + public string WorkflowName => "VersionedWorkflow"; + public string WorkflowVersion => "2.0.0"; + public string DisplayName => "Versioned Workflow V2"; + public IReadOnlyCollection WorkflowRoles => ["WF_USER"]; + public IReadOnlyCollection 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 StartAsync( + WorkflowStartExecutionContext context, + CancellationToken cancellationToken = default) + { + var startRequest = context.GetRequiredStartRequest(); + return Task.FromResult(new WorkflowStartExecutionPlan + { + WorkflowState = new Dictionary + { + ["businessId"] = JsonSerializer.SerializeToElement(startRequest.BusinessKey), + }, + Tasks = + [ + new WorkflowExecutionTaskPlan + { + TaskName = ReviewTaskName, + TaskType = ReviewTaskType, + Route = ReviewRoute, + }, + ], + }); + } + + public Task 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, + }, + ], + }); + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/PerformanceCommonGlobalUsings.cs b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/PerformanceCommonGlobalUsings.cs new file mode 100644 index 000000000..dd7b96670 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/PerformanceCommonGlobalUsings.cs @@ -0,0 +1 @@ +global using StellaOps.Workflow.IntegrationTests.Shared.Performance; diff --git a/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowEnginePerformanceSupport.cs b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowEnginePerformanceSupport.cs new file mode 100644 index 000000000..190dfe41b --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowEnginePerformanceSupport.cs @@ -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 StartHostedServicesAsync( + IServiceProvider provider, + CancellationToken cancellationToken = default) + { + var services = provider.GetServices().ToArray(); + foreach (var service in services) + { + await service.StartAsync(cancellationToken); + } + + return new HostedServicesHandle(services); + } + + public static async Task WithRuntimeServiceAsync( + IServiceProvider provider, + Func> action) + { + using var scope = provider.CreateScope(); + var runtimeService = scope.ServiceProvider.GetRequiredService(); + return await action(runtimeService); + } + + public static async Task WithRuntimeServiceAsync( + IServiceProvider provider, + Func action) + { + using var scope = provider.CreateScope(); + var runtimeService = scope.ServiceProvider.GetRequiredService(); + await action(runtimeService); + } + + public static async Task> RunConcurrentAsync( + IEnumerable items, + int concurrency, + Func> 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 services) : IAsyncDisposable + { + public async ValueTask DisposeAsync() + { + for (var index = services.Count - 1; index >= 0; index--) + { + await services[index].StopAsync(CancellationToken.None); + } + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowPerformanceArtifacts.cs b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowPerformanceArtifacts.cs new file mode 100644 index 000000000..4403d9794 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowPerformanceArtifacts.cs @@ -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 Metadata { get; init; } + public WorkflowPerformanceLatencySummary? LatencySummary { get; init; } + public Dictionary? 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 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 CounterDeltas { get; init; } = []; + public Dictionary DurationDeltas { get; init; } = []; + public Dictionary Metadata { get; init; } = []; + public IReadOnlyList 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( + 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; } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowPerformanceComparisonMatrix.cs b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowPerformanceComparisonMatrix.cs new file mode 100644 index 000000000..a05ae2018 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowPerformanceComparisonMatrix.cs @@ -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 Columns { get; init; } + public required IReadOnlyList Rows { get; init; } + public required IReadOnlyList Sources { get; init; } + public required IReadOnlyList 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 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 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(); + + 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 scenarios, + Func valueSelector) + { + var values = new Dictionary(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(File.ReadAllText(latencyPath), JsonOptions) + ?? throw new InvalidOperationException($"Unable to deserialize performance artifact '{latencyPath}'."); + var throughput = JsonSerializer.Deserialize(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); +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowPerformanceComparisonMatrixTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowPerformanceComparisonMatrixTests.cs new file mode 100644 index 000000000..2bfca6ad3 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowPerformanceComparisonMatrixTests.cs @@ -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(); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowSignalDrainTelemetry.cs b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowSignalDrainTelemetry.cs new file mode 100644 index 000000000..375197b3f --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/Performance/WorkflowSignalDrainTelemetry.cs @@ -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; +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/StellaOps.Workflow.IntegrationTests.Shared.csproj b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/StellaOps.Workflow.IntegrationTests.Shared.csproj new file mode 100644 index 000000000..955920ab7 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/StellaOps.Workflow.IntegrationTests.Shared.csproj @@ -0,0 +1,35 @@ + + + net10.0 + false + enable + enable + false + CS8601;CS8602;CS8604;NU1015 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/TransportProbeWorkflows.cs b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/TransportProbeWorkflows.cs new file mode 100644 index 000000000..4b5043fa6 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/TransportProbeWorkflows.cs @@ -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 +{ + 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 WorkflowRoles => []; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState(startRequest => new + { + probeKey = startRequest.ProbeKey, + outcome = "pending", + }) + .StartWith(flow => flow + .Call( + "Invoke Legacy Rabbit", + ProbeAddress, + context => new { probeKey = context.StateValues["probeKey"].Get() }, + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete) + .Set("outcome", _ => "success") + .Complete()) + .Build(); +} + +public sealed class MicroserviceOutcomeProbeWorkflow + : IDeclarativeWorkflow +{ + 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 WorkflowRoles => []; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState(startRequest => new + { + probeKey = startRequest.ProbeKey, + outcome = "pending", + }) + .StartWith(flow => flow + .Call( + "Invoke Microservice", + ProbeAddress, + context => new { probeKey = context.StateValues["probeKey"].Get() }, + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete) + .Set("outcome", _ => "success") + .Complete()) + .Build(); +} + +public sealed class GraphqlOutcomeProbeWorkflow + : IDeclarativeWorkflow +{ + 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 WorkflowRoles => []; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState(startRequest => new + { + probeKey = startRequest.ProbeKey, + outcome = "pending", + }) + .StartWith(flow => flow + .QueryGraphql( + "Invoke Graphql", + ProbeAddress, + context => new { probeKey = context.StateValues["probeKey"].Get() }, + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete) + .Set("outcome", _ => "success") + .Complete()) + .Build(); +} + +public sealed class HttpOutcomeProbeWorkflow + : IDeclarativeWorkflow +{ + 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 WorkflowRoles => []; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState(startRequest => new + { + probeKey = startRequest.ProbeKey, + outcome = "pending", + }) + .StartWith(flow => flow + .Call( + "Invoke Http", + ProbeAddress, + context => new { probeKey = context.StateValues["probeKey"].Get() }, + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete) + .Set("outcome", _ => "success") + .Complete()) + .Build(); +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/TransportUnhandledProbeWorkflows.cs b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/TransportUnhandledProbeWorkflows.cs new file mode 100644 index 000000000..c492759fd --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/TransportUnhandledProbeWorkflows.cs @@ -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 BuildInitialState( + TransportProbeWorkflowRequest startRequest) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["probeKey"] = startRequest.ProbeKey, + ["outcome"] = "pending", + }.AsWorkflowJsonDictionary(); + } + + public static WorkflowHumanTaskDefinition CreateProbeTask( + Action> onComplete) + { + return WorkflowHumanTask.For( + ProbeTaskName, + ProbeTaskType, + ProbeRoute, + ProbeTaskRoles) + .WithPayload(context => new + { + probeKey = context.StateValues["probeKey"].Get(), + }) + .OnComplete(onComplete); + } +} + +public sealed class LegacyRabbitUnhandledStartProbeWorkflow + : IDeclarativeWorkflow +{ + 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 WorkflowRoles => []; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState) + .StartWith(flow => flow + .Call( + "Invoke Legacy Rabbit", + ProbeAddress, + context => new { probeKey = context.StateValues["probeKey"].Get() }) + .Set("outcome", _ => "success") + .Complete()) + .Build(); +} + +public sealed class LegacyRabbitUnhandledCompletionProbeWorkflow + : IDeclarativeWorkflow +{ + 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 WorkflowRoles => []; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState) + .AddTask(TransportUnhandledProbeWorkflowSupport.CreateProbeTask(flow => flow + .Call( + "Invoke Legacy Rabbit", + ProbeAddress, + context => new { probeKey = context.StateValues["probeKey"].Get() }) + .Set("outcome", _ => "success") + .Complete())) + .StartWith(flow => flow.ActivateTask(TransportUnhandledProbeWorkflowSupport.ProbeTaskName)) + .Build(); +} + +public sealed class MicroserviceUnhandledStartProbeWorkflow + : IDeclarativeWorkflow +{ + 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 WorkflowRoles => []; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState) + .StartWith(flow => flow + .Call( + "Invoke Microservice", + ProbeAddress, + context => new { probeKey = context.StateValues["probeKey"].Get() }) + .Set("outcome", _ => "success") + .Complete()) + .Build(); +} + +public sealed class MicroserviceUnhandledCompletionProbeWorkflow + : IDeclarativeWorkflow +{ + 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 WorkflowRoles => []; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState) + .AddTask(TransportUnhandledProbeWorkflowSupport.CreateProbeTask(flow => flow + .Call( + "Invoke Microservice", + ProbeAddress, + context => new { probeKey = context.StateValues["probeKey"].Get() }) + .Set("outcome", _ => "success") + .Complete())) + .StartWith(flow => flow.ActivateTask(TransportUnhandledProbeWorkflowSupport.ProbeTaskName)) + .Build(); +} + +public sealed class GraphqlUnhandledStartProbeWorkflow + : IDeclarativeWorkflow +{ + 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 WorkflowRoles => []; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState) + .StartWith(flow => flow + .QueryGraphql( + "Invoke Graphql", + ProbeAddress, + context => new { probeKey = context.StateValues["probeKey"].Get() }) + .Set("outcome", _ => "success") + .Complete()) + .Build(); +} + +public sealed class GraphqlUnhandledCompletionProbeWorkflow + : IDeclarativeWorkflow +{ + 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 WorkflowRoles => []; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState) + .AddTask(TransportUnhandledProbeWorkflowSupport.CreateProbeTask(flow => flow + .QueryGraphql( + "Invoke Graphql", + ProbeAddress, + context => new { probeKey = context.StateValues["probeKey"].Get() }) + .Set("outcome", _ => "success") + .Complete())) + .StartWith(flow => flow.ActivateTask(TransportUnhandledProbeWorkflowSupport.ProbeTaskName)) + .Build(); +} + +public sealed class HttpUnhandledStartProbeWorkflow + : IDeclarativeWorkflow +{ + 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 WorkflowRoles => []; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState) + .StartWith(flow => flow + .Call( + "Invoke Http", + ProbeAddress, + context => new { probeKey = context.StateValues["probeKey"].Get() }) + .Set("outcome", _ => "success") + .Complete()) + .Build(); +} + +public sealed class HttpUnhandledCompletionProbeWorkflow + : IDeclarativeWorkflow +{ + 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 WorkflowRoles => []; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState(TransportUnhandledProbeWorkflowSupport.BuildInitialState) + .AddTask(TransportUnhandledProbeWorkflowSupport.CreateProbeTask(flow => flow + .Call( + "Invoke Http", + ProbeAddress, + context => new { probeKey = context.StateValues["probeKey"].Get() }) + .Set("outcome", _ => "success") + .Complete())) + .StartWith(flow => flow.ActivateTask(TransportUnhandledProbeWorkflowSupport.ProbeTaskName)) + .Build(); +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowIntegrationAssertions.cs b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowIntegrationAssertions.cs new file mode 100644 index 000000000..e5e448534 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowIntegrationAssertions.cs @@ -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 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 GetSingleOpenTaskAsync( + WorkflowRuntimeService runtimeService, + string workflowInstanceId, + string? actorId = null, + IReadOnlyCollection? actorRoles = null) + { + return (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + ActorId = actorId, + ActorRoles = actorRoles ?? Array.Empty(), + Status = "Open", + })).Tasks.Should().ContainSingle().Subject; + } + + public static async Task GetInstanceAsync( + WorkflowRuntimeService runtimeService, + string workflowInstanceId, + string? actorId = null, + IReadOnlyCollection? actorRoles = null) + { + return await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest + { + WorkflowInstanceId = workflowInstanceId, + ActorId = actorId, + ActorRoles = actorRoles ?? Array.Empty(), + }); + } + + public static async Task 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 GetRuntimeRecordAsync( + IWorkflowRuntimeStateStore runtimeStateStore, + string workflowInstanceId) + { + var runtimeRecord = await runtimeStateStore.GetAsync(workflowInstanceId); + runtimeRecord.Should().NotBeNull(); + return runtimeRecord!; + } + + public static async Task GetSingleTaskByNameAsync( + WorkflowRuntimeService runtimeService, + string workflowInstanceId, + string taskName, + string? actorId = null, + IReadOnlyCollection? actorRoles = null, + string? status = "Open") + { + return (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest + { + WorkflowInstanceId = workflowInstanceId, + ActorId = actorId, + ActorRoles = actorRoles ?? Array.Empty(), + 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(); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowPlatformBootstrapTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowPlatformBootstrapTests.cs new file mode 100644 index 000000000..a3388649e --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowPlatformBootstrapTests.cs @@ -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 + { + ["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()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().Should().NotBeNull(); + provider + .GetRequiredService() + .GetDefinition("ApproveApplication", "1.0.0") + .Should() + .NotBeNull(); + provider.GetRequiredService().Should().NotBeNull(); + provider.GetRequiredService().Should().NotBeNull(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService>().Value.DefaultProvider.Should().Be(WorkflowRuntimeProviderNames.Engine); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + + var runtimeStateStore = provider.GetRequiredService(); + 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(); + hostedJobLockService.GetType().FullName.Should().Be("StellaOps.Workflow.DataStore.Oracle.OracleWorkflowHostedJobLockService"); + hostedJobLockService.GetType().Assembly.GetName().Name.Should().Be("StellaOps.Workflow.DataStore.Oracle"); + provider.GetRequiredService().Should().NotBeNull(); + provider.GetRequiredService().Should().NotBeNull(); + } + + [Test] + public void WorkflowDefinitionCatalog_ShouldContainApproveApplicationVersionedDefinition() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["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()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + + using var provider = services.BuildServiceProvider(); + + provider + .GetRequiredService() + .GetDefinition("ApproveApplication", "1.0.0") + .Should() + .NotBeNull(); + } + + [Test] + public void AddWorkflowPlatformServices_WhenPostgresBackendPluginIsSelected_ShouldBuildBackendNeutralProvider() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["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()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService>().Value.Provider.Should().Be(WorkflowBackendNames.Postgres); + var runtimeStateStore = provider.GetRequiredService(); + runtimeStateStore.GetType().FullName.Should().Be(typeof(PostgresWorkflowRuntimeStateStore).FullName); + runtimeStateStore.GetType().Assembly.GetName().Name.Should().Be("StellaOps.Workflow.DataStore.PostgreSQL"); + + var hostedJobLockService = provider.GetRequiredService(); + hostedJobLockService.GetType().FullName.Should().Be(typeof(PostgresWorkflowHostedJobLockService).FullName); + hostedJobLockService.GetType().Assembly.GetName().Name.Should().Be("StellaOps.Workflow.DataStore.PostgreSQL"); + + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(PostgresWorkflowProjectionStore).FullName); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(PostgresWorkflowProjectionRetentionStore).FullName); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(PostgresWorkflowMutationCoordinator).FullName); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(PostgresWorkflowSignalStore).FullName); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(PostgresWorkflowSignalBus).FullName); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(PostgresWorkflowScheduleBus).FullName); + provider.GetRequiredService().Should().NotBeNull(); + } + + [Test] + public void AddWorkflowPlatformServices_WhenMongoBackendPluginIsSelected_ShouldBuildBackendNeutralProvider() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["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()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService>().Value.Provider.Should().Be(WorkflowBackendNames.Mongo); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(MongoWorkflowRuntimeStateStore).FullName); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(MongoWorkflowHostedJobLockService).FullName); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(MongoWorkflowProjectionStore).FullName); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(MongoWorkflowProjectionRetentionStore).FullName); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(MongoWorkflowMutationCoordinator).FullName); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(MongoWorkflowSignalStore).FullName); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(MongoWorkflowSignalBus).FullName); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(MongoWorkflowScheduleBus).FullName); + provider.GetRequiredService().Should().NotBeNull(); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowPlatformRedisSignalDriverBootstrapTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowPlatformRedisSignalDriverBootstrapTests.cs new file mode 100644 index 000000000..770e59750 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowPlatformRedisSignalDriverBootstrapTests.cs @@ -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 + { + ["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().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(PostgresWorkflowSignalStore).FullName); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(PostgresWorkflowSignalStore).FullName); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(RedisWorkflowSignalDriver).FullName); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetServices() + .Should() + .NotContain(service => service.GetType().FullName == typeof(RedisWorkflowWakeOutboxPublisherHostedService).FullName); + } + + [Test] + public void AddWorkflowPlatformServices_WhenOracleAndRedisSignalDriverAreSelected_ShouldBuildProvider() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["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().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(OracleAqWorkflowSignalBus).FullName); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(OracleAqWorkflowSignalBus).FullName); + provider.GetRequiredService().GetType().FullName.Should().Be(typeof(RedisWorkflowSignalDriver).FullName); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetServices() + .Should() + .NotContain(service => service.GetType().FullName == typeof(RedisWorkflowWakeOutboxPublisherHostedService).FullName); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowTransportScripts.cs b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowTransportScripts.cs new file mode 100644 index 000000000..b9162484f --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.IntegrationTests.Shared/WorkflowTransportScripts.cs @@ -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, + 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 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, + 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 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, + 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 ?? ""}'."), + operationName); + } + + public Task 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 ?? ""}:{query}"; + } +} + +public sealed class ScriptedWorkflowHttpTransport + : ScriptedTransportBase, + 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 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 +{ + private readonly Dictionary> scripts = new(StringComparer.OrdinalIgnoreCase); + private readonly object invocationSync = new(); + + public List 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 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(exception); + } + } + + protected abstract TResponse BuildMissingResponse(TRequest request, string key); + + private ScriptedCallSequence GetSequence(string key) + { + if (!scripts.TryGetValue(key, out var sequence)) + { + sequence = new ScriptedCallSequence(); + scripts[key] = sequence; + } + + return sequence; + } +} + +internal sealed class ScriptedCallSequence +{ + private readonly Queue> 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(); + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/AssistantPrintInsisDocumentsRenderingTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/AssistantPrintInsisDocumentsRenderingTests.cs new file mode 100644 index 000000000..0bcc75695 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/AssistantPrintInsisDocumentsRenderingTests.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using NUnit.Framework; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Renderer.ElkSharp; +using StellaOps.Workflow.Renderer.Svg; + +namespace StellaOps.Workflow.Renderer.Tests; + +[TestFixture] +public class AssistantPrintInsisDocumentsRenderingTests +{ + private static WorkflowRenderGraph BuildAssistantPrintInsisDocumentsGraph() + { + return new WorkflowRenderGraph + { + Id = "AssistantPrintInsisDocuments:1.0.0", + Nodes = + [ + new WorkflowRenderNode { Id = "start", Label = "Start", Kind = "Start", Width = 264, Height = 132 }, + new WorkflowRenderNode { Id = "start/1", Label = "Assign Business Reference", Kind = "BusinessReference", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/2/split", Label = "Spin off async process", Kind = "Fork", Width = 176, Height = 124 }, + new WorkflowRenderNode { Id = "start/2/join", Label = "Spin off async process Join", Kind = "Join", Width = 176, Height = 124 }, + new WorkflowRenderNode { Id = "start/3", Label = "Load Notification Parameters", Kind = "TransportCall", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/4/batched", Label = "Setting:\nnotificationParameters\nskipSystemNotification\ntoEmailsCount\nnotificationHasBody\nnotificationHasTitle", Kind = "SetState", Width = 224, Height = 104 }, + new WorkflowRenderNode { Id = "start/9", Label = "Has Notification Content", Kind = "Decision", Width = 188, Height = 132 }, + new WorkflowRenderNode { Id = "start/9/true/1", Label = "Send Private Note", Kind = "Decision", Width = 188, Height = 132 }, + new WorkflowRenderNode { Id = "start/9/true/1/true/1", Label = "Send Private Note", Kind = "TransportCall", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/9/true/1/true/1/handled/1", Label = "Set notificationPrivateNoteFailed", Kind = "SetState", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/9/true/2", Label = "Has Notification Emails", Kind = "Decision", Width = 188, Height = 132 }, + new WorkflowRenderNode { Id = "start/9/true/2/true/1", Label = "Send Notification Email", Kind = "TransportCall", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/9/true/2/true/1/handled/1", Label = "Set notificationEmailFailed", Kind = "SetState", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "end", Label = "End", Kind = "End", Width = 264, Height = 132 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1", Label = "Generate Documents", Kind = "Repeat", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/1/batched", Label = "Setting:\nprintTimedOut\nprintGenerateFailed\nhasMissingDocuments", Kind = "SetState", Width = 224, Height = 104 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4", Label = "Print Batch Documents", Kind = "TransportCall", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/1", Label = "Attempt Again", Kind = "Decision", Width = 188, Height = 132 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/1/true/1", Label = "Wait 5m", Kind = "Timer", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/2", Label = "Set printGenerateFailed", Kind = "SetState", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5", Label = "Print Batch Returned Result", Kind = "Decision", Width = 188, Height = 132 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5/true/1", Label = "Print Batch Succeeded", Kind = "Decision", Width = 188, Height = 132 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5/true/1/true/1/batched", Label = "Setting:\npolicyNo\nfiles\ndocsCount\nhasMissingDocuments", Kind = "SetState", Width = 224, Height = 104 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/timeout/1", Label = "Set printTimedOut", Kind = "SetState", Width = 208, Height = 88 }, + ], + Edges = + [ + new WorkflowRenderEdge { Id = "edge/1", SourceNodeId = "start", TargetNodeId = "start/1" }, + new WorkflowRenderEdge { Id = "edge/2", SourceNodeId = "start/1", TargetNodeId = "start/2/split" }, + new WorkflowRenderEdge { Id = "edge/3", SourceNodeId = "start/2/split", TargetNodeId = "start/2/branch-1/1", Label = "branch 1" }, + new WorkflowRenderEdge { Id = "edge/4", SourceNodeId = "start/2/split", TargetNodeId = "start/2/join", Label = "branch 2" }, + new WorkflowRenderEdge { Id = "edge/5", SourceNodeId = "start/2/branch-1/1/body/4", TargetNodeId = "start/2/branch-1/1/body/4/failure/1", Label = "on failure" }, + new WorkflowRenderEdge { Id = "edge/6", SourceNodeId = "start/2/branch-1/1/body/4", TargetNodeId = "start/2/branch-1/1/body/4/timeout/1", Label = "on timeout" }, + new WorkflowRenderEdge { Id = "edge/7", SourceNodeId = "start/2/branch-1/1/body/4", TargetNodeId = "start/2/branch-1/1/body/5" }, + new WorkflowRenderEdge { Id = "edge/8", SourceNodeId = "start/2/branch-1/1/body/4/failure/1", TargetNodeId = "start/2/branch-1/1/body/4/failure/1/true/1", Label = "when notstate.printInsisAttempt gt 2" }, + new WorkflowRenderEdge { Id = "edge/9", SourceNodeId = "start/2/branch-1/1/body/4/failure/1", TargetNodeId = "start/2/branch-1/1/body/4/failure/2", Label = "default" }, + new WorkflowRenderEdge { Id = "edge/10", SourceNodeId = "start/2/branch-1/1/body/4/failure/1/true/1", TargetNodeId = "start/2/branch-1/1/body/4/failure/2" }, + new WorkflowRenderEdge { Id = "edge/11", SourceNodeId = "start/2/branch-1/1/body/4/failure/2", TargetNodeId = "start/2/branch-1/1/body/5" }, + new WorkflowRenderEdge { Id = "edge/12", SourceNodeId = "start/2/branch-1/1/body/4/timeout/1", TargetNodeId = "start/2/branch-1/1/body/5" }, + new WorkflowRenderEdge { Id = "edge/13", SourceNodeId = "start/2/branch-1/1/body/5", TargetNodeId = "start/2/branch-1/1/body/5/true/1", Label = "when state.printTimedOut eq false" }, + new WorkflowRenderEdge { Id = "edge/14", SourceNodeId = "start/2/branch-1/1/body/5", TargetNodeId = "start/2/branch-1/1", Label = "repeat while state.printInsisAttempt eq 0" }, + new WorkflowRenderEdge { Id = "edge/15", SourceNodeId = "start/2/branch-1/1/body/5/true/1", TargetNodeId = "start/2/branch-1/1", Label = "repeat while state.printInsisAttempt eq 0" }, + new WorkflowRenderEdge { Id = "edge/16", SourceNodeId = "start/2/branch-1/1/body/5/true/1", TargetNodeId = "start/2/branch-1/1/body/5/true/1/true/1/batched", Label = "when state.printGenerateFailed eq false" }, + new WorkflowRenderEdge { Id = "edge/17", SourceNodeId = "start/2/branch-1/1", TargetNodeId = "start/2/join" }, + new WorkflowRenderEdge { Id = "edge/18", SourceNodeId = "start/2/branch-1/1", TargetNodeId = "start/2/branch-1/1/body/1/batched", Label = "body" }, + new WorkflowRenderEdge { Id = "edge/19", SourceNodeId = "start/2/join", TargetNodeId = "start/3" }, + new WorkflowRenderEdge { Id = "edge/20", SourceNodeId = "start/3", TargetNodeId = "end", Label = "on failure / timeout" }, + new WorkflowRenderEdge { Id = "edge/21", SourceNodeId = "start/3", TargetNodeId = "start/4/batched" }, + new WorkflowRenderEdge { Id = "edge/22", SourceNodeId = "start/9", TargetNodeId = "start/9/true/1", Label = "when state.notificationHasBody" }, + new WorkflowRenderEdge { Id = "edge/23", SourceNodeId = "start/9", TargetNodeId = "end", Label = "default" }, + new WorkflowRenderEdge { Id = "edge/24", SourceNodeId = "start/9/true/1", TargetNodeId = "start/9/true/1/true/1", Label = "when state.skipSystemNotification eq false" }, + new WorkflowRenderEdge { Id = "edge/25", SourceNodeId = "start/9/true/1", TargetNodeId = "start/9/true/2", Label = "default" }, + new WorkflowRenderEdge { Id = "edge/26", SourceNodeId = "start/9/true/1/true/1", TargetNodeId = "start/9/true/1/true/1/handled/1", Label = "on failure / timeout" }, + new WorkflowRenderEdge { Id = "edge/27", SourceNodeId = "start/9/true/1/true/1", TargetNodeId = "start/9/true/2" }, + new WorkflowRenderEdge { Id = "edge/28", SourceNodeId = "start/9/true/1/true/1/handled/1", TargetNodeId = "start/9/true/2" }, + new WorkflowRenderEdge { Id = "edge/29", SourceNodeId = "start/9/true/2", TargetNodeId = "start/9/true/2/true/1", Label = "when state.toEmailsCount gt 0" }, + new WorkflowRenderEdge { Id = "edge/30", SourceNodeId = "start/9/true/2", TargetNodeId = "end", Label = "default" }, + new WorkflowRenderEdge { Id = "edge/31", SourceNodeId = "start/9/true/2/true/1", TargetNodeId = "start/9/true/2/true/1/handled/1", Label = "on failure / timeout" }, + new WorkflowRenderEdge { Id = "edge/32", SourceNodeId = "start/9/true/2/true/1", TargetNodeId = "end" }, + new WorkflowRenderEdge { Id = "edge/33", SourceNodeId = "start/9/true/2/true/1/handled/1", TargetNodeId = "end" }, + new WorkflowRenderEdge { Id = "edge/34", SourceNodeId = "start/2/branch-1/1/body/1/batched", TargetNodeId = "start/2/branch-1/1/body/4" }, + new WorkflowRenderEdge { Id = "edge/35", SourceNodeId = "start/2/branch-1/1/body/5/true/1/true/1/batched", TargetNodeId = "start/2/branch-1/1", Label = "repeat while state.printInsisAttempt eq 0" }, + new WorkflowRenderEdge { Id = "edge/36", SourceNodeId = "start/4/batched", TargetNodeId = "start/9" }, + ], + }; + } + + [Test] + public async Task AssistantPrintInsisDocuments_WhenLayoutOnly_ShouldProduceFinitePositions() + { + var graph = BuildAssistantPrintInsisDocumentsGraph(); + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + + var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + }); + + Assert.That(layout.Nodes.Count, Is.EqualTo(24)); + Assert.That(layout.Edges.Count, Is.EqualTo(36)); + Assert.That(layout.Nodes.All(n => double.IsFinite(n.X) && double.IsFinite(n.Y)), Is.True); + } + + [Test] + [Category("RenderingArtifacts")] + public async Task AssistantPrintInsisDocuments_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings() + { + var graph = BuildAssistantPrintInsisDocumentsGraph(); + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + + var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + }); + + var svgRenderer = new WorkflowRenderSvgRenderer(); + var svgDoc = svgRenderer.Render(layout, "AssistantPrintInsisDocuments [ElkSharp]"); + + var outputDir = Path.Combine( + Path.GetDirectoryName(typeof(AssistantPrintInsisDocumentsRenderingTests).Assembly.Location)!, + "TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "AssistantPrintInsisDocuments"); + Directory.CreateDirectory(outputDir); + + var svgPath = Path.Combine(outputDir, "elksharp.svg"); + await File.WriteAllTextAsync(svgPath, svgDoc.Svg); + + var jsonPath = Path.Combine(outputDir, "elksharp.json"); + await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true })); + + WorkflowRenderPngExporter? pngExporter = null; + string? pngPath = null; + try + { + pngPath = Path.Combine(outputDir, "elksharp.png"); + pngExporter = new WorkflowRenderPngExporter(); + await pngExporter.ExportAsync(svgDoc, pngPath, scale: 2f); + TestContext.Out.WriteLine($"PNG generated at: {pngPath}"); + } + catch (Exception ex) + { + TestContext.Out.WriteLine($"PNG export failed (non-fatal): {ex.Message}"); + TestContext.Out.WriteLine($"SVG available at: {svgPath}"); + } + + TestContext.Out.WriteLine($"SVG: {svgPath}"); + TestContext.Out.WriteLine($"JSON: {jsonPath}"); + + // Verify zero edge-node crossings + var crossings = 0; + foreach (var node in layout.Nodes) + { + foreach (var edge in layout.Edges) + { + if (edge.SourceNodeId == node.Id || edge.TargetNodeId == node.Id) continue; + foreach (var section in edge.Sections) + { + var pts = new List { section.StartPoint }; + pts.AddRange(section.BendPoints); + pts.Add(section.EndPoint); + for (var i = 0; i < pts.Count - 1; i++) + { + var p1 = pts[i]; + var p2 = pts[i + 1]; + if (Math.Abs(p1.Y - p2.Y) < 2 && p1.Y > node.Y && p1.Y < node.Y + node.Height) + { + if (Math.Max(p1.X, p2.X) > node.X && Math.Min(p1.X, p2.X) < node.X + node.Width) + crossings++; + } + else if (Math.Abs(p1.X - p2.X) < 2 && p1.X > node.X && p1.X < node.X + node.Width) + { + if (Math.Max(p1.Y, p2.Y) > node.Y && Math.Min(p1.Y, p2.Y) < node.Y + node.Height) + crossings++; + } + } + } + } + } + + TestContext.Out.WriteLine($"Edge-node crossings: {crossings}"); + Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes"); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkJsWorkflowRenderLayoutEngineTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkJsWorkflowRenderLayoutEngineTests.cs new file mode 100644 index 000000000..303d89ccc --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkJsWorkflowRenderLayoutEngineTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using NUnit.Framework; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Renderer.ElkJs; + +namespace StellaOps.Workflow.Renderer.Tests; + +[TestFixture] +public class ElkJsWorkflowRenderLayoutEngineTests +{ + [Test] + public async Task LayoutAsync_WhenSimpleLinearGraphProvided_ShouldReturnPositionedNodesAndEdges() + { + var engine = new ElkJsWorkflowRenderLayoutEngine(); + var graph = new WorkflowRenderGraph + { + Id = "root", + Nodes = + [ + new WorkflowRenderNode + { + Id = "start", + Label = "Start", + Kind = "Start", + Width = 80, + Height = 40, + }, + new WorkflowRenderNode + { + Id = "task-1", + Label = "Review", + Kind = "Task", + Width = 160, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "end", + Label = "End", + Kind = "End", + Width = 80, + Height = 40, + }, + ], + Edges = + [ + new WorkflowRenderEdge + { + Id = "e1", + SourceNodeId = "start", + TargetNodeId = "task-1", + }, + new WorkflowRenderEdge + { + Id = "e2", + SourceNodeId = "task-1", + TargetNodeId = "end", + }, + ], + }; + + var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + }); + + result.GraphId.Should().Be("root"); + result.Nodes.Should().HaveCount(3); + result.Edges.Should().HaveCount(2); + + var orderedNodes = result.Nodes.OrderBy(x => x.X).ToArray(); + orderedNodes.Select(x => x.Id).Should().ContainInOrder("start", "task-1", "end"); + result.Edges.Should().OnlyContain(x => x.Sections.Count > 0); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpSourceAnalyzerTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpSourceAnalyzerTests.cs new file mode 100644 index 000000000..353a23824 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpSourceAnalyzerTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using NUnit.Framework; + +using StellaOps.ElkSharp; + +namespace StellaOps.Workflow.Renderer.Tests; + +[TestFixture] +public class ElkSharpSourceAnalyzerTests +{ + [Test] + public void Analyze_WhenGivenGwtLikeSource_ShouldReturnStructuralCounts() + { + const string Source = """ + function defineClass(typeId, superTypeIdOrPrototype, castableTypeMap){ + } + + function layout_10(graphObj, layoutOptionsObj, optionsObj){ + } + + function createSomething(){ + defineClass(1, 0, {}); + createForClass('pkg', 'Clazz', 1); + registerLayoutAlgorithms(data_0.algorithms); + } + + var $intern_0 = 1; + var $intern_1 = 2; + """; + + var profile = ElkSharpSourceAnalyzer.Analyze("elk-worker.js", Source); + + profile.SourceName.Should().Be("elk-worker.js"); + profile.LineCount.Should().BeGreaterThan(0); + profile.CharacterCount.Should().Be(Source.Length); + profile.FunctionCount.Should().Be(3); + profile.DefineClassCount.Should().Be(2); + profile.CreateForClassCount.Should().Be(1); + profile.InternConstantCount.Should().Be(2); + profile.LayoutCommandCount.Should().Be(1); + profile.RegisterAlgorithmCount.Should().Be(1); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpWorkflowRenderLayoutEngineTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpWorkflowRenderLayoutEngineTests.cs new file mode 100644 index 000000000..f647f637c --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpWorkflowRenderLayoutEngineTests.cs @@ -0,0 +1,958 @@ +using FluentAssertions; +using NUnit.Framework; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Renderer.ElkSharp; + +namespace StellaOps.Workflow.Renderer.Tests; + +[TestFixture] +public class ElkSharpWorkflowRenderLayoutEngineTests +{ + [Test] + public async Task LayoutAsync_WhenSimpleLinearGraphProvided_ShouldReturnPositionedNodesAndEdges() + { + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + var graph = new WorkflowRenderGraph + { + Id = "root", + Nodes = + [ + new WorkflowRenderNode + { + Id = "start", + Label = "Start", + Kind = "Start", + Width = 80, + Height = 40, + }, + new WorkflowRenderNode + { + Id = "task-1", + Label = "Review", + Kind = "Task", + Width = 160, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "end", + Label = "End", + Kind = "End", + Width = 80, + Height = 40, + }, + ], + Edges = + [ + new WorkflowRenderEdge + { + Id = "e1", + SourceNodeId = "start", + TargetNodeId = "task-1", + }, + new WorkflowRenderEdge + { + Id = "e2", + SourceNodeId = "task-1", + TargetNodeId = "end", + }, + ], + }; + + var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + }); + + result.GraphId.Should().Be("root"); + result.Nodes.Should().HaveCount(3); + result.Edges.Should().HaveCount(2); + + var orderedNodes = result.Nodes.OrderBy(x => x.X).ToArray(); + orderedNodes.Select(x => x.Id).Should().ContainInOrder("start", "task-1", "end"); + result.Edges.Should().OnlyContain(x => x.Sections.Count > 0); + var startNode = result.Nodes.Single(x => x.Id == "start"); + var taskNode = result.Nodes.Single(x => x.Id == "task-1"); + var endNode = result.Nodes.Single(x => x.Id == "end"); + (startNode.Y + (startNode.Height / 2d)).Should().BeApproximately(taskNode.Y + (taskNode.Height / 2d), 0.01d); + (endNode.Y + (endNode.Height / 2d)).Should().BeApproximately(taskNode.Y + (taskNode.Height / 2d), 0.01d); + } + + [Test] + public async Task LayoutAsync_WhenBranchTargetIsBelowSource_ShouldUseLowerAnchorPoint() + { + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + var graph = new WorkflowRenderGraph + { + Id = "branch", + Nodes = + [ + new WorkflowRenderNode + { + Id = "source", + Label = "Source", + Kind = "Decision", + Width = 144, + Height = 120, + }, + new WorkflowRenderNode + { + Id = "upper", + Label = "Upper", + Kind = "SetState", + Width = 180, + Height = 84, + }, + new WorkflowRenderNode + { + Id = "lower", + Label = "Lower", + Kind = "SetState", + Width = 180, + Height = 84, + }, + ], + Edges = + [ + new WorkflowRenderEdge + { + Id = "e-upper", + SourceNodeId = "source", + TargetNodeId = "upper", + Label = "when true", + }, + new WorkflowRenderEdge + { + Id = "e-lower", + SourceNodeId = "source", + TargetNodeId = "lower", + Label = "otherwise", + }, + ], + }; + + var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + }); + + var source = result.Nodes.Single(x => x.Id == "source"); + var upper = result.Nodes.Single(x => x.Id == "upper"); + var lower = result.Nodes.Single(x => x.Id == "lower"); + var lowerEdge = result.Edges.Single(x => x.Id == "e-lower").Sections.Single(); + var sourceCenterY = source.Y + (source.Height / 2d); + var upperCenterY = upper.Y + (upper.Height / 2d); + var lowerCenterY = lower.Y + (lower.Height / 2d); + + upperCenterY.Should().BeLessThan(lowerCenterY); + lowerEdge.StartPoint.Y.Should().BeGreaterThanOrEqualTo(sourceCenterY); + } + + [Test] + public async Task LayoutAsync_WhenSameLaneStateBoxesConnected_ShouldAnchorToBoxBorders() + { + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + var graph = new WorkflowRenderGraph + { + Id = "same-lane", + Nodes = + [ + new WorkflowRenderNode + { + Id = "left", + Label = "Set printGenerateFailed", + Kind = "SetState", + Width = 208, + Height = 88, + }, + new WorkflowRenderNode + { + Id = "right", + Label = "Set hasMissingDocuments", + Kind = "SetState", + Width = 208, + Height = 88, + }, + ], + Edges = + [ + new WorkflowRenderEdge + { + Id = "e1", + SourceNodeId = "left", + TargetNodeId = "right", + }, + ], + }; + + var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + }); + + var left = result.Nodes.Single(x => x.Id == "left"); + var right = result.Nodes.Single(x => x.Id == "right"); + var edge = result.Edges.Single().Sections.Single(); + + edge.BendPoints.Should().BeEmpty(); + edge.StartPoint.X.Should().BeApproximately(left.X + left.Width, 0.01d); + edge.EndPoint.X.Should().BeApproximately(right.X, 0.01d); + edge.StartPoint.Y.Should().BeGreaterThanOrEqualTo(left.Y); + edge.StartPoint.Y.Should().BeLessThanOrEqualTo(left.Y + left.Height); + edge.EndPoint.Y.Should().BeGreaterThanOrEqualTo(right.Y); + edge.EndPoint.Y.Should().BeLessThanOrEqualTo(right.Y + right.Height); + } + + [Test] + public async Task LayoutAsync_WhenRetryEdgePointsBackwards_ShouldKeepPrimaryFlowForwardAndRouteBackEdgeOutside() + { + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + var graph = new WorkflowRenderGraph + { + Id = "retry-loop", + Nodes = + [ + new WorkflowRenderNode + { + Id = "start", + Label = "Start", + Kind = "Start", + Width = 88, + Height = 48, + }, + new WorkflowRenderNode + { + Id = "service", + Label = "Call service", + Kind = "TransportCall", + Width = 208, + Height = 88, + }, + new WorkflowRenderNode + { + Id = "retry", + Label = "Retry", + Kind = "HumanTask", + Width = 208, + Height = 88, + }, + new WorkflowRenderNode + { + Id = "end", + Label = "End", + Kind = "End", + Width = 88, + Height = 48, + }, + ], + Edges = + [ + new WorkflowRenderEdge + { + Id = "e-start", + SourceNodeId = "start", + TargetNodeId = "service", + }, + new WorkflowRenderEdge + { + Id = "e-failure", + SourceNodeId = "service", + TargetNodeId = "retry", + Label = "on failure", + }, + new WorkflowRenderEdge + { + Id = "e-retry", + SourceNodeId = "retry", + TargetNodeId = "service", + Label = "retry", + }, + new WorkflowRenderEdge + { + Id = "e-end", + SourceNodeId = "service", + TargetNodeId = "end", + Label = "default", + }, + ], + }; + + var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + }); + + var start = result.Nodes.Single(x => x.Id == "start"); + var service = result.Nodes.Single(x => x.Id == "service"); + var retry = result.Nodes.Single(x => x.Id == "retry"); + var end = result.Nodes.Single(x => x.Id == "end"); + var retryEdge = result.Edges.Single(x => x.Id == "e-retry").Sections.Single(); + + start.X.Should().BeLessThan(service.X); + service.X.Should().BeLessThan(retry.X); + end.X.Should().BeGreaterThan(service.X); + retryEdge.StartPoint.Y.Should().BeApproximately(retry.Y, 0.01d); + retryEdge.EndPoint.Y.Should().BeApproximately(service.Y, 0.01d); + retryEdge.BendPoints.Should().HaveCountGreaterThan(0); + retryEdge.BendPoints.Should().OnlyContain(point => point.Y < service.Y); + } + + [Test] + public async Task LayoutAsync_WhenEndNodeIsDeclaredBeforeItsPredecessors_ShouldStillPlaceEndAsASink() + { + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + var graph = new WorkflowRenderGraph + { + Id = "end-order", + Nodes = + [ + new WorkflowRenderNode + { + Id = "start", + Label = "Start", + Kind = "Start", + Width = 88, + Height = 48, + }, + new WorkflowRenderNode + { + Id = "end", + Label = "End", + Kind = "End", + Width = 88, + Height = 48, + }, + new WorkflowRenderNode + { + Id = "task-a", + Label = "Task A", + Kind = "Task", + Width = 160, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "task-b", + Label = "Task B", + Kind = "Task", + Width = 160, + Height = 72, + }, + ], + Edges = + [ + new WorkflowRenderEdge + { + Id = "start-a", + SourceNodeId = "start", + TargetNodeId = "task-a", + }, + new WorkflowRenderEdge + { + Id = "a-b", + SourceNodeId = "task-a", + TargetNodeId = "task-b", + }, + new WorkflowRenderEdge + { + Id = "a-end", + SourceNodeId = "task-a", + TargetNodeId = "end", + Label = "on failure", + }, + new WorkflowRenderEdge + { + Id = "b-end", + SourceNodeId = "task-b", + TargetNodeId = "end", + Label = "default", + }, + ], + }; + + var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + Effort = WorkflowRenderLayoutEffort.Best, + }); + + var taskA = result.Nodes.Single(node => node.Id == "task-a"); + var taskB = result.Nodes.Single(node => node.Id == "task-b"); + var end = result.Nodes.Single(node => node.Id == "end"); + var edgeToEnd = result.Edges.Single(edge => edge.Id == "b-end").Sections.Single(); + + end.X.Should().BeGreaterThan(taskA.X); + end.X.Should().BeGreaterThan(taskB.X); + edgeToEnd.EndPoint.X.Should().BeApproximately(end.X, 0.01d); + edgeToEnd.BendPoints.Should().OnlyContain(point => point.X >= taskA.X); + } + + [Test] + public async Task LayoutAsync_WhenLinearChainFeedsFork_ShouldKeepNodeCentersAligned() + { + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + var graph = new WorkflowRenderGraph + { + Id = "chain-alignment", + Nodes = + [ + new WorkflowRenderNode + { + Id = "start", + Label = "Start", + Kind = "Start", + Width = 88, + Height = 48, + }, + new WorkflowRenderNode + { + Id = "task", + Label = "Task", + Kind = "Task", + Width = 160, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "fork", + Label = "Fork", + Kind = "Fork", + Width = 120, + Height = 84, + }, + new WorkflowRenderNode + { + Id = "branch-a", + Label = "Branch A", + Kind = "Task", + Width = 140, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "branch-b", + Label = "Branch B", + Kind = "Task", + Width = 140, + Height = 72, + }, + ], + Edges = + [ + new WorkflowRenderEdge + { + Id = "start-task", + SourceNodeId = "start", + TargetNodeId = "task", + }, + new WorkflowRenderEdge + { + Id = "task-fork", + SourceNodeId = "task", + TargetNodeId = "fork", + }, + new WorkflowRenderEdge + { + Id = "fork-a", + SourceNodeId = "fork", + TargetNodeId = "branch-a", + }, + new WorkflowRenderEdge + { + Id = "fork-b", + SourceNodeId = "fork", + TargetNodeId = "branch-b", + }, + ], + }; + + var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + Effort = WorkflowRenderLayoutEffort.Best, + }); + + var start = result.Nodes.Single(node => node.Id == "start"); + var task = result.Nodes.Single(node => node.Id == "task"); + var fork = result.Nodes.Single(node => node.Id == "fork"); + + var startCenterY = start.Y + (start.Height / 2d); + var taskCenterY = task.Y + (task.Height / 2d); + var forkCenterY = fork.Y + (fork.Height / 2d); + + taskCenterY.Should().BeApproximately(startCenterY, 0.01d); + forkCenterY.Should().BeApproximately(taskCenterY, 0.01d); + } + + [Test] + public async Task LayoutAsync_WhenLongEdgeUsesPorts_ShouldPreservePortAnchors() + { + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + var graph = new WorkflowRenderGraph + { + Id = "ports", + Nodes = + [ + new WorkflowRenderNode + { + Id = "a", + Label = "A", + Kind = "Task", + Width = 120, + Height = 72, + Ports = + [ + new WorkflowRenderPort { Id = "a-out", Side = "EAST" }, + ], + }, + new WorkflowRenderNode + { + Id = "b", + Label = "B", + Kind = "Task", + Width = 120, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "c", + Label = "C", + Kind = "Task", + Width = 120, + Height = 72, + Ports = + [ + new WorkflowRenderPort { Id = "c-in", Side = "WEST" }, + ], + }, + ], + Edges = + [ + new WorkflowRenderEdge + { + Id = "ab", + SourceNodeId = "a", + TargetNodeId = "b", + }, + new WorkflowRenderEdge + { + Id = "bc", + SourceNodeId = "b", + TargetNodeId = "c", + }, + new WorkflowRenderEdge + { + Id = "ac", + SourceNodeId = "a", + TargetNodeId = "c", + SourcePortId = "a-out", + TargetPortId = "c-in", + }, + ], + }; + + var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + }); + + var edge = result.Edges.Single(x => x.Id == "ac").Sections.Single(); + var source = result.Nodes.Single(x => x.Id == "a"); + var target = result.Nodes.Single(x => x.Id == "c"); + var sourcePort = source.Ports.Single(x => x.Id == "a-out"); + var targetPort = target.Ports.Single(x => x.Id == "c-in"); + + edge.StartPoint.X.Should().BeApproximately(sourcePort.X + (sourcePort.Width / 2d), 0.01d); + edge.StartPoint.Y.Should().BeApproximately(sourcePort.Y + (sourcePort.Height / 2d), 0.01d); + edge.EndPoint.X.Should().BeApproximately(targetPort.X + (targetPort.Width / 2d), 0.01d); + edge.EndPoint.Y.Should().BeApproximately(targetPort.Y + (targetPort.Height / 2d), 0.01d); + } + + [Test] + public async Task LayoutAsync_WhenTopToBottomEdgesConverge_ShouldSpreadTargetAnchorsAcrossTopSide() + { + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + var graph = new WorkflowRenderGraph + { + Id = "top-to-bottom", + Nodes = + [ + new WorkflowRenderNode + { + Id = "left", + Label = "Left", + Kind = "Task", + Width = 120, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "right", + Label = "Right", + Kind = "Task", + Width = 120, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "target", + Label = "Target", + Kind = "Task", + Width = 144, + Height = 80, + }, + ], + Edges = + [ + new WorkflowRenderEdge + { + Id = "lt", + SourceNodeId = "left", + TargetNodeId = "target", + }, + new WorkflowRenderEdge + { + Id = "rt", + SourceNodeId = "right", + TargetNodeId = "target", + }, + ], + }; + + var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.TopToBottom, + }); + + var target = result.Nodes.Single(x => x.Id == "target"); + var leftEdge = result.Edges.Single(x => x.Id == "lt").Sections.Single(); + var rightEdge = result.Edges.Single(x => x.Id == "rt").Sections.Single(); + + leftEdge.EndPoint.Y.Should().BeApproximately(target.Y, 0.01d); + rightEdge.EndPoint.Y.Should().BeApproximately(target.Y, 0.01d); + leftEdge.EndPoint.X.Should().NotBeApproximately(rightEdge.EndPoint.X, 0.01d); + } + + [Test] + public async Task LayoutAsync_WhenMultipleLaneFamiliesConvergeIntoEnd_ShouldReserveDistinctBundleBands() + { + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + var graph = new WorkflowRenderGraph + { + Id = "end-bundles", + Nodes = + [ + new WorkflowRenderNode + { + Id = "success-a", + Label = "Success A", + Kind = "Task", + Width = 140, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "success-b", + Label = "Success B", + Kind = "Task", + Width = 140, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "failure", + Label = "Failure", + Kind = "Task", + Width = 140, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "end", + Label = "End", + Kind = "End", + Width = 88, + Height = 48, + }, + ], + Edges = + [ + new WorkflowRenderEdge + { + Id = "success-1", + SourceNodeId = "success-a", + TargetNodeId = "end", + Label = "default", + }, + new WorkflowRenderEdge + { + Id = "success-2", + SourceNodeId = "success-b", + TargetNodeId = "end", + Label = "default", + }, + new WorkflowRenderEdge + { + Id = "failure-1", + SourceNodeId = "failure", + TargetNodeId = "end", + Label = "on failure", + }, + ], + }; + + var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + Effort = WorkflowRenderLayoutEffort.Best, + OrderingIterations = 18, + PlacementIterations = 10, + }); + + var successOne = result.Edges.Single(edge => edge.Id == "success-1").Sections.Single(); + var successTwo = result.Edges.Single(edge => edge.Id == "success-2").Sections.Single(); + var failure = result.Edges.Single(edge => edge.Id == "failure-1").Sections.Single(); + + var successBundleYOne = ResolvePreTargetBundleY(successOne); + var successBundleYTwo = ResolvePreTargetBundleY(successTwo); + var failureBundleY = ResolvePreTargetBundleY(failure); + + successBundleYOne.Should().BeApproximately(successBundleYTwo, 0.01d); + failureBundleY.Should().NotBeApproximately(successBundleYOne, 0.01d); + } + + [Test] + public async Task LayoutAsync_WhenLongEndFanInExists_ShouldUseExternalSinkCorridorInsteadOfInteriorDummyCenters() + { + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + var graph = new WorkflowRenderGraph + { + Id = "end-highway", + Nodes = + [ + new WorkflowRenderNode + { + Id = "start", + Label = "Start", + Kind = "Start", + Width = 88, + Height = 48, + }, + new WorkflowRenderNode + { + Id = "prepare", + Label = "Prepare", + Kind = "Task", + Width = 140, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "review", + Label = "Review", + Kind = "Task", + Width = 140, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "notify", + Label = "Notify", + Kind = "Task", + Width = 140, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "end", + Label = "End", + Kind = "End", + Width = 88, + Height = 48, + }, + ], + Edges = + [ + new WorkflowRenderEdge + { + Id = "start-prepare", + SourceNodeId = "start", + TargetNodeId = "prepare", + }, + new WorkflowRenderEdge + { + Id = "prepare-review", + SourceNodeId = "prepare", + TargetNodeId = "review", + }, + new WorkflowRenderEdge + { + Id = "review-notify", + SourceNodeId = "review", + TargetNodeId = "notify", + }, + new WorkflowRenderEdge + { + Id = "prepare-end", + SourceNodeId = "prepare", + TargetNodeId = "end", + Label = "default", + }, + new WorkflowRenderEdge + { + Id = "review-end", + SourceNodeId = "review", + TargetNodeId = "end", + Label = "default", + }, + new WorkflowRenderEdge + { + Id = "notify-end", + SourceNodeId = "notify", + TargetNodeId = "end", + Label = "on failure", + }, + ], + }; + + var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + Effort = WorkflowRenderLayoutEffort.Best, + OrderingIterations = 18, + PlacementIterations = 10, + }); + + var prepare = result.Nodes.Single(node => node.Id == "prepare"); + var end = result.Nodes.Single(node => node.Id == "end"); + var maxInteriorBottom = result.Nodes + .Where(node => !string.Equals(node.Id, "end", StringComparison.Ordinal)) + .Max(node => node.Y + node.Height); + var longEdge = result.Edges.Single(edge => edge.Id == "prepare-end").Sections.Single(); + var defaultPeer = result.Edges.Single(edge => edge.Id == "review-end").Sections.Single(); + + longEdge.BendPoints.Should().Contain(point => point.Y > maxInteriorBottom + 1d); + defaultPeer.BendPoints.Should().Contain(point => point.Y > maxInteriorBottom + 1d); + longEdge.BendPoints.Should().NotContain(point => + point.Y <= maxInteriorBottom + 1d + && point.X > prepare.X + prepare.Width + 96d + && point.X < end.X - 96d); + } + + [Test] + public async Task LayoutAsync_WhenBackwardFamilySharesTarget_ShouldUseSharedSourceCollectorColumn() + { + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + var graph = new WorkflowRenderGraph + { + Id = "backward-family-collector", + Nodes = + [ + new WorkflowRenderNode + { + Id = "target", + Label = "Target", + Kind = "Repeat", + Width = 160, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "a", + Label = "A", + Kind = "Decision", + Width = 144, + Height = 96, + }, + new WorkflowRenderNode + { + Id = "b", + Label = "B", + Kind = "Decision", + Width = 144, + Height = 96, + }, + new WorkflowRenderNode + { + Id = "c", + Label = "C", + Kind = "SetState", + Width = 176, + Height = 84, + }, + ], + Edges = + [ + new WorkflowRenderEdge + { + Id = "target-a", + SourceNodeId = "target", + TargetNodeId = "a", + }, + new WorkflowRenderEdge + { + Id = "a-b", + SourceNodeId = "a", + TargetNodeId = "b", + }, + new WorkflowRenderEdge + { + Id = "b-c", + SourceNodeId = "b", + TargetNodeId = "c", + }, + new WorkflowRenderEdge + { + Id = "loop-a", + SourceNodeId = "a", + TargetNodeId = "target", + Label = "repeat while retry", + }, + new WorkflowRenderEdge + { + Id = "loop-b", + SourceNodeId = "b", + TargetNodeId = "target", + Label = "repeat while retry", + }, + new WorkflowRenderEdge + { + Id = "loop-c", + SourceNodeId = "c", + TargetNodeId = "target", + Label = "repeat while retry", + }, + ], + }; + + var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + Effort = WorkflowRenderLayoutEffort.Best, + }); + + var loopEdges = result.Edges + .Where(edge => edge.Id is "loop-a" or "loop-b" or "loop-c") + .Select(edge => edge.Sections.Single()) + .ToArray(); + + loopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 3); + var sharedCollectorX = loopEdges[0].BendPoints.ElementAt(0).X; + loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(0).X - sharedCollectorX) <= 0.01d); + loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(1).X - sharedCollectorX) <= 0.01d); + var sharedCorridorY = loopEdges[0].BendPoints.ElementAt(1).Y; + loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(1).Y - sharedCorridorY) <= 0.01d); + } + + private static double ResolvePreTargetBundleY(WorkflowRenderEdgeSection section) + { + var preTargetX = section.BendPoints.Max(point => point.X); + var bundlePoint = section.BendPoints + .Where(point => Math.Abs(point.X - preTargetX) <= 0.01d && Math.Abs(point.Y - section.EndPoint.Y) > 0.01d) + .OrderBy(point => point.Y) + .First(); + + return bundlePoint.Y; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/MsaglWorkflowRenderLayoutEngineTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/MsaglWorkflowRenderLayoutEngineTests.cs new file mode 100644 index 000000000..2440888fd --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/MsaglWorkflowRenderLayoutEngineTests.cs @@ -0,0 +1,79 @@ +using FluentAssertions; +using NUnit.Framework; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Renderer.Msagl; + +namespace StellaOps.Workflow.Renderer.Tests; + +[TestFixture] +public class MsaglWorkflowRenderLayoutEngineTests +{ + [Test] + public async Task LayoutAsync_WhenSimpleLinearGraphProvided_ShouldReturnPositionedNodesAndEdges() + { + var engine = new MsaglWorkflowRenderLayoutEngine(); + var graph = new WorkflowRenderGraph + { + Id = "root", + Nodes = + [ + new WorkflowRenderNode + { + Id = "start", + Label = "Start", + Kind = "Start", + Width = 80, + Height = 40, + }, + new WorkflowRenderNode + { + Id = "task-1", + Label = "Review", + Kind = "Task", + Width = 160, + Height = 72, + }, + new WorkflowRenderNode + { + Id = "end", + Label = "End", + Kind = "End", + Width = 80, + Height = 40, + }, + ], + Edges = + [ + new WorkflowRenderEdge + { + Id = "e1", + SourceNodeId = "start", + TargetNodeId = "task-1", + }, + new WorkflowRenderEdge + { + Id = "e2", + SourceNodeId = "task-1", + TargetNodeId = "end", + }, + ], + }; + + var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + }); + + result.GraphId.Should().Be("root"); + result.Nodes.Should().HaveCount(3); + result.Edges.Should().HaveCount(2); + + var orderedNodes = result.Nodes.OrderBy(x => x.X).ToArray(); + orderedNodes.Select(x => x.Id).Should().ContainInOrder("start", "task-1", "end"); + result.Edges.Should().OnlyContain(x => x.Sections.Count > 0); + result.Edges.SelectMany(x => x.Sections).Should().OnlyContain(section => + Math.Abs(section.StartPoint.X) > 0.001d + && Math.Abs(section.EndPoint.X) > 0.001d); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj new file mode 100644 index 000000000..0da8be957 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj @@ -0,0 +1,40 @@ + + + net10.0 + false + enable + enable + false + true + false + + CS8601;CS8602;CS8604;NU1015 + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderSvgRendererTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderSvgRendererTests.cs new file mode 100644 index 000000000..72e3a3d46 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderSvgRendererTests.cs @@ -0,0 +1,261 @@ +using FluentAssertions; +using NUnit.Framework; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Renderer.Svg; + +namespace StellaOps.Workflow.Renderer.Tests; + +[TestFixture] +public class WorkflowRenderSvgRendererTests +{ + [Test] + public void Render_WhenTaskGatewayAndConditionsExist_ShouldEmitBoxesDiamondsLegendAndStyledBranches() + { + var renderer = new WorkflowRenderSvgRenderer(); + var layout = new WorkflowRenderLayoutResult + { + GraphId = "svg", + Nodes = + [ + new WorkflowRenderPositionedNode + { + Id = "start", + Label = "Start", + Kind = "Start", + IconKey = "start", + X = 0, + Y = 12, + Width = 128, + Height = 64, + }, + new WorkflowRenderPositionedNode + { + Id = "task", + Label = "Call Pricing", + Kind = "TransportCall", + IconKey = "transport", + X = 220, + Y = 0, + Width = 196, + Height = 84, + }, + new WorkflowRenderPositionedNode + { + Id = "timer", + Label = "Wait For Timeout", + Kind = "Timer", + IconKey = "timer", + X = 220, + Y = 126, + Width = 196, + Height = 84, + }, + new WorkflowRenderPositionedNode + { + Id = "decision", + Label = "Approved?", + Kind = "Decision", + IconKey = "decision", + X = 500, + Y = 0, + Width = 144, + Height = 96, + }, + new WorkflowRenderPositionedNode + { + Id = "human", + Label = "Review Documents", + Kind = "HumanTask", + IconKey = "human", + X = 500, + Y = 126, + Width = 196, + Height = 84, + }, + new WorkflowRenderPositionedNode + { + Id = "fork", + Label = "Fan Out", + Kind = "Fork", + IconKey = "fork", + X = 720, + Y = 0, + Width = 144, + Height = 96, + }, + ], + Edges = + [ + new WorkflowRenderRoutedEdge + { + Id = "e1", + SourceNodeId = "start", + TargetNodeId = "task", + Sections = + [ + new WorkflowRenderEdgeSection + { + StartPoint = new WorkflowRenderPoint { X = 128, Y = 44 }, + EndPoint = new WorkflowRenderPoint { X = 220, Y = 42 }, + BendPoints = [], + }, + ], + }, + new WorkflowRenderRoutedEdge + { + Id = "e2", + SourceNodeId = "task", + TargetNodeId = "decision", + Label = "when payload.answer == \"approve\"", + Sections = + [ + new WorkflowRenderEdgeSection + { + StartPoint = new WorkflowRenderPoint { X = 416, Y = 42 }, + EndPoint = new WorkflowRenderPoint { X = 500, Y = 48 }, + BendPoints = [], + }, + ], + }, + new WorkflowRenderRoutedEdge + { + Id = "e3", + SourceNodeId = "task", + TargetNodeId = "timer", + Label = "on timeout", + Sections = + [ + new WorkflowRenderEdgeSection + { + StartPoint = new WorkflowRenderPoint { X = 416, Y = 42 }, + EndPoint = new WorkflowRenderPoint { X = 220, Y = 168 }, + BendPoints = + [ + new WorkflowRenderPoint { X = 460, Y = 42 }, + new WorkflowRenderPoint { X = 460, Y = 168 }, + ], + }, + ], + }, + ], + }; + + var document = renderer.Render(layout, "SvgSmoke"); + + document.Svg.Should().Contain("Wait For Timeout<"); + document.Svg.Should().Contain(">Review Documents<"); + document.Svg.Should().Contain("fill=\"#f4fdfb\" stroke=\"#0f766e\" stroke-width=\"3.5\""); + document.Svg.Should().Contain("Approved?"); + document.Svg.Should().NotContain("data-badge-kind=\"Timer\""); + document.Svg.Should().NotContain(">F<"); + document.Svg.Should().NotContain(">?<"); + } + + [Test] + public void Render_WhenOrthogonalEdgesCross_ShouldEmitBridgeGap() + { + var renderer = new WorkflowRenderSvgRenderer(); + var layout = new WorkflowRenderLayoutResult + { + GraphId = "crossings", + Nodes = + [ + new WorkflowRenderPositionedNode + { + Id = "left", + Label = "Left", + Kind = "Task", + X = 0, + Y = 70, + Width = 96, + Height = 56, + }, + new WorkflowRenderPositionedNode + { + Id = "right", + Label = "Right", + Kind = "Task", + X = 280, + Y = 70, + Width = 96, + Height = 56, + }, + new WorkflowRenderPositionedNode + { + Id = "top", + Label = "Top", + Kind = "Task", + X = 140, + Y = 0, + Width = 96, + Height = 56, + }, + new WorkflowRenderPositionedNode + { + Id = "bottom", + Label = "Bottom", + Kind = "Task", + X = 140, + Y = 180, + Width = 96, + Height = 56, + }, + ], + Edges = + [ + new WorkflowRenderRoutedEdge + { + Id = "horizontal", + SourceNodeId = "left", + TargetNodeId = "right", + Sections = + [ + new WorkflowRenderEdgeSection + { + StartPoint = new WorkflowRenderPoint { X = 96, Y = 98 }, + EndPoint = new WorkflowRenderPoint { X = 280, Y = 98 }, + BendPoints = [], + }, + ], + }, + new WorkflowRenderRoutedEdge + { + Id = "vertical", + SourceNodeId = "top", + TargetNodeId = "bottom", + Label = "on failure", + Sections = + [ + new WorkflowRenderEdgeSection + { + StartPoint = new WorkflowRenderPoint { X = 188, Y = 56 }, + EndPoint = new WorkflowRenderPoint { X = 188, Y = 180 }, + BendPoints = [], + }, + ], + }, + ], + }; + + var document = renderer.Render(layout, "BridgeGap"); + + document.Svg.Should().Contain("data-bridge-gap=\"true\""); + document.Svg.Should().Contain("M 214.93,318"); + document.Svg.Should().Contain("L 225.07,318"); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderingBenchmark.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderingBenchmark.cs new file mode 100644 index 000000000..4777afdd9 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderingBenchmark.cs @@ -0,0 +1,395 @@ +using System.Text.Json; + +using StellaOps.Workflow.Abstractions; + +using Microsoft.Extensions.DependencyInjection; + +namespace StellaOps.Workflow.Renderer.Tests; + +internal sealed record WorkflowRenderLayoutMetrics +{ + public required int NodeOverlapCount { get; init; } + public required int EdgeNodeIntersectionCount { get; init; } + public required int EdgeCrossingCount { get; init; } + public required int BendCount { get; init; } + public required int PortViolationCount { get; init; } + public required int FeedbackEdgeCount { get; init; } + public required int FeedbackBandViolationCount { get; init; } + public required double ForwardEdgeRatio { get; init; } + public required double Area { get; init; } + public required double QualityScore { get; init; } +} + +internal sealed record WorkflowRenderBenchmarkEntry +{ + public required string WorkflowName { get; init; } + public required string ProviderName { get; init; } + public required WorkflowRenderLayoutMetrics Metrics { get; init; } +} + +internal static class WorkflowRenderingBenchmark +{ + internal static async Task> MeasureAsync( + ServiceProvider provider, + IReadOnlyCollection workflowNames, + IReadOnlyCollection providerNames, + CancellationToken cancellationToken = default) + { + var store = provider.GetRequiredService(); + var compiler = provider.GetRequiredService(); + var resolver = provider.GetRequiredService(); + var entries = new List(workflowNames.Count * providerNames.Count); + + foreach (var workflowName in workflowNames) + { + cancellationToken.ThrowIfCancellationRequested(); + var definition = store.GetRequiredDefinition(workflowName); + var graph = compiler.Compile(definition); + foreach (var providerName in providerNames) + { + var layout = await resolver.Resolve(providerName).LayoutAsync(graph, cancellationToken: cancellationToken); + entries.Add(new WorkflowRenderBenchmarkEntry + { + WorkflowName = workflowName, + ProviderName = providerName, + Metrics = CalculateMetrics(layout), + }); + } + } + + return entries; + } + + internal static async Task WriteSummaryAsync( + IReadOnlyCollection entries, + string outputPath, + CancellationToken cancellationToken = default) + { + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); + await File.WriteAllTextAsync( + outputPath, + JsonSerializer.Serialize(entries, new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + WriteIndented = true, + }), + cancellationToken); + } + + internal static WorkflowRenderLayoutMetrics CalculateMetrics(WorkflowRenderLayoutResult layout) + { + var nodesById = layout.Nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var nodeRects = layout.Nodes + .ToDictionary(node => node.Id, node => ToRect(node), StringComparer.Ordinal); + var segments = layout.Edges + .SelectMany(edge => EnumerateSegments(edge).Select(segment => new EdgeSegment(edge, segment.Start, segment.End))) + .ToArray(); + + var nodeOverlapCount = 0; + for (var leftIndex = 0; leftIndex < layout.Nodes.Count; leftIndex++) + { + for (var rightIndex = leftIndex + 1; rightIndex < layout.Nodes.Count; rightIndex++) + { + var left = layout.Nodes.ElementAt(leftIndex); + var right = layout.Nodes.ElementAt(rightIndex); + if (RectanglesOverlap(nodeRects[left.Id], nodeRects[right.Id], 0.25d)) + { + nodeOverlapCount++; + } + } + } + + var edgeNodeIntersectionCount = 0; + foreach (var segment in segments) + { + foreach (var node in layout.Nodes) + { + if (string.Equals(node.Id, segment.Edge.SourceNodeId, StringComparison.Ordinal) + || string.Equals(node.Id, segment.Edge.TargetNodeId, StringComparison.Ordinal)) + { + continue; + } + + if (SegmentIntersectsRect(segment.Start, segment.End, nodeRects[node.Id])) + { + edgeNodeIntersectionCount++; + } + } + } + + var edgeCrossingCount = 0; + for (var leftIndex = 0; leftIndex < segments.Length; leftIndex++) + { + for (var rightIndex = leftIndex + 1; rightIndex < segments.Length; rightIndex++) + { + var left = segments[leftIndex]; + var right = segments[rightIndex]; + if (string.Equals(left.Edge.Id, right.Edge.Id, StringComparison.Ordinal)) + { + continue; + } + + if (string.Equals(left.Edge.SourceNodeId, right.Edge.SourceNodeId, StringComparison.Ordinal) + || string.Equals(left.Edge.SourceNodeId, right.Edge.TargetNodeId, StringComparison.Ordinal) + || string.Equals(left.Edge.TargetNodeId, right.Edge.SourceNodeId, StringComparison.Ordinal) + || string.Equals(left.Edge.TargetNodeId, right.Edge.TargetNodeId, StringComparison.Ordinal)) + { + continue; + } + + if (SegmentsCross(left.Start, left.End, right.Start, right.End)) + { + edgeCrossingCount++; + } + } + } + + var bendCount = layout.Edges.Sum(edge => edge.Sections.Sum(section => section.BendPoints.Count)); + var portViolationCount = layout.Edges.Sum(edge => CountPortViolations(edge, nodesById)); + var feedbackEdges = layout.Edges.Where(edge => IsFeedbackEdge(edge, nodesById)).ToArray(); + var feedbackBandViolationCount = feedbackEdges.Count(edge => !IsFeedbackEdgeRoutedAboveMainFlow(edge, nodesById)); + var forwardEdgeRatio = CalculateForwardEdgeRatio(layout, nodesById); + var area = CalculateArea(layout.Nodes); + var qualityScore = (forwardEdgeRatio * 100d) + - (nodeOverlapCount * 1000d) + - (edgeNodeIntersectionCount * 150d) + - (edgeCrossingCount * 12d) + - (bendCount * 1.5d) + - (portViolationCount * 80d) + - (feedbackBandViolationCount * 35d) + - (area / 50000d); + + return new WorkflowRenderLayoutMetrics + { + NodeOverlapCount = nodeOverlapCount, + EdgeNodeIntersectionCount = edgeNodeIntersectionCount, + EdgeCrossingCount = edgeCrossingCount, + BendCount = bendCount, + PortViolationCount = portViolationCount, + FeedbackEdgeCount = feedbackEdges.Length, + FeedbackBandViolationCount = feedbackBandViolationCount, + ForwardEdgeRatio = forwardEdgeRatio, + Area = area, + QualityScore = qualityScore, + }; + } + + private static int CountPortViolations( + WorkflowRenderRoutedEdge edge, + IReadOnlyDictionary nodesById) + { + if (edge.Sections.Count == 0) + { + return 0; + } + + var section = edge.Sections.ElementAt(0); + var violations = 0; + if (!string.IsNullOrWhiteSpace(edge.SourcePortId)) + { + var port = nodesById[edge.SourceNodeId].Ports.FirstOrDefault(candidate => + string.Equals(candidate.Id, edge.SourcePortId, StringComparison.Ordinal)); + if (port is not null && !PointsEqual(section.StartPoint, port.X + (port.Width / 2d), port.Y + (port.Height / 2d))) + { + violations++; + } + } + + if (!string.IsNullOrWhiteSpace(edge.TargetPortId)) + { + var port = nodesById[edge.TargetNodeId].Ports.FirstOrDefault(candidate => + string.Equals(candidate.Id, edge.TargetPortId, StringComparison.Ordinal)); + if (port is not null && !PointsEqual(section.EndPoint, port.X + (port.Width / 2d), port.Y + (port.Height / 2d))) + { + violations++; + } + } + + return violations; + } + + private static bool PointsEqual(WorkflowRenderPoint point, double x, double y) + { + return Math.Abs(point.X - x) <= 0.1d && Math.Abs(point.Y - y) <= 0.1d; + } + + private static bool IsFeedbackEdge( + WorkflowRenderRoutedEdge edge, + IReadOnlyDictionary nodesById) + { + var source = nodesById[edge.SourceNodeId]; + var target = nodesById[edge.TargetNodeId]; + return (target.X + (target.Width / 2d)) < (source.X + (source.Width / 2d)) - 1d; + } + + private static bool IsFeedbackEdgeRoutedAboveMainFlow( + WorkflowRenderRoutedEdge edge, + IReadOnlyDictionary nodesById) + { + if (edge.Sections.Count == 0) + { + return true; + } + + var source = nodesById[edge.SourceNodeId]; + var target = nodesById[edge.TargetNodeId]; + var threshold = Math.Min(source.Y, target.Y) + 1d; + return edge.Sections + .SelectMany(section => section.BendPoints) + .All(point => point.Y < threshold); + } + + private static double CalculateForwardEdgeRatio( + WorkflowRenderLayoutResult layout, + IReadOnlyDictionary nodesById) + { + if (layout.Edges.Count == 0) + { + return 1d; + } + + var forwardEdges = layout.Edges.Count(edge => + { + var source = nodesById[edge.SourceNodeId]; + var target = nodesById[edge.TargetNodeId]; + return (target.X + (target.Width / 2d)) >= (source.X + (source.Width / 2d)) - 1d; + }); + + return forwardEdges / (double)layout.Edges.Count; + } + + private static double CalculateArea(IReadOnlyCollection nodes) + { + if (nodes.Count == 0) + { + return 0d; + } + + var minX = nodes.Min(node => node.X); + var minY = nodes.Min(node => node.Y); + var maxX = nodes.Max(node => node.X + node.Width); + var maxY = nodes.Max(node => node.Y + node.Height); + return Math.Max(1d, maxX - minX) * Math.Max(1d, maxY - minY); + } + + private static IEnumerable<(WorkflowRenderPoint Start, WorkflowRenderPoint End)> EnumerateSegments(WorkflowRenderRoutedEdge edge) + { + foreach (var section in edge.Sections) + { + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); + for (var index = 0; index < points.Count - 1; index++) + { + yield return (points[index], points[index + 1]); + } + } + } + + private static WorkflowRenderRect ToRect(WorkflowRenderPositionedNode node) + { + return new WorkflowRenderRect(node.X, node.Y, node.Width, node.Height); + } + + private static bool RectanglesOverlap(WorkflowRenderRect first, WorkflowRenderRect second, double epsilon) + { + return first.Left < second.Right - epsilon + && first.Right > second.Left + epsilon + && first.Top < second.Bottom - epsilon + && first.Bottom > second.Top + epsilon; + } + + private static bool SegmentIntersectsRect( + WorkflowRenderPoint start, + WorkflowRenderPoint end, + WorkflowRenderRect rect) + { + const double epsilon = 0.25d; + if (Math.Abs(start.Y - end.Y) <= 0.01d) + { + var y = start.Y; + if (y <= rect.Top + epsilon || y >= rect.Bottom - epsilon) + { + return false; + } + + var minX = Math.Min(start.X, end.X); + var maxX = Math.Max(start.X, end.X); + return minX < rect.Right - epsilon && maxX > rect.Left + epsilon; + } + + if (Math.Abs(start.X - end.X) <= 0.01d) + { + var x = start.X; + if (x <= rect.Left + epsilon || x >= rect.Right - epsilon) + { + return false; + } + + var minY = Math.Min(start.Y, end.Y); + var maxY = Math.Max(start.Y, end.Y); + return minY < rect.Bottom - epsilon && maxY > rect.Top + epsilon; + } + + var segmentRect = new WorkflowRenderRect( + Math.Min(start.X, end.X), + Math.Min(start.Y, end.Y), + Math.Abs(end.X - start.X), + Math.Abs(end.Y - start.Y)); + return RectanglesOverlap(segmentRect, rect, epsilon); + } + + private static bool SegmentsCross( + WorkflowRenderPoint firstStart, + WorkflowRenderPoint firstEnd, + WorkflowRenderPoint secondStart, + WorkflowRenderPoint secondEnd) + { + const double epsilon = 0.01d; + var firstHorizontal = Math.Abs(firstStart.Y - firstEnd.Y) <= epsilon; + var firstVertical = Math.Abs(firstStart.X - firstEnd.X) <= epsilon; + var secondHorizontal = Math.Abs(secondStart.Y - secondEnd.Y) <= epsilon; + var secondVertical = Math.Abs(secondStart.X - secondEnd.X) <= epsilon; + + if (firstHorizontal && secondVertical) + { + return IsInteriorCrossing(firstStart, firstEnd, secondStart, secondEnd); + } + + if (firstVertical && secondHorizontal) + { + return IsInteriorCrossing(secondStart, secondEnd, firstStart, firstEnd); + } + + return false; + } + + private static bool IsInteriorCrossing( + WorkflowRenderPoint horizontalStart, + WorkflowRenderPoint horizontalEnd, + WorkflowRenderPoint verticalStart, + WorkflowRenderPoint verticalEnd) + { + var minHorizontalX = Math.Min(horizontalStart.X, horizontalEnd.X); + var maxHorizontalX = Math.Max(horizontalStart.X, horizontalEnd.X); + var minVerticalY = Math.Min(verticalStart.Y, verticalEnd.Y); + var maxVerticalY = Math.Max(verticalStart.Y, verticalEnd.Y); + var intersectionX = verticalStart.X; + var intersectionY = horizontalStart.Y; + + return intersectionX > minHorizontalX + 0.1d + && intersectionX < maxHorizontalX - 0.1d + && intersectionY > minVerticalY + 0.1d + && intersectionY < maxVerticalY - 0.1d; + } + + private sealed record EdgeSegment( + WorkflowRenderRoutedEdge Edge, + WorkflowRenderPoint Start, + WorkflowRenderPoint End); + + private readonly record struct WorkflowRenderRect(double Left, double Top, double Width, double Height) + { + public double Right => Left + Width; + public double Bottom => Top + Height; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderingBenchmarkTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderingBenchmarkTests.cs new file mode 100644 index 000000000..1517bf414 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/WorkflowRenderingBenchmarkTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; +using NUnit.Framework; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Engine.Tests; + +using Microsoft.Extensions.DependencyInjection; + +namespace StellaOps.Workflow.Renderer.Tests; + +[TestFixture] +public class WorkflowRenderingBenchmarkTests +{ + [Test] + public async Task ElkSharp_WhenRenderingAssistantPrintInsisDocuments_ShouldAvoidTopologyViolationsAndKeepFeedbackAboveMainFlow() + { + using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider(); + var store = provider.GetRequiredService(); + var compiler = provider.GetRequiredService(); + var resolver = provider.GetRequiredService(); + var graph = compiler.Compile(store.GetRequiredDefinition("AssistantPrintInsisDocuments")); + + var layout = await resolver.Resolve(WorkflowRenderLayoutProviderNames.ElkSharp).LayoutAsync(graph); + var metrics = WorkflowRenderingBenchmark.CalculateMetrics(layout); + TestContext.Out.WriteLine( + $"AssistantPrintInsisDocuments metrics: overlaps={metrics.NodeOverlapCount}, edgeNode={metrics.EdgeNodeIntersectionCount}, crossings={metrics.EdgeCrossingCount}, bends={metrics.BendCount}, feedback={metrics.FeedbackEdgeCount}, feedbackBand={metrics.FeedbackBandViolationCount}, forwardRatio={metrics.ForwardEdgeRatio:0.###}, score={metrics.QualityScore:0.##}"); + + metrics.NodeOverlapCount.Should().Be(0); + metrics.PortViolationCount.Should().Be(0); + metrics.FeedbackBandViolationCount.Should().BeLessThanOrEqualTo(1); + metrics.ForwardEdgeRatio.Should().BeGreaterThan(0.55d); + metrics.FeedbackEdgeCount.Should().BeGreaterThan(0); + } + + [Test] + [Category("RenderingBenchmark")] + public async Task ElkSharp_WhenBenchmarkingCanonicalCorpus_ShouldProduceMetricsSummaryWithoutNodeOverlaps() + { + using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider(); + var workflowNames = WorkflowRenderingTestHelpers.GetAllCanonicalWorkflowNames(provider); + var entries = await WorkflowRenderingBenchmark.MeasureAsync( + provider, + workflowNames, + [WorkflowRenderLayoutProviderNames.ElkSharp]); + var outputPath = Path.Combine( + TestContext.CurrentContext.WorkDirectory, + "workflow-render-benchmarks", + "elksharp-corpus-summary.json"); + + await WorkflowRenderingBenchmark.WriteSummaryAsync(entries, outputPath); + + File.Exists(outputPath).Should().BeTrue(); + entries.Should().HaveCount(workflowNames.Length); + entries.Should().OnlyContain(entry => entry.Metrics.NodeOverlapCount == 0); + entries.Should().OnlyContain(entry => entry.Metrics.PortViolationCount == 0); + entries.Should().OnlyContain(entry => !double.IsNaN(entry.Metrics.QualityScore) && !double.IsInfinity(entry.Metrics.QualityScore)); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Signaling.Redis.Tests/RedisDockerFixture.cs b/src/Workflow/__Tests/StellaOps.Workflow.Signaling.Redis.Tests/RedisDockerFixture.cs new file mode 100644 index 000000000..f5bbb5ffe --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Signaling.Redis.Tests/RedisDockerFixture.cs @@ -0,0 +1,153 @@ +using System; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +using StackExchange.Redis; + +using NUnit.Framework; + +namespace StellaOps.Workflow.Signaling.Redis.Tests; + +public sealed class RedisDockerFixture : IDisposable +{ + private readonly string containerName = $"stella-workflow-redis-{Guid.NewGuid():N}"; + private readonly int hostPort = GetFreeTcpPort(); + private bool started; + + public string ConnectionString => $"127.0.0.1:{hostPort},abortConnect=false,connectRetry=5,connectTimeout=1000"; + + public async Task StartOrIgnoreAsync(CancellationToken cancellationToken = default) + { + if (started) + { + return; + } + + if (!await CanUseDockerAsync(cancellationToken)) + { + Assert.Ignore("Docker is not available. Redis-backed workflow integration tests require a local Docker daemon."); + } + + var runExitCode = await RunDockerCommandAsync( + $"run -d --name {containerName} -p {hostPort}:6379 redis:7-alpine", + ignoreErrors: false, + cancellationToken); + if (runExitCode != 0) + { + Assert.Ignore("Unable to start Redis Docker container for workflow integration tests."); + } + + started = true; + try + { + await WaitUntilReadyAsync(cancellationToken); + } + catch + { + Dispose(); + throw; + } + } + + public void Dispose() + { + if (!started) + { + return; + } + + try + { + RunDockerCommandAsync($"rm -f {containerName}", ignoreErrors: true, CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + catch + { + } + finally + { + started = false; + } + } + + private async Task WaitUntilReadyAsync(CancellationToken cancellationToken) + { + var timeoutAt = DateTime.UtcNow.AddSeconds(30); + while (DateTime.UtcNow < timeoutAt) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await using var connection = await ConnectionMultiplexer.ConnectAsync(ConnectionString); + var db = connection.GetDatabase(); + await db.PingAsync(); + return; + } + catch + { + await Task.Delay(500, cancellationToken); + } + } + + Dispose(); + Assert.Ignore("Redis Docker container did not become ready in time for workflow integration tests."); + } + + private static async Task CanUseDockerAsync(CancellationToken cancellationToken) + { + return await RunDockerCommandAsync("version --format {{.Server.Version}}", ignoreErrors: true, cancellationToken) == 0; + } + + private static async Task 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(); + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Signaling.Redis.Tests/RedisWorkflowSignalDriverIntegrationTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Signaling.Redis.Tests/RedisWorkflowSignalDriverIntegrationTests.cs new file mode 100644 index 000000000..5b4ccaa94 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Signaling.Redis.Tests/RedisWorkflowSignalDriverIntegrationTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Signaling.Redis; + +using FluentAssertions; + +using Microsoft.Extensions.Options; + +using NUnit.Framework; + +using StackExchange.Redis; + +namespace StellaOps.Workflow.Signaling.Redis.Tests; + +[TestFixture] +[Category("Integration")] +public class RedisWorkflowSignalDriverIntegrationTests +{ + private RedisDockerFixture? fixture; + private string? channelName; + + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + fixture = new RedisDockerFixture(); + await fixture.StartOrIgnoreAsync(); + channelName = $"stella:test:workflow:wake:{Guid.NewGuid():N}"; + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + fixture?.Dispose(); + } + + [Test] + public async Task RedisWorkflowSignalDriver_ShouldWakeAndClaimSignal() + { + await using var connection = await ConnectionMultiplexer.ConnectAsync(fixture!.ConnectionString); + var claimStore = new RecordingSignalClaimStore(); + claimStore.NextClaims.Enqueue(null); + claimStore.NextClaims.Enqueue(new RecordingSignalLease(CreateEnvelope("redis-driver-1"))); + var driverOptions = Options.Create(new RedisWorkflowSignalDriverOptions + { + ChannelName = channelName!, + BlockingWaitSeconds = 5, + }); + await using var wakeSubscription = new RedisWorkflowWakeSubscription(connection, driverOptions); + var driver = new RedisWorkflowSignalDriver( + connection, + claimStore, + wakeSubscription, + driverOptions); + + var receiveTask = driver.ReceiveAsync("consumer-a"); + await Task.Delay(250); + await driver.NotifySignalAvailableAsync(CreateNotification("redis-driver-1")); + + await using var lease = await receiveTask; + lease.Should().NotBeNull(); + lease!.Envelope.SignalId.Should().Be("redis-driver-1"); + claimStore.ConsumerNames.Should().Contain("consumer-a"); + } + + [Test] + public async Task RedisWorkflowSignalDriver_ShouldUsePostCommitDispatchMode() + { + await using var connection = await ConnectionMultiplexer.ConnectAsync(fixture!.ConnectionString); + var claimStore = new RecordingSignalClaimStore(); + var driverOptions = Options.Create(new RedisWorkflowSignalDriverOptions + { + ChannelName = channelName!, + BlockingWaitSeconds = 5, + }); + await using var wakeSubscription = new RedisWorkflowWakeSubscription(connection, driverOptions); + var driver = new RedisWorkflowSignalDriver( + connection, + claimStore, + wakeSubscription, + driverOptions); + + driver.DispatchMode.Should().Be(WorkflowSignalDriverDispatchMode.PostCommitNotification); + } + + private static WorkflowSignalEnvelope CreateEnvelope(string signalId) + { + return new WorkflowSignalEnvelope + { + SignalId = signalId, + WorkflowInstanceId = $"wf-{signalId}", + RuntimeProvider = WorkflowRuntimeProviderNames.Engine, + SignalType = WorkflowSignalTypes.ExternalSignal, + ExpectedVersion = 1, + }; + } + + private static WorkflowSignalWakeNotification CreateNotification(string signalId) + { + return new WorkflowSignalWakeNotification + { + SignalId = signalId, + WorkflowInstanceId = $"wf-{signalId}", + RuntimeProvider = WorkflowRuntimeProviderNames.Engine, + SignalType = WorkflowSignalTypes.ExternalSignal, + }; + } + + private sealed class RecordingSignalClaimStore : IWorkflowSignalClaimStore + { + public Queue NextClaims { get; } = new(); + public ConcurrentBag ConsumerNames { get; } = []; + + public Task TryClaimAsync( + string consumerName, + CancellationToken cancellationToken = default) + { + ConsumerNames.Add(consumerName); + return Task.FromResult(NextClaims.Count == 0 ? null : NextClaims.Dequeue()); + } + } + + private sealed class RecordingSignalLease(WorkflowSignalEnvelope envelope) : IWorkflowSignalLease + { + public WorkflowSignalEnvelope Envelope { get; } = envelope; + public int DeliveryCount => 1; + + public Task CompleteAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task AbandonAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task DeadLetterAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Signaling.Redis.Tests/StellaOps.Workflow.Signaling.Redis.Tests.csproj b/src/Workflow/__Tests/StellaOps.Workflow.Signaling.Redis.Tests/StellaOps.Workflow.Signaling.Redis.Tests.csproj new file mode 100644 index 000000000..52cd4a591 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Signaling.Redis.Tests/StellaOps.Workflow.Signaling.Redis.Tests.csproj @@ -0,0 +1,32 @@ + + + net10.0 + false + enable + enable + false + true + false + + CS8601;CS8602;CS8604;NU1015 + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Endpoints/WorkflowE2ETests.cs b/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Endpoints/WorkflowE2ETests.cs new file mode 100644 index 000000000..b4ae0b7b9 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Endpoints/WorkflowE2ETests.cs @@ -0,0 +1,228 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +using FluentAssertions; + +using NUnit.Framework; + +using StellaOps.Workflow.Contracts; +using StellaOps.Workflow.WebService.Tests.Fixtures; + +namespace StellaOps.Workflow.WebService.Tests.Endpoints; + +[TestFixture] +public class WorkflowE2ETests +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + }; + + private WorkflowWebApplicationFactory _factory = null!; + private HttpClient _client = null!; + + [OneTimeSetUp] + public void Setup() + { + _factory = new WorkflowWebApplicationFactory(); + _client = _factory.CreateClient(); + } + + [OneTimeTearDown] + public void Teardown() + { + _client?.Dispose(); + _factory?.Dispose(); + } + + [Test] + public async Task GetDefinitions_ShouldListSimpleApproval() + { + // Act + var response = await _client.GetAsync("/api/workflow/definitions"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadFromJsonAsync(JsonOptions); + body.Should().NotBeNull(); + body!.Definitions.Should().Contain(d => d.WorkflowName == "SimpleApproval"); + + var definition = body.Definitions.First(d => d.WorkflowName == "SimpleApproval"); + definition.WorkflowVersion.Should().Be("1.0.0"); + definition.DisplayName.Should().Be("Simple Approval"); + definition.WorkflowRoles.Should().Contain("APPROVER"); + definition.Tasks.Should().ContainSingle(t => t.TaskName == "Review"); + } + + [Test] + public async Task FullWorkflowLifecycle_SimpleApproval() + { + // ──────────────────────────────────────────── + // 1. Start the workflow + // ──────────────────────────────────────────── + var startPayload = new StartWorkflowRequest + { + WorkflowName = "SimpleApproval", + Payload = new Dictionary + { + ["requestId"] = "REQ-001", + ["description"] = "Test approval request", + }, + }; + + var startResponse = await _client.PostAsJsonAsync("/api/workflow/start", startPayload, JsonOptions); + startResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var startResult = await startResponse.Content.ReadFromJsonAsync(JsonOptions); + startResult.Should().NotBeNull(); + startResult!.WorkflowName.Should().Be("SimpleApproval"); + startResult.WorkflowVersion.Should().Be("1.0.0"); + + var instanceId = startResult.WorkflowInstanceId; + instanceId.Should().NotBeNullOrEmpty(); + + // ──────────────────────────────────────────── + // 2. Query open tasks for this instance + // ──────────────────────────────────────────── + var tasksResponse = await _client.GetAsync( + $"/api/workflow/tasks?workflowInstanceId={instanceId}"); + tasksResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var tasksResult = await tasksResponse.Content.ReadFromJsonAsync(JsonOptions); + tasksResult.Should().NotBeNull(); + tasksResult!.Tasks.Should().ContainSingle(); + + var reviewTask = tasksResult.Tasks.First(); + reviewTask.TaskName.Should().Be("Review"); + reviewTask.TaskType.Should().Be("ReviewTask"); + reviewTask.Status.Should().Be("Pending"); + reviewTask.WorkflowInstanceId.Should().Be(instanceId); + reviewTask.Payload.Should().ContainKey("description"); + + var taskId = reviewTask.WorkflowTaskId; + taskId.Should().NotBeNullOrEmpty(); + + // ──────────────────────────────────────────── + // 3. Get the specific task by ID + // ──────────────────────────────────────────── + var singleTaskResponse = await _client.GetAsync( + $"/api/workflow/tasks/{taskId}?actorId=user-1"); + singleTaskResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var singleTaskResult = await singleTaskResponse.Content.ReadFromJsonAsync(JsonOptions); + singleTaskResult.Should().NotBeNull(); + singleTaskResult!.Task.WorkflowTaskId.Should().Be(taskId); + singleTaskResult.Task.TaskName.Should().Be("Review"); + + // ──────────────────────────────────────────── + // 4. Complete the task with decision = "approved" + // ──────────────────────────────────────────── + var completePayload = new WorkflowTaskCompleteRequest + { + WorkflowTaskId = taskId, + ActorId = "user-1", + ActorRoles = ["APPROVER"], + Payload = new Dictionary + { + ["decision"] = "approved", + }, + }; + + var completeResponse = await _client.PostAsJsonAsync( + $"/api/workflow/tasks/{taskId}/complete", completePayload, JsonOptions); + completeResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var completeResult = await completeResponse.Content.ReadFromJsonAsync(JsonOptions); + completeResult.Should().NotBeNull(); + completeResult!.WorkflowTaskId.Should().Be(taskId); + completeResult.Completed.Should().BeTrue(); + + // ──────────────────────────────────────────── + // 5. Verify the task is now completed + // ──────────────────────────────────────────── + var completedTasksResponse = await _client.GetAsync( + $"/api/workflow/tasks?workflowInstanceId={instanceId}&status=Pending"); + completedTasksResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var completedTasks = await completedTasksResponse.Content.ReadFromJsonAsync(JsonOptions); + completedTasks.Should().NotBeNull(); + completedTasks!.Tasks.Should().BeEmpty("all tasks should be completed"); + + // ──────────────────────────────────────────── + // 6. Verify the instance is completed + // ──────────────────────────────────────────── + var instanceResponse = await _client.GetAsync( + $"/api/workflow/instances/{instanceId}"); + instanceResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var instanceResult = await instanceResponse.Content.ReadFromJsonAsync(JsonOptions); + instanceResult.Should().NotBeNull(); + instanceResult!.Instance.WorkflowInstanceId.Should().Be(instanceId); + instanceResult.Instance.WorkflowName.Should().Be("SimpleApproval"); + instanceResult.Instance.Status.Should().Be("Completed"); + instanceResult.Instance.CompletedOnUtc.Should().NotBeNull(); + + // The workflow state should contain the decision + instanceResult.WorkflowState.Should().ContainKey("decision"); + + // There should be task events: Created and Completed + instanceResult.TaskEvents.Should().Contain(e => e.EventType == "Created"); + instanceResult.TaskEvents.Should().Contain(e => e.EventType == "Completed"); + } + + [Test] + public async Task GetInstances_AfterStarting_ShouldReturnOpenInstance() + { + // Arrange: Start a workflow + var startPayload = new StartWorkflowRequest + { + WorkflowName = "SimpleApproval", + Payload = new Dictionary + { + ["requestId"] = "REQ-LIST-001", + ["description"] = "Instance listing test", + }, + }; + + var startResponse = await _client.PostAsJsonAsync("/api/workflow/start", startPayload, JsonOptions); + startResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var startResult = await startResponse.Content.ReadFromJsonAsync(JsonOptions); + var instanceId = startResult!.WorkflowInstanceId; + + // Act: List instances + var instancesResponse = await _client.GetAsync( + $"/api/workflow/instances?workflowName=SimpleApproval"); + instancesResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var instancesResult = await instancesResponse.Content.ReadFromJsonAsync(JsonOptions); + + // Assert + instancesResult.Should().NotBeNull(); + instancesResult!.Instances.Should().Contain(i => i.WorkflowInstanceId == instanceId); + + var instance = instancesResult.Instances.First(i => i.WorkflowInstanceId == instanceId); + instance.WorkflowName.Should().Be("SimpleApproval"); + instance.Status.Should().Be("Open"); + } + + [Test] + public async Task StartWorkflow_WithUnknownName_ShouldReturnError() + { + // Arrange + var startPayload = new StartWorkflowRequest + { + WorkflowName = "NonExistentWorkflow", + Payload = new Dictionary(), + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/workflow/start", startPayload, JsonOptions); + + // Assert: the engine should fail with a definition-not-found error + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, + "starting a non-existent workflow should return 400 Bad Request"); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/InMemoryWorkflowProjectionStore.cs b/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/InMemoryWorkflowProjectionStore.cs new file mode 100644 index 000000000..7d7b5562c --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/InMemoryWorkflowProjectionStore.cs @@ -0,0 +1,574 @@ +using System.Collections.Concurrent; +using System.Text.Json; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; +using StellaOps.Workflow.Engine.Services; + +namespace StellaOps.Workflow.WebService.Tests.Fixtures; + +/// +/// A fully in-memory implementation of for E2E testing. +/// Stores workflow instances, tasks, and task events in concurrent dictionaries. +/// +internal sealed class InMemoryWorkflowProjectionStore( + WorkflowRoleResolutionService workflowRoleResolutionService) : IWorkflowProjectionStore +{ + private readonly ConcurrentDictionary instances = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary tasks = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentBag taskEvents = []; + + public Task CreateWorkflowAsync( + WorkflowDefinitionDescriptor definition, + WorkflowBusinessReference? businessReference, + WorkflowStartExecutionPlan executionPlan, + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var workflowRoles = workflowRoleResolutionService.NormalizeRoles(definition.WorkflowRoles); + var workflowInstanceId = $"wf-{Guid.NewGuid():N}"; + + instances[workflowInstanceId] = new WorkflowInstanceRecord + { + WorkflowInstanceId = workflowInstanceId, + WorkflowName = definition.WorkflowName, + WorkflowVersion = definition.WorkflowVersion, + BusinessReference = businessReference, + Status = executionPlan.InstanceStatus, + RuntimeProvider = "InProcess", + RuntimeInstanceId = workflowInstanceId, + RuntimeStatus = executionPlan.InstanceStatus, + CreatedOnUtc = now, + WorkflowState = executionPlan.WorkflowState.ToDictionary( + x => x.Key, + x => (object?)x.Value.Clone(), + StringComparer.OrdinalIgnoreCase), + }; + + foreach (var taskPlan in executionPlan.Tasks) + { + var taskId = $"wt-{Guid.NewGuid():N}"; + var taskRoles = workflowRoleResolutionService.NormalizeRoles(taskPlan.TaskRoles); + var runtimeRoles = workflowRoleResolutionService.NormalizeRoles(taskPlan.RuntimeRoles); + var effectiveRoles = workflowRoleResolutionService.ResolveEffectiveRoles(workflowRoles, taskRoles, runtimeRoles); + + tasks[taskId] = new WorkflowTaskRecord + { + WorkflowTaskId = taskId, + WorkflowInstanceId = workflowInstanceId, + WorkflowName = definition.WorkflowName, + WorkflowVersion = definition.WorkflowVersion, + TaskName = taskPlan.TaskName, + TaskType = taskPlan.TaskType, + Route = taskPlan.Route, + BusinessReference = businessReference, + Status = "Pending", + WorkflowRoles = workflowRoles, + TaskRoles = taskRoles, + RuntimeRoles = runtimeRoles, + EffectiveRoles = effectiveRoles, + Payload = taskPlan.Payload.ToDictionary( + x => x.Key, + x => (object?)x.Value.Clone(), + StringComparer.OrdinalIgnoreCase), + CreatedOnUtc = now, + TimeoutSeconds = taskPlan.TimeoutSeconds, + }; + + taskEvents.Add(new WorkflowTaskEventRecord + { + WorkflowTaskId = taskId, + TaskName = taskPlan.TaskName, + EventType = "Created", + CreatedOnUtc = now, + }); + } + + return Task.FromResult(new StartWorkflowResponse + { + WorkflowInstanceId = workflowInstanceId, + WorkflowName = definition.WorkflowName, + WorkflowVersion = definition.WorkflowVersion, + BusinessReference = businessReference, + }); + } + + public Task> GetTasksAsync( + WorkflowTasksGetRequest request, + CancellationToken cancellationToken = default) + { + var query = tasks.Values.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(request.WorkflowInstanceId)) + { + query = query.Where(x => string.Equals(x.WorkflowInstanceId, request.WorkflowInstanceId, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(request.WorkflowName)) + { + query = query.Where(x => string.Equals(x.WorkflowName, request.WorkflowName, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(request.Status)) + { + query = query.Where(x => string.Equals(x.Status, request.Status, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(request.Assignee)) + { + query = query.Where(x => string.Equals(x.Assignee, request.Assignee, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(request.BusinessReferenceKey)) + { + query = query.Where(x => + x.BusinessReference is not null + && string.Equals(x.BusinessReference.Key, request.BusinessReferenceKey, StringComparison.OrdinalIgnoreCase)); + } + + var results = query.Select(ToTaskSummary).ToArray(); + return Task.FromResult>(results); + } + + public Task GetTaskAsync( + string workflowTaskId, + CancellationToken cancellationToken = default) + { + tasks.TryGetValue(workflowTaskId, out var record); + return Task.FromResult(record is null ? null : ToTaskSummary(record)); + } + + public Task GetExecutionSnapshotAsync( + string workflowTaskId, + CancellationToken cancellationToken = default) + { + if (!tasks.TryGetValue(workflowTaskId, out var taskRecord)) + { + return Task.FromResult(null); + } + + if (!instances.TryGetValue(taskRecord.WorkflowInstanceId, out var instanceRecord)) + { + return Task.FromResult(null); + } + + var state = instanceRecord.WorkflowState.ToDictionary( + x => x.Key, + x => x.Value is JsonElement je ? je.Clone() : JsonSerializer.SerializeToElement(x.Value), + StringComparer.OrdinalIgnoreCase); + + return Task.FromResult(new WorkflowExecutionSnapshot + { + Task = ToTaskSummary(taskRecord), + WorkflowState = state, + }); + } + + public Task AssignTaskAsync( + string workflowTaskId, + string actorId, + string assignee, + CancellationToken cancellationToken = default) + { + if (!tasks.TryGetValue(workflowTaskId, out var record)) + { + throw new InvalidOperationException($"Task '{workflowTaskId}' not found."); + } + + record.Assignee = assignee; + record.Status = "Assigned"; + + taskEvents.Add(new WorkflowTaskEventRecord + { + WorkflowTaskId = workflowTaskId, + TaskName = record.TaskName, + EventType = "Assigned", + ActorId = actorId, + CreatedOnUtc = DateTime.UtcNow, + }); + + return Task.FromResult(ToTaskSummary(record)); + } + + public Task AssignTaskRolesAsync( + string workflowTaskId, + string actorId, + IReadOnlyCollection targetRoles, + CancellationToken cancellationToken = default) + { + if (!tasks.TryGetValue(workflowTaskId, out var record)) + { + throw new InvalidOperationException($"Task '{workflowTaskId}' not found."); + } + + record.RuntimeRoles = targetRoles.ToArray(); + record.EffectiveRoles = workflowRoleResolutionService.ResolveEffectiveRoles( + record.WorkflowRoles, record.TaskRoles, record.RuntimeRoles); + + return Task.FromResult(ToTaskSummary(record)); + } + + public Task ReleaseTaskAsync( + string workflowTaskId, + string actorId, + CancellationToken cancellationToken = default) + { + if (!tasks.TryGetValue(workflowTaskId, out var record)) + { + throw new InvalidOperationException($"Task '{workflowTaskId}' not found."); + } + + record.Assignee = null; + record.Status = "Pending"; + + taskEvents.Add(new WorkflowTaskEventRecord + { + WorkflowTaskId = workflowTaskId, + TaskName = record.TaskName, + EventType = "Released", + ActorId = actorId, + CreatedOnUtc = DateTime.UtcNow, + }); + + return Task.FromResult(ToTaskSummary(record)); + } + + public Task ApplyTaskCompletionAsync( + string workflowTaskId, + string actorId, + IDictionary payload, + WorkflowTaskCompletionPlan completionPlan, + WorkflowBusinessReference? businessReference, + CancellationToken cancellationToken = default) + { + if (!tasks.TryGetValue(workflowTaskId, out var taskRecord)) + { + throw new InvalidOperationException($"Task '{workflowTaskId}' not found."); + } + + if (!instances.TryGetValue(taskRecord.WorkflowInstanceId, out var instanceRecord)) + { + throw new InvalidOperationException($"Instance '{taskRecord.WorkflowInstanceId}' not found."); + } + + var now = DateTime.UtcNow; + + // Mark task completed + taskRecord.Status = "Completed"; + taskRecord.CompletedOnUtc = now; + + taskEvents.Add(new WorkflowTaskEventRecord + { + WorkflowTaskId = workflowTaskId, + TaskName = taskRecord.TaskName, + EventType = "Completed", + ActorId = actorId, + Payload = payload, + CreatedOnUtc = now, + }); + + // Update instance state + foreach (var kvp in completionPlan.WorkflowState) + { + instanceRecord.WorkflowState[kvp.Key] = kvp.Value.Clone(); + } + + // Update instance status + instanceRecord.Status = completionPlan.InstanceStatus; + instanceRecord.RuntimeStatus = completionPlan.InstanceStatus; + if (string.Equals(completionPlan.InstanceStatus, "Completed", StringComparison.OrdinalIgnoreCase)) + { + instanceRecord.CompletedOnUtc = now; + } + + // Resolve business reference update + if (businessReference is not null || completionPlan.BusinessReference is not null) + { + instanceRecord.BusinessReference = completionPlan.BusinessReference ?? businessReference; + } + + // Create next tasks + var workflowRoles = workflowRoleResolutionService.NormalizeRoles( + instances.TryGetValue(taskRecord.WorkflowInstanceId, out var inst) + ? inst.WorkflowRoles ?? [] + : []); + + foreach (var nextTaskPlan in completionPlan.NextTasks) + { + var nextTaskId = $"wt-{Guid.NewGuid():N}"; + var taskRoles = workflowRoleResolutionService.NormalizeRoles(nextTaskPlan.TaskRoles); + var runtimeRoles = workflowRoleResolutionService.NormalizeRoles(nextTaskPlan.RuntimeRoles); + var effectiveRoles = workflowRoleResolutionService.ResolveEffectiveRoles(workflowRoles, taskRoles, runtimeRoles); + + tasks[nextTaskId] = new WorkflowTaskRecord + { + WorkflowTaskId = nextTaskId, + WorkflowInstanceId = taskRecord.WorkflowInstanceId, + WorkflowName = taskRecord.WorkflowName, + WorkflowVersion = taskRecord.WorkflowVersion, + TaskName = nextTaskPlan.TaskName, + TaskType = nextTaskPlan.TaskType, + Route = nextTaskPlan.Route, + BusinessReference = instanceRecord.BusinessReference, + Status = "Pending", + WorkflowRoles = workflowRoles, + TaskRoles = taskRoles, + RuntimeRoles = runtimeRoles, + EffectiveRoles = effectiveRoles, + Payload = nextTaskPlan.Payload.ToDictionary( + x => x.Key, + x => (object?)x.Value.Clone(), + StringComparer.OrdinalIgnoreCase), + CreatedOnUtc = now, + TimeoutSeconds = nextTaskPlan.TimeoutSeconds, + }; + + taskEvents.Add(new WorkflowTaskEventRecord + { + WorkflowTaskId = nextTaskId, + TaskName = nextTaskPlan.TaskName, + EventType = "Created", + CreatedOnUtc = now, + }); + } + + return Task.FromResult(ToTaskSummary(taskRecord)); + } + + public Task ApplyRuntimeProgressAsync( + string workflowInstanceId, + WorkflowTaskCompletionPlan progressPlan, + WorkflowBusinessReference? businessReference, + CancellationToken cancellationToken = default) + { + if (!instances.TryGetValue(workflowInstanceId, out var instanceRecord)) + { + throw new InvalidOperationException($"Instance '{workflowInstanceId}' not found."); + } + + foreach (var kvp in progressPlan.WorkflowState) + { + instanceRecord.WorkflowState[kvp.Key] = kvp.Value.Clone(); + } + + instanceRecord.Status = progressPlan.InstanceStatus; + instanceRecord.RuntimeStatus = progressPlan.InstanceStatus; + + if (string.Equals(progressPlan.InstanceStatus, "Completed", StringComparison.OrdinalIgnoreCase)) + { + instanceRecord.CompletedOnUtc = DateTime.UtcNow; + } + + return Task.CompletedTask; + } + + public Task> GetInstancesAsync( + WorkflowInstancesGetRequest request, + CancellationToken cancellationToken = default) + { + var query = instances.Values.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(request.WorkflowName)) + { + query = query.Where(x => string.Equals(x.WorkflowName, request.WorkflowName, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(request.WorkflowInstanceId)) + { + query = query.Where(x => string.Equals(x.WorkflowInstanceId, request.WorkflowInstanceId, StringComparison.OrdinalIgnoreCase)); + } + + if (request.WorkflowInstanceIds.Count > 0) + { + var ids = new HashSet(request.WorkflowInstanceIds, StringComparer.OrdinalIgnoreCase); + query = query.Where(x => ids.Contains(x.WorkflowInstanceId)); + } + + if (!string.IsNullOrWhiteSpace(request.Status)) + { + query = query.Where(x => string.Equals(x.Status, request.Status, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(request.BusinessReferenceKey)) + { + query = query.Where(x => + x.BusinessReference is not null + && string.Equals(x.BusinessReference.Key, request.BusinessReferenceKey, StringComparison.OrdinalIgnoreCase)); + } + + var results = query.Select(x => ToInstanceSummary(x, request.IncludeDetails)).ToArray(); + return Task.FromResult>(results); + } + + public Task GetInstanceAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default) + { + instances.TryGetValue(workflowInstanceId, out var record); + return Task.FromResult(record is null ? null : ToInstanceSummary(record, includeDetails: false)); + } + + public Task GetInstanceDetailsAsync( + string workflowInstanceId, + CancellationToken cancellationToken = default) + { + if (!instances.TryGetValue(workflowInstanceId, out var instanceRecord)) + { + return Task.FromResult(null); + } + + var instanceTasks = tasks.Values + .Where(x => string.Equals(x.WorkflowInstanceId, workflowInstanceId, StringComparison.OrdinalIgnoreCase)) + .Select(ToTaskSummary) + .ToArray(); + + var instanceTaskEvents = taskEvents + .Where(x => instanceTasks.Any(t => string.Equals(t.WorkflowTaskId, x.WorkflowTaskId, StringComparison.OrdinalIgnoreCase))) + .Select(ToTaskEventSummary) + .ToArray(); + + return Task.FromResult(new WorkflowInstanceProjectionDetails + { + Instance = ToInstanceSummary(instanceRecord, includeDetails: false), + WorkflowState = new Dictionary(instanceRecord.WorkflowState, StringComparer.OrdinalIgnoreCase), + Tasks = instanceTasks, + TaskEvents = instanceTaskEvents, + }); + } + + private WorkflowInstanceSummary ToInstanceSummary(WorkflowInstanceRecord record, bool includeDetails) + { + WorkflowTaskSummary? activeTask = null; + IDictionary workflowState = new Dictionary(); + + if (includeDetails) + { + activeTask = tasks.Values + .Where(x => string.Equals(x.WorkflowInstanceId, record.WorkflowInstanceId, StringComparison.OrdinalIgnoreCase) + && !string.Equals(x.Status, "Completed", StringComparison.OrdinalIgnoreCase)) + .Select(ToTaskSummary) + .FirstOrDefault(); + + workflowState = new Dictionary(record.WorkflowState, StringComparer.OrdinalIgnoreCase); + } + + return new WorkflowInstanceSummary + { + WorkflowInstanceId = record.WorkflowInstanceId, + WorkflowName = record.WorkflowName, + WorkflowVersion = record.WorkflowVersion, + BusinessReference = record.BusinessReference, + Status = record.Status, + RuntimeProvider = record.RuntimeProvider, + RuntimeInstanceId = record.RuntimeInstanceId, + RuntimeStatus = record.RuntimeStatus, + CreatedOnUtc = record.CreatedOnUtc, + CompletedOnUtc = record.CompletedOnUtc, + ActiveTask = activeTask, + WorkflowState = workflowState, + }; + } + + private static WorkflowTaskSummary ToTaskSummary(WorkflowTaskRecord record) + { + return new WorkflowTaskSummary + { + WorkflowTaskId = record.WorkflowTaskId, + WorkflowInstanceId = record.WorkflowInstanceId, + WorkflowName = record.WorkflowName, + WorkflowVersion = record.WorkflowVersion, + TaskName = record.TaskName, + TaskType = record.TaskType, + Route = record.Route, + BusinessReference = record.BusinessReference, + Assignee = record.Assignee, + Status = record.Status, + WorkflowRoles = record.WorkflowRoles, + TaskRoles = record.TaskRoles, + RuntimeRoles = record.RuntimeRoles, + EffectiveRoles = record.EffectiveRoles, + AllowedActions = ComputeAllowedActions(record), + Payload = new Dictionary(record.Payload, StringComparer.OrdinalIgnoreCase), + CreatedOnUtc = record.CreatedOnUtc, + CompletedOnUtc = record.CompletedOnUtc, + DeadlineUtc = record.TimeoutSeconds.HasValue + ? record.CreatedOnUtc.AddSeconds(record.TimeoutSeconds.Value) + : null, + }; + } + + private static WorkflowTaskEventSummary ToTaskEventSummary(WorkflowTaskEventRecord record) + { + return new WorkflowTaskEventSummary + { + WorkflowTaskId = record.WorkflowTaskId, + TaskName = record.TaskName, + EventType = record.EventType, + ActorId = record.ActorId, + Payload = record.Payload ?? new Dictionary(), + CreatedOnUtc = record.CreatedOnUtc, + }; + } + + private static IReadOnlyCollection ComputeAllowedActions(WorkflowTaskRecord record) + { + if (string.Equals(record.Status, "Completed", StringComparison.OrdinalIgnoreCase)) + { + return []; + } + + return ["AssignSelf", "Complete"]; + } + + // ──────────────────────────────────────────────────── + // Internal mutable records + // ──────────────────────────────────────────────────── + + private sealed class WorkflowInstanceRecord + { + public required string WorkflowInstanceId { get; init; } + public required string WorkflowName { get; init; } + public required string WorkflowVersion { get; init; } + public WorkflowBusinessReference? BusinessReference { get; set; } + public string Status { get; set; } = "Open"; + public string? RuntimeProvider { get; init; } + public string? RuntimeInstanceId { get; init; } + public string? RuntimeStatus { get; set; } + public DateTime CreatedOnUtc { get; init; } = DateTime.UtcNow; + public DateTime? CompletedOnUtc { get; set; } + public IReadOnlyCollection WorkflowRoles { get; init; } = []; + public Dictionary WorkflowState { get; init; } = new(StringComparer.OrdinalIgnoreCase); + } + + private sealed class WorkflowTaskRecord + { + public required string WorkflowTaskId { get; init; } + public required string WorkflowInstanceId { get; init; } + public required string WorkflowName { get; init; } + public required string WorkflowVersion { get; init; } + public required string TaskName { get; init; } + public required string TaskType { get; init; } + public required string Route { get; init; } + public WorkflowBusinessReference? BusinessReference { get; init; } + public string? Assignee { get; set; } + public string Status { get; set; } = "Pending"; + public IReadOnlyCollection WorkflowRoles { get; init; } = []; + public IReadOnlyCollection TaskRoles { get; init; } = []; + public IReadOnlyCollection RuntimeRoles { get; set; } = []; + public IReadOnlyCollection EffectiveRoles { get; set; } = []; + public Dictionary Payload { get; init; } = new(StringComparer.OrdinalIgnoreCase); + public DateTime CreatedOnUtc { get; init; } = DateTime.UtcNow; + public DateTime? CompletedOnUtc { get; set; } + public int? TimeoutSeconds { get; init; } + } + + private sealed class WorkflowTaskEventRecord + { + public required string WorkflowTaskId { get; init; } + public string? TaskName { get; init; } + public required string EventType { get; init; } + public string? ActorId { get; init; } + public IDictionary? Payload { get; init; } + public DateTime CreatedOnUtc { get; init; } = DateTime.UtcNow; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/NoopWorkflowMutationCoordinator.cs b/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/NoopWorkflowMutationCoordinator.cs new file mode 100644 index 000000000..e3d6edb15 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/NoopWorkflowMutationCoordinator.cs @@ -0,0 +1,38 @@ +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.WebService.Tests.Fixtures; + +/// +/// A no-op mutation coordinator for E2E testing. Commit is a no-op since +/// the in-memory projection store does not use transactions. +/// +internal sealed class NoopWorkflowMutationCoordinator : IWorkflowMutationCoordinator +{ + public Task BeginAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(new NoopMutationScope()); + } + + private sealed class NoopMutationScope : IWorkflowMutationScope + { + private readonly List> postCommitActions = []; + + public void RegisterPostCommitAction(Func 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; + } + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/NoopWorkflowProjectionRetentionStore.cs b/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/NoopWorkflowProjectionRetentionStore.cs new file mode 100644 index 000000000..c7f3e89dc --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/NoopWorkflowProjectionRetentionStore.cs @@ -0,0 +1,16 @@ +using StellaOps.Workflow.Abstractions; + +namespace StellaOps.Workflow.WebService.Tests.Fixtures; + +/// +/// A no-op retention store for E2E testing. Retention is not exercised during tests. +/// +internal sealed class NoopWorkflowProjectionRetentionStore : IWorkflowProjectionRetentionStore +{ + public Task RunAsync( + DateTime nowUtc, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new WorkflowProjectionRetentionBatch()); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/SimpleApprovalWorkflow.cs b/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/SimpleApprovalWorkflow.cs new file mode 100644 index 000000000..99484d47d --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/SimpleApprovalWorkflow.cs @@ -0,0 +1,48 @@ +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +namespace StellaOps.Workflow.WebService.Tests.Fixtures; + +/// +/// A minimal workflow for E2E testing: +/// Start -> Initialize state -> Human task "Review" -> On complete: set decision -> Complete. +/// No external service calls (no transports needed). +/// +public sealed class SimpleApprovalWorkflow : IDeclarativeWorkflow +{ + public string WorkflowName => "SimpleApproval"; + + public string WorkflowVersion => "1.0.0"; + + public string DisplayName => "Simple Approval"; + + public IReadOnlyCollection WorkflowRoles { get; } = ["APPROVER"]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .InitializeState( + WorkflowExpr.Obj( + WorkflowExpr.Prop("requestId", WorkflowExpr.Path("start.requestId")), + WorkflowExpr.Prop("description", WorkflowExpr.Path("start.description")), + WorkflowExpr.Prop("decision", WorkflowExpr.Null()))) + .AddTask( + WorkflowHumanTask.For( + "Review", + "ReviewTask", + "default") + .WithPayload( + WorkflowExpr.Obj( + WorkflowExpr.Prop("description", WorkflowExpr.Path("state.description")))) + .OnComplete(flow => flow + .Set("decision", WorkflowExpr.Path("payload.decision")) + .Complete())) + .StartWith(flow => flow.ActivateTask("Review")) + .Build(); + + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; +} + +public sealed class SimpleApprovalRequest +{ + public string? RequestId { get; set; } + public string? Description { get; set; } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/WorkflowWebApplicationFactory.cs b/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/WorkflowWebApplicationFactory.cs new file mode 100644 index 000000000..52466630b --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/Fixtures/WorkflowWebApplicationFactory.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; + +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Engine.Scheduling; +using StellaOps.Workflow.Engine.Services; +using StellaOps.Workflow.Engine.Signaling; + +namespace StellaOps.Workflow.WebService.Tests.Fixtures; + +/// +/// Custom that configures the workflow +/// web service for E2E testing without external infrastructure (MongoDB, Redis, etc.). +/// All stores are replaced with in-memory or null implementations. +/// +public class WorkflowWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureAppConfiguration((_, config) => + { + // Override backend/signal-driver config so MongoDB and Redis registrations + // are skipped (their AddWorkflow* extensions short-circuit when the provider + // does not match). The engine core services already register in-memory and + // null defaults for every store interface, so no real infrastructure is needed. + config.AddInMemoryCollection(new Dictionary + { + ["WorkflowBackend:Provider"] = "InMemory", + ["WorkflowSignalDriver:Provider"] = "None", + ["WorkflowRetention:OpenStaleAfterDays"] = "30", + ["WorkflowRetention:CompletedPurgeAfterDays"] = "180", + }); + }); + + builder.ConfigureServices(services => + { + // Register the SimpleApprovalWorkflow so the engine knows about it + services.AddWorkflowRegistration(); + + // Program.cs calls AddWorkflowMongoDataStore and AddWorkflowRedisSignaling + // which use Replace() to override the engine's TryAdd defaults. The config + // overrides above run AFTER Program.cs, so the Mongo/Redis provider checks + // see the original config and DO register their implementations. We must + // explicitly replace ALL infrastructure-dependent services with in-memory or + // null implementations so no real MongoDB or Redis connections are attempted. + + // Runtime state store (replaces MongoWorkflowRuntimeStateStore) + services.RemoveAll(); + services.AddSingleton(); + + // Hosted job lock service (replaces MongoWorkflowHostedJobLockService) + services.RemoveAll(); + services.AddSingleton(); + + // Projection store (no engine default; replaces MongoWorkflowProjectionStore). + // Registered as singleton so workflow data persists across HTTP request scopes. + var projectionStore = new InMemoryWorkflowProjectionStore(new WorkflowRoleResolutionService()); + services.RemoveAll(); + services.AddSingleton(projectionStore); + + // Mutation coordinator (no engine default; replaces MongoWorkflowMutationCoordinator) + services.RemoveAll(); + services.AddScoped(); + + // Retention store (replaces MongoWorkflowProjectionRetentionStore) + services.RemoveAll(); + services.AddScoped(); + + // Signal stores (replaces Mongo signal/claim/scheduler/dead-letter stores) + services.RemoveAll(); + services.AddScoped(); + + services.RemoveAll(); + services.AddScoped(); + + services.RemoveAll(); + services.AddScoped(); + + services.RemoveAll(); + services.AddScoped(); + + // Signal driver and wake outbox (replaces Redis signal driver) + services.RemoveAll(); + services.AddScoped(); + + services.RemoveAll(); + services.AddScoped(); + + // Remove hosted services that poll signals or run retention in the background; + // they are not needed for E2E request/response testing and would fail without + // real infrastructure. + services.RemoveAll(); + }); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/StellaOps.Workflow.WebService.Tests.csproj b/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/StellaOps.Workflow.WebService.Tests.csproj new file mode 100644 index 000000000..366a1f27f --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.WebService.Tests/StellaOps.Workflow.WebService.Tests.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + false + enable + enable + false + true + false + + CS8601;CS8602;CS8604;NU1015 + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/src/Workflow/docs/decompiled-samples/csharp/ApproveApplication.cs b/src/Workflow/docs/decompiled-samples/csharp/ApproveApplication.cs new file mode 100644 index 000000000..34204b155 --- /dev/null +++ b/src/Workflow/docs/decompiled-samples/csharp/ApproveApplication.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +/// Start request model for the workflow. +public sealed class ApproveApplicationRequest +{ + public long SrPolicyId { get; set; } + public long SrAnnexId { get; set; } + public long SrCustId { get; set; } + public object[]? InitialTaskRoles { get; set; } +} + +public sealed class ApproveApplicationWorkflow : IDeclarativeWorkflow +{ + public string WorkflowName => "ApproveApplication"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Approve Application"; + public IReadOnlyCollection WorkflowRoles => ["DBA", "UR_UNDERWRITER", "APR_APPL", "UR_OPERATIONS", "UR_EXCLUSIVE_AGENT", "UR_AGENT", "UR_ORG_ADMIN", "UR_HEALTH"]; + public WorkflowSpec Spec { get; } = WorkflowSpec.For( + ) + .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( + "Approve Application", "ApproveQTApproveApplication", "default") + .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)).WhenExpression( + "Rejected?", WorkflowExpr.Eq( + WorkflowExpr.Path("payload.answer"), WorkflowExpr.String("reject")), + 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( + "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")))))), + resultKey: "operations").Set( + "reopenTask", WorkflowExpr.Not(WorkflowExpr.Path("result.operations.passed"))).WhenExpression( + "Operations Passed?", WorkflowExpr.Eq( + WorkflowExpr.Path("state.reopenTask"), WorkflowExpr.Bool(false)), + 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( + "Load Policy Product Info", new LegacyRabbitAddress("pas_get_policy_product_info" + ), + WorkflowExpr.Obj( + WorkflowExpr.Prop("SrPolicyId", WorkflowExpr.Path("state.srPolicyId"))), + resultKey: "productInfo").Complete(), whenElse => whenElse.ActivateTask( + "Approve Application", WorkflowExpr.Func( + "coalesce", WorkflowExpr.Path("result.operations.errorsBypassRoles"), + WorkflowExpr.Array())))))) + .StartWith( + flow => flow.SetBusinessReference( + new WorkflowBusinessReferenceDeclaration() { KeyExpression = WorkflowExpr.Path("start.srPolicyId" + ), + Parts = new WorkflowNamedExpressionDefinition[] { new WorkflowNamedExpressionDefinition() { Name = "policyId", + Expression = WorkflowExpr.Path("start.srPolicyId") }, new WorkflowNamedExpressionDefinition( + ) { Name = "annexId", + Expression = WorkflowExpr.Path("start.srAnnexId") }, new WorkflowNamedExpressionDefinition( + ) { Name = "customerId", + Expression = WorkflowExpr.Path("start.srCustId") } } }) + .ActivateTask( + "Approve Application", WorkflowExpr.Path("state.initialTaskRoles"))) + .Build(); + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; +} diff --git a/src/Workflow/docs/decompiled-samples/csharp/AssistantPolicyReinstate.cs b/src/Workflow/docs/decompiled-samples/csharp/AssistantPolicyReinstate.cs new file mode 100644 index 000000000..0b7173acf --- /dev/null +++ b/src/Workflow/docs/decompiled-samples/csharp/AssistantPolicyReinstate.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +/// Start request model for the workflow. +public sealed class AssistantPolicyReinstateRequest +{ + public long SrPolicyId { get; set; } +} + +public sealed class AssistantPolicyReinstateWorkflow : IDeclarativeWorkflow +{ + public string WorkflowName => "AssistantPolicyReinstate"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Assistant Policy Reinstate"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public WorkflowSpec Spec { get; } = WorkflowSpec.For( + ) + .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("Retry", "RetryReinstate", + "default") + .WithRoles("DBA") + .WithPayload( + WorkflowExpr.Obj( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId")))) + .OnComplete(flow => flow.Complete())) + .StartWith( + flow => flow.SetBusinessReference( + new WorkflowBusinessReferenceDeclaration() { KeyExpression = WorkflowExpr.Path("start.srPolicyId" + ), + Parts = new WorkflowNamedExpressionDefinition[] { new WorkflowNamedExpressionDefinition() { Name = "policyId", + Expression = WorkflowExpr.Path("start.srPolicyId") } } }) + .Call( + "Policy Reinstate INSIS", new LegacyRabbitAddress("pas_policy_reinstate_insis"), + WorkflowExpr.Obj( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId")))) + .Set("existsOnIpal", WorkflowExpr.Bool(true)) + .WhenExpression( + "Exists on IPAL?", WorkflowExpr.Eq( + WorkflowExpr.Path("state.existsOnIpal"), WorkflowExpr.Bool(true)), + whenTrue => whenTrue.Call( + "Transfer Annex", new LegacyRabbitAddress("pas_transfer_annex"), WorkflowExpr.Obj( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId")))).Set("transferApproved", + WorkflowExpr.Bool(true)).WhenExpression( + "Transfer approved?", WorkflowExpr.Eq( + WorkflowExpr.Path("state.transferApproved"), WorkflowExpr.Bool(true)), + whenTrue => whenTrue.Complete(), whenElse => whenElse.ActivateTask("Retry")), + whenElse => whenElse.Complete())) + .Build(); + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; +} diff --git a/src/Workflow/docs/decompiled-samples/csharp/AssistantPrintInsisDocuments.cs b/src/Workflow/docs/decompiled-samples/csharp/AssistantPrintInsisDocuments.cs new file mode 100644 index 000000000..6c0794462 --- /dev/null +++ b/src/Workflow/docs/decompiled-samples/csharp/AssistantPrintInsisDocuments.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +/// Start request model for the workflow. +public sealed class AssistantPrintInsisDocumentsRequest +{ + public long SrPolicyId { get; set; } +} + +public sealed class AssistantPrintInsisDocumentsWorkflow : IDeclarativeWorkflow +{ + public string WorkflowName => "AssistantPrintInsisDocuments"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Assistant Print INSIS Documents"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public WorkflowSpec Spec { get; } = WorkflowSpec.For( + ) + .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("After Print Review", + "AfterPrintReview", "default") + .WithRoles("DBA") + .WithPayload( + WorkflowExpr.Obj( + WorkflowExpr.Prop("phase", WorkflowExpr.Path("state.phase")))) + .OnComplete(flow => flow.Complete())) + .StartWith( + flow => flow.SetBusinessReference( + new WorkflowBusinessReferenceDeclaration() { KeyExpression = WorkflowExpr.Path("start.srPolicyId" + ), + Parts = new WorkflowNamedExpressionDefinition[] { new WorkflowNamedExpressionDefinition() { Name = "policyId", + Expression = WorkflowExpr.Path("start.srPolicyId") } } }) + .Set("phase", WorkflowExpr.String("fork-started")) + .Fork( + "Spin off async process", branch1 => branch1.ActivateTask("After Print Review"), + branch2 => branch2.Wait("Wait 5m", WorkflowExpr.String("00:05:00")).Set("phase", + WorkflowExpr.String("timer-done"))) + .Complete()) + .Build(); + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; +} diff --git a/src/Workflow/docs/decompiled-samples/csharp/UpdateSrPolicyIdSrcAndCopyCovers.cs b/src/Workflow/docs/decompiled-samples/csharp/UpdateSrPolicyIdSrcAndCopyCovers.cs new file mode 100644 index 000000000..c951ecd65 --- /dev/null +++ b/src/Workflow/docs/decompiled-samples/csharp/UpdateSrPolicyIdSrcAndCopyCovers.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +/// Start request model for the workflow. +public sealed class UpdateSrPolicyIdSrcAndCopyCoversRequest +{ + public long SrPolicyId { get; set; } +} + +public sealed class UpdateSrPolicyIdSrcAndCopyCoversWorkflow : IDeclarativeWorkflow +{ + public string WorkflowName => "UpdateSrPolicyIdSrcAndCopyCovers"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Update Policy ID Source and Copy Covers"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public WorkflowSpec Spec { get; } = WorkflowSpec.For( + ) + .InitializeState( + WorkflowExpr.Obj( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")), + WorkflowExpr.Prop("shouldContinue", WorkflowExpr.Bool(true)))) + .StartWith( + flow => flow.SetBusinessReference( + new WorkflowBusinessReferenceDeclaration() { KeyExpression = WorkflowExpr.Path("start.srPolicyId" + ), + Parts = new WorkflowNamedExpressionDefinition[] { new WorkflowNamedExpressionDefinition() { Name = "policyId", + Expression = WorkflowExpr.Path("start.srPolicyId") } } }) + .Call( + "Update Policy Source", new LegacyRabbitAddress("pas_update_policy_source"), + WorkflowExpr.Obj( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId")))) + .WhenExpression( + "Continue or end process", WorkflowExpr.Eq( + WorkflowExpr.Path("state.shouldContinue"), WorkflowExpr.Bool(true)), + 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 Tasks => Spec.TaskDescriptors; +} diff --git a/src/Workflow/docs/decompiled-samples/csharp/UserDataCheckConsistency.cs b/src/Workflow/docs/decompiled-samples/csharp/UserDataCheckConsistency.cs new file mode 100644 index 000000000..270f717e8 --- /dev/null +++ b/src/Workflow/docs/decompiled-samples/csharp/UserDataCheckConsistency.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.Contracts; + +/// Start request model for the workflow. +public sealed class UserDataCheckConsistencyRequest +{ + public long SrPolicyId { get; set; } +} + +public sealed class UserDataCheckConsistencyWorkflow : IDeclarativeWorkflow +{ + public string WorkflowName => "UserDataCheckConsistency"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "User Data Check Consistency"; + public IReadOnlyCollection WorkflowRoles => ["DBA"]; + public WorkflowSpec Spec { get; } = WorkflowSpec.For( + ) + .InitializeState( + WorkflowExpr.Obj( + WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")), + WorkflowExpr.Prop("isConsistent", WorkflowExpr.Bool(false)))) + .StartWith( + flow => flow.SetBusinessReference( + new WorkflowBusinessReferenceDeclaration() { KeyExpression = WorkflowExpr.Path("start.srPolicyId" + ), + Parts = new WorkflowNamedExpressionDefinition[] { new WorkflowNamedExpressionDefinition() { Name = "policyId", + Expression = WorkflowExpr.Path("start.srPolicyId") } } }) + .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 Tasks => Spec.TaskDescriptors; +} diff --git a/src/Workflow/docs/decompiled-samples/json/ApproveApplication.json b/src/Workflow/docs/decompiled-samples/json/ApproveApplication.json new file mode 100644 index 000000000..ac28e6878 --- /dev/null +++ b/src/Workflow/docs/decompiled-samples/json/ApproveApplication.json @@ -0,0 +1,538 @@ +{ + "schemaVersion": "stellaops.workflow.definition/v1", + "workflowName": "ApproveApplication", + "workflowVersion": "1.0.0", + "displayName": "Approve Application", + "startRequest": { + "contractName": "StellaOps.Workflow.Engine.Tests.TestApproveApplicationStartRequest", + "schema": { + "type": "object", + "properties": { + "srPolicyId": { + "type": "number" + }, + "srAnnexId": { + "type": "number" + }, + "srCustId": { + "type": "number" + }, + "initialTaskRoles": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "allowAdditionalProperties": true + }, + "workflowRoles": [ + "DBA", + "UR_UNDERWRITER", + "APR_APPL", + "UR_OPERATIONS", + "UR_EXCLUSIVE_AGENT", + "UR_AGENT", + "UR_ORG_ADMIN", + "UR_HEALTH" + ], + "businessReference": { + "keyExpression": { + "$type": "path", + "path": "start.srPolicyId" + }, + "parts": [ + { + "name": "policyId", + "expression": { + "$type": "path", + "path": "start.srPolicyId" + } + }, + { + "name": "annexId", + "expression": { + "$type": "path", + "path": "start.srAnnexId" + } + }, + { + "name": "customerId", + "expression": { + "$type": "path", + "path": "start.srCustId" + } + } + ] + }, + "start": { + "initializeStateExpression": { + "$type": "object", + "properties": [ + { + "name": "srPolicyId", + "expression": { + "$type": "path", + "path": "start.srPolicyId" + } + }, + { + "name": "srAnnexId", + "expression": { + "$type": "path", + "path": "start.srAnnexId" + } + }, + { + "name": "srCustId", + "expression": { + "$type": "path", + "path": "start.srCustId" + } + }, + { + "name": "initialTaskRoles", + "expression": { + "$type": "function", + "functionName": "coalesce", + "arguments": [ + { + "$type": "path", + "path": "start.initialTaskRoles" + }, + { + "$type": "array", + "items": [] + } + ] + } + }, + { + "name": "lineOfBusiness", + "expression": { + "$type": "null" + } + }, + { + "name": "showRequireGroupError", + "expression": { + "$type": "boolean", + "value": true + } + }, + { + "name": "policySubstatus", + "expression": { + "$type": "string", + "value": "REG" + } + }, + { + "name": "isRejected", + "expression": { + "$type": "boolean", + "value": false + } + }, + { + "name": "reopenTask", + "expression": { + "$type": "boolean", + "value": false + } + }, + { + "name": "requiresBatch", + "expression": { + "$type": "boolean", + "value": false + } + } + ] + }, + "initialSequence": { + "steps": [ + { + "$type": "activate-task", + "taskName": "Approve Application", + "runtimeRolesExpression": { + "$type": "path", + "path": "state.initialTaskRoles" + } + } + ] + } + }, + "tasks": [ + { + "taskName": "Approve Application", + "taskType": "ApproveQTApproveApplication", + "routeExpression": { + "$type": "string", + "value": "business/policies" + }, + "payloadExpression": { + "$type": "object", + "properties": [ + { + "name": "srPolicyId", + "expression": { + "$type": "path", + "path": "state.srPolicyId" + } + }, + { + "name": "srAnnexId", + "expression": { + "$type": "path", + "path": "state.srAnnexId" + } + } + ] + }, + "taskRoles": [], + "onComplete": { + "steps": [ + { + "$type": "set-state", + "stateKey": "answer", + "valueExpression": { + "$type": "path", + "path": "payload.answer" + }, + "onlyIfPresent": false + }, + { + "$type": "set-state", + "stateKey": "isRejected", + "valueExpression": { + "$type": "boolean", + "value": false + }, + "onlyIfPresent": false + }, + { + "$type": "set-state", + "stateKey": "reopenTask", + "valueExpression": { + "$type": "boolean", + "value": false + }, + "onlyIfPresent": false + }, + { + "$type": "set-state", + "stateKey": "requiresBatch", + "valueExpression": { + "$type": "boolean", + "value": false + }, + "onlyIfPresent": false + }, + { + "$type": "decision", + "decisionName": "Rejected?", + "conditionExpression": { + "$type": "binary", + "operator": "eq", + "left": { + "$type": "path", + "path": "payload.answer" + }, + "right": { + "$type": "string", + "value": "reject" + } + }, + "whenTrue": { + "steps": [ + { + "$type": "set-state", + "stateKey": "isRejected", + "valueExpression": { + "$type": "boolean", + "value": true + }, + "onlyIfPresent": false + }, + { + "$type": "call-transport", + "stepName": "Cancel Application", + "invocation": { + "address": { + "$type": "legacy-rabbit", + "command": "pas_annexprocessing_cancelaplorqt", + "mode": 1 + }, + "payloadExpression": { + "$type": "object", + "properties": [ + { + "name": "srPolicyId", + "expression": { + "$type": "path", + "path": "state.srPolicyId" + } + } + ] + } + } + }, + { + "$type": "complete" + } + ] + }, + "whenElse": { + "steps": [ + { + "$type": "call-transport", + "stepName": "Perform Operations", + "invocation": { + "address": { + "$type": "legacy-rabbit", + "command": "pas_operations_perform", + "mode": 2 + }, + "payloadExpression": { + "$type": "object", + "properties": [ + { + "name": "srPolicyId", + "expression": { + "$type": "path", + "path": "state.srPolicyId" + } + }, + { + "name": "runConditions", + "expression": { + "$type": "object", + "properties": [ + { + "name": "lineOfBusiness", + "expression": { + "$type": "path", + "path": "state.lineOfBusiness" + } + }, + { + "name": "operationType", + "expression": { + "$type": "string", + "value": "POLICY_ISSUING" + } + }, + { + "name": "stages", + "expression": { + "$type": "array", + "items": [ + { + "$type": "string", + "value": "UNDERWRITING" + }, + { + "$type": "string", + "value": "CONFIRMATION" + }, + { + "$type": "string", + "value": "POST_PROCESSING" + } + ] + } + } + ] + } + } + ] + } + }, + "resultKey": "operations" + }, + { + "$type": "set-state", + "stateKey": "reopenTask", + "valueExpression": { + "$type": "unary", + "operator": "not", + "operand": { + "$type": "path", + "path": "result.operations.passed" + } + }, + "onlyIfPresent": false + }, + { + "$type": "decision", + "decisionName": "Operations Passed?", + "conditionExpression": { + "$type": "binary", + "operator": "eq", + "left": { + "$type": "path", + "path": "state.reopenTask" + }, + "right": { + "$type": "boolean", + "value": false + } + }, + "whenTrue": { + "steps": [ + { + "$type": "set-state", + "stateKey": "policySubstatus", + "valueExpression": { + "$type": "string", + "value": "INT_TRNSF_PEND" + }, + "onlyIfPresent": false + }, + { + "$type": "call-transport", + "stepName": "Convert Application To Policy", + "invocation": { + "address": { + "$type": "legacy-rabbit", + "command": "pas_polreg_convertapltopol", + "mode": 1 + }, + "payloadExpression": { + "$type": "object", + "properties": [ + { + "name": "SrPolicyId", + "expression": { + "$type": "path", + "path": "state.srPolicyId" + } + }, + { + "name": "Substatus", + "expression": { + "$type": "string", + "value": "INT_TRNSF_PEND" + } + }, + { + "name": "ErrorIfAlreadyTheSame", + "expression": { + "$type": "boolean", + "value": false + } + }, + { + "name": "Backdate", + "expression": { + "$type": "number", + "value": "100" + } + } + ] + } + } + }, + { + "$type": "call-transport", + "stepName": "Generate Policy Number", + "invocation": { + "address": { + "$type": "legacy-rabbit", + "command": "pas_annexprocessing_generatepolicyno", + "mode": 1 + }, + "payloadExpression": { + "$type": "object", + "properties": [ + { + "name": "SrPolicyId", + "expression": { + "$type": "path", + "path": "state.srPolicyId" + } + } + ] + } + } + }, + { + "$type": "call-transport", + "stepName": "Load Policy Product Info", + "invocation": { + "address": { + "$type": "legacy-rabbit", + "command": "pas_get_policy_product_info", + "mode": 1 + }, + "payloadExpression": { + "$type": "object", + "properties": [ + { + "name": "SrPolicyId", + "expression": { + "$type": "path", + "path": "state.srPolicyId" + } + } + ] + } + }, + "resultKey": "productInfo" + }, + { + "$type": "complete" + } + ] + }, + "whenElse": { + "steps": [ + { + "$type": "activate-task", + "taskName": "Approve Application", + "runtimeRolesExpression": { + "$type": "function", + "functionName": "coalesce", + "arguments": [ + { + "$type": "path", + "path": "result.operations.errorsBypassRoles" + }, + { + "$type": "array", + "items": [] + } + ] + } + } + ] + } + } + ] + } + } + ] + } + } + ], + "requiredModules": [ + { + "moduleName": "transport.legacy-rabbit", + "versionExpression": "\u003E=1.0.0", + "optional": false + }, + { + "moduleName": "workflow.dsl.core", + "versionExpression": "\u003E=1.0.0", + "optional": false + }, + { + "moduleName": "workflow.functions.core", + "versionExpression": "\u003E=1.0.0", + "optional": false + } + ], + "requiredCapabilities": [] +} \ No newline at end of file diff --git a/src/Workflow/docs/decompiled-samples/json/AssistantPolicyReinstate.json b/src/Workflow/docs/decompiled-samples/json/AssistantPolicyReinstate.json new file mode 100644 index 000000000..f8a1f538e --- /dev/null +++ b/src/Workflow/docs/decompiled-samples/json/AssistantPolicyReinstate.json @@ -0,0 +1,235 @@ +{ + "schemaVersion": "stellaops.workflow.definition/v1", + "workflowName": "AssistantPolicyReinstate", + "workflowVersion": "1.0.0", + "displayName": "Assistant Policy Reinstate", + "startRequest": { + "contractName": "StellaOps.Workflow.Engine.Tests.TestAssistantPolicyReinstateStartRequest", + "schema": { + "type": "object", + "properties": { + "srPolicyId": { + "type": "number" + } + } + }, + "allowAdditionalProperties": true + }, + "workflowRoles": [ + "DBA" + ], + "businessReference": { + "keyExpression": { + "$type": "path", + "path": "start.srPolicyId" + }, + "parts": [ + { + "name": "policyId", + "expression": { + "$type": "path", + "path": "start.srPolicyId" + } + } + ] + }, + "start": { + "initializeStateExpression": { + "$type": "object", + "properties": [ + { + "name": "srPolicyId", + "expression": { + "$type": "path", + "path": "start.srPolicyId" + } + }, + { + "name": "existsOnIpal", + "expression": { + "$type": "boolean", + "value": false + } + }, + { + "name": "transferApproved", + "expression": { + "$type": "boolean", + "value": false + } + } + ] + }, + "initialSequence": { + "steps": [ + { + "$type": "call-transport", + "stepName": "Policy Reinstate INSIS", + "invocation": { + "address": { + "$type": "legacy-rabbit", + "command": "pas_policy_reinstate_insis", + "mode": 1 + }, + "payloadExpression": { + "$type": "object", + "properties": [ + { + "name": "srPolicyId", + "expression": { + "$type": "path", + "path": "state.srPolicyId" + } + } + ] + } + } + }, + { + "$type": "set-state", + "stateKey": "existsOnIpal", + "valueExpression": { + "$type": "boolean", + "value": true + }, + "onlyIfPresent": false + }, + { + "$type": "decision", + "decisionName": "Exists on IPAL?", + "conditionExpression": { + "$type": "binary", + "operator": "eq", + "left": { + "$type": "path", + "path": "state.existsOnIpal" + }, + "right": { + "$type": "boolean", + "value": true + } + }, + "whenTrue": { + "steps": [ + { + "$type": "call-transport", + "stepName": "Transfer Annex", + "invocation": { + "address": { + "$type": "legacy-rabbit", + "command": "pas_transfer_annex", + "mode": 1 + }, + "payloadExpression": { + "$type": "object", + "properties": [ + { + "name": "srPolicyId", + "expression": { + "$type": "path", + "path": "state.srPolicyId" + } + } + ] + } + } + }, + { + "$type": "set-state", + "stateKey": "transferApproved", + "valueExpression": { + "$type": "boolean", + "value": true + }, + "onlyIfPresent": false + }, + { + "$type": "decision", + "decisionName": "Transfer approved?", + "conditionExpression": { + "$type": "binary", + "operator": "eq", + "left": { + "$type": "path", + "path": "state.transferApproved" + }, + "right": { + "$type": "boolean", + "value": true + } + }, + "whenTrue": { + "steps": [ + { + "$type": "complete" + } + ] + }, + "whenElse": { + "steps": [ + { + "$type": "activate-task", + "taskName": "Retry" + } + ] + } + } + ] + }, + "whenElse": { + "steps": [ + { + "$type": "complete" + } + ] + } + } + ] + } + }, + "tasks": [ + { + "taskName": "Retry", + "taskType": "RetryReinstate", + "routeExpression": { + "$type": "string", + "value": "business/policies" + }, + "payloadExpression": { + "$type": "object", + "properties": [ + { + "name": "srPolicyId", + "expression": { + "$type": "path", + "path": "state.srPolicyId" + } + } + ] + }, + "taskRoles": [ + "DBA" + ], + "onComplete": { + "steps": [ + { + "$type": "complete" + } + ] + } + } + ], + "requiredModules": [ + { + "moduleName": "transport.legacy-rabbit", + "versionExpression": "\u003E=1.0.0", + "optional": false + }, + { + "moduleName": "workflow.dsl.core", + "versionExpression": "\u003E=1.0.0", + "optional": false + } + ], + "requiredCapabilities": [] +} \ No newline at end of file diff --git a/src/Workflow/docs/decompiled-samples/json/AssistantPrintInsisDocuments.json b/src/Workflow/docs/decompiled-samples/json/AssistantPrintInsisDocuments.json new file mode 100644 index 000000000..241a3b19d --- /dev/null +++ b/src/Workflow/docs/decompiled-samples/json/AssistantPrintInsisDocuments.json @@ -0,0 +1,162 @@ +{ + "schemaVersion": "stellaops.workflow.definition/v1", + "workflowName": "AssistantPrintInsisDocuments", + "workflowVersion": "1.0.0", + "displayName": "Assistant Print INSIS Documents", + "startRequest": { + "contractName": "StellaOps.Workflow.Engine.Tests.TestAssistantPrintInsisDocumentsStartRequest", + "schema": { + "type": "object", + "properties": { + "srPolicyId": { + "type": "number" + } + } + }, + "allowAdditionalProperties": true + }, + "workflowRoles": [ + "DBA" + ], + "businessReference": { + "keyExpression": { + "$type": "path", + "path": "start.srPolicyId" + }, + "parts": [ + { + "name": "policyId", + "expression": { + "$type": "path", + "path": "start.srPolicyId" + } + } + ] + }, + "start": { + "initializeStateExpression": { + "$type": "object", + "properties": [ + { + "name": "srPolicyId", + "expression": { + "$type": "path", + "path": "start.srPolicyId" + } + }, + { + "name": "phase", + "expression": { + "$type": "string", + "value": "starting" + } + }, + { + "name": "printAttempt", + "expression": { + "$type": "number", + "value": "0" + } + }, + { + "name": "lastPrintAttempt", + "expression": { + "$type": "number", + "value": "0" + } + } + ] + }, + "initialSequence": { + "steps": [ + { + "$type": "set-state", + "stateKey": "phase", + "valueExpression": { + "$type": "string", + "value": "fork-started" + }, + "onlyIfPresent": false + }, + { + "$type": "fork", + "stepName": "Spin off async process", + "branches": [ + { + "steps": [ + { + "$type": "activate-task", + "taskName": "After Print Review" + } + ] + }, + { + "steps": [ + { + "$type": "timer", + "stepName": "Wait 5m", + "delayExpression": { + "$type": "string", + "value": "00:05:00" + } + }, + { + "$type": "set-state", + "stateKey": "phase", + "valueExpression": { + "$type": "string", + "value": "timer-done" + }, + "onlyIfPresent": false + } + ] + } + ] + }, + { + "$type": "complete" + } + ] + } + }, + "tasks": [ + { + "taskName": "After Print Review", + "taskType": "AfterPrintReview", + "routeExpression": { + "$type": "string", + "value": "business/policies" + }, + "payloadExpression": { + "$type": "object", + "properties": [ + { + "name": "phase", + "expression": { + "$type": "path", + "path": "state.phase" + } + } + ] + }, + "taskRoles": [ + "DBA" + ], + "onComplete": { + "steps": [ + { + "$type": "complete" + } + ] + } + } + ], + "requiredModules": [ + { + "moduleName": "workflow.dsl.core", + "versionExpression": "\u003E=1.0.0", + "optional": false + } + ], + "requiredCapabilities": [] +} \ No newline at end of file diff --git a/src/Workflow/docs/decompiled-samples/json/UpdateSrPolicyIdSrcAndCopyCovers.json b/src/Workflow/docs/decompiled-samples/json/UpdateSrPolicyIdSrcAndCopyCovers.json new file mode 100644 index 000000000..d70812296 --- /dev/null +++ b/src/Workflow/docs/decompiled-samples/json/UpdateSrPolicyIdSrcAndCopyCovers.json @@ -0,0 +1,151 @@ +{ + "schemaVersion": "stellaops.workflow.definition/v1", + "workflowName": "UpdateSrPolicyIdSrcAndCopyCovers", + "workflowVersion": "1.0.0", + "displayName": "Update Policy ID Source and Copy Covers", + "startRequest": { + "contractName": "StellaOps.Workflow.Engine.Tests.TestUpdateSrPolicyIdSrcAndCopyCoversStartRequest", + "schema": { + "type": "object", + "properties": { + "srPolicyId": { + "type": "number" + } + } + }, + "allowAdditionalProperties": true + }, + "workflowRoles": [ + "DBA" + ], + "businessReference": { + "keyExpression": { + "$type": "path", + "path": "start.srPolicyId" + }, + "parts": [ + { + "name": "policyId", + "expression": { + "$type": "path", + "path": "start.srPolicyId" + } + } + ] + }, + "start": { + "initializeStateExpression": { + "$type": "object", + "properties": [ + { + "name": "srPolicyId", + "expression": { + "$type": "path", + "path": "start.srPolicyId" + } + }, + { + "name": "shouldContinue", + "expression": { + "$type": "boolean", + "value": true + } + } + ] + }, + "initialSequence": { + "steps": [ + { + "$type": "call-transport", + "stepName": "Update Policy Source", + "invocation": { + "address": { + "$type": "legacy-rabbit", + "command": "pas_update_policy_source", + "mode": 1 + }, + "payloadExpression": { + "$type": "object", + "properties": [ + { + "name": "srPolicyId", + "expression": { + "$type": "path", + "path": "state.srPolicyId" + } + } + ] + } + } + }, + { + "$type": "decision", + "decisionName": "Continue or end process", + "conditionExpression": { + "$type": "binary", + "operator": "eq", + "left": { + "$type": "path", + "path": "state.shouldContinue" + }, + "right": { + "$type": "boolean", + "value": true + } + }, + "whenTrue": { + "steps": [ + { + "$type": "call-transport", + "stepName": "Copy Covers", + "invocation": { + "address": { + "$type": "legacy-rabbit", + "command": "pas_copy_covers", + "mode": 1 + }, + "payloadExpression": { + "$type": "object", + "properties": [ + { + "name": "srPolicyId", + "expression": { + "$type": "path", + "path": "state.srPolicyId" + } + } + ] + } + } + }, + { + "$type": "complete" + } + ] + }, + "whenElse": { + "steps": [ + { + "$type": "complete" + } + ] + } + } + ] + } + }, + "tasks": [], + "requiredModules": [ + { + "moduleName": "transport.legacy-rabbit", + "versionExpression": "\u003E=1.0.0", + "optional": false + }, + { + "moduleName": "workflow.dsl.core", + "versionExpression": "\u003E=1.0.0", + "optional": false + } + ], + "requiredCapabilities": [] +} \ No newline at end of file diff --git a/src/Workflow/docs/decompiled-samples/json/UserDataCheckConsistency.json b/src/Workflow/docs/decompiled-samples/json/UserDataCheckConsistency.json new file mode 100644 index 000000000..a8b5fc510 --- /dev/null +++ b/src/Workflow/docs/decompiled-samples/json/UserDataCheckConsistency.json @@ -0,0 +1,110 @@ +{ + "schemaVersion": "stellaops.workflow.definition/v1", + "workflowName": "UserDataCheckConsistency", + "workflowVersion": "1.0.0", + "displayName": "User Data Check Consistency", + "startRequest": { + "contractName": "StellaOps.Workflow.Engine.Tests.TestUserDataCheckConsistencyStartRequest", + "schema": { + "type": "object", + "properties": { + "srPolicyId": { + "type": "number" + } + } + }, + "allowAdditionalProperties": true + }, + "workflowRoles": [ + "DBA" + ], + "businessReference": { + "keyExpression": { + "$type": "path", + "path": "start.srPolicyId" + }, + "parts": [ + { + "name": "policyId", + "expression": { + "$type": "path", + "path": "start.srPolicyId" + } + } + ] + }, + "start": { + "initializeStateExpression": { + "$type": "object", + "properties": [ + { + "name": "srPolicyId", + "expression": { + "$type": "path", + "path": "start.srPolicyId" + } + }, + { + "name": "isConsistent", + "expression": { + "$type": "boolean", + "value": false + } + } + ] + }, + "initialSequence": { + "steps": [ + { + "$type": "call-transport", + "stepName": "Check User Data", + "invocation": { + "address": { + "$type": "legacy-rabbit", + "command": "pas_check_user_data", + "mode": 1 + }, + "payloadExpression": { + "$type": "object", + "properties": [ + { + "name": "srPolicyId", + "expression": { + "$type": "path", + "path": "state.srPolicyId" + } + } + ] + } + } + }, + { + "$type": "set-state", + "stateKey": "isConsistent", + "valueExpression": { + "$type": "boolean", + "value": true + }, + "onlyIfPresent": false + }, + { + "$type": "complete" + } + ] + } + }, + "tasks": [], + "requiredModules": [ + { + "moduleName": "transport.legacy-rabbit", + "versionExpression": "\u003E=1.0.0", + "optional": false + }, + { + "moduleName": "workflow.dsl.core", + "versionExpression": "\u003E=1.0.0", + "optional": false + } + ], + "requiredCapabilities": [] +} \ No newline at end of file diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkModels.cs b/src/__Libraries/StellaOps.ElkSharp/ElkModels.cs new file mode 100644 index 000000000..a530fd707 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkModels.cs @@ -0,0 +1,138 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.ElkSharp; + +public enum ElkLayoutDirection +{ + TopToBottom = 0, + LeftToRight = 1, +} + +public enum ElkLayoutEffort +{ + Draft = 0, + Balanced = 1, + Best = 2, +} + +public sealed record ElkPort +{ + public required string Id { get; init; } + public string? Side { get; init; } + public double Width { get; init; } = 8; + public double Height { get; init; } = 8; +} + +public sealed record ElkNode +{ + public required string Id { get; init; } + public required string Label { get; init; } + public required string Kind { get; init; } + public string? IconKey { get; init; } + public string? SemanticType { get; init; } + public string? SemanticKey { get; init; } + public string? Route { get; init; } + public string? TaskType { get; init; } + public string? ParentNodeId { get; init; } + public double Width { get; init; } = 160; + public double Height { get; init; } = 72; + public IReadOnlyCollection Ports { get; init; } = []; +} + +public sealed record ElkEdge +{ + public required string Id { get; init; } + public required string SourceNodeId { get; init; } + public required string TargetNodeId { get; init; } + public string? SourcePortId { get; init; } + public string? TargetPortId { get; init; } + public string? Kind { get; init; } + public string? Label { get; init; } +} + +public sealed record ElkGraph +{ + public required string Id { get; init; } + public required IReadOnlyCollection Nodes { get; init; } + public required IReadOnlyCollection Edges { get; init; } +} + +public sealed record ElkLayoutOptions +{ + public ElkLayoutDirection Direction { get; init; } = ElkLayoutDirection.LeftToRight; + public double NodeSpacing { get; init; } = 40; + public double LayerSpacing { get; init; } = 60; + public ElkLayoutEffort Effort { get; init; } = ElkLayoutEffort.Best; + public int? OrderingIterations { get; init; } + public int? PlacementIterations { get; init; } +} + +public sealed record ElkPoint +{ + public required double X { get; init; } + public required double Y { get; init; } +} + +public sealed record ElkPositionedPort +{ + public required string Id { get; init; } + public string? Side { get; init; } + public double X { get; init; } + public double Y { get; init; } + public double Width { get; init; } + public double Height { get; init; } +} + +public sealed record ElkPositionedNode +{ + public required string Id { get; init; } + public required string Label { get; init; } + public required string Kind { get; init; } + public string? IconKey { get; init; } + public string? SemanticType { get; init; } + public string? SemanticKey { get; init; } + public string? Route { get; init; } + public string? TaskType { get; init; } + public string? ParentNodeId { get; init; } + public double X { get; init; } + public double Y { get; init; } + public double Width { get; init; } + public double Height { get; init; } + public IReadOnlyCollection Ports { get; init; } = []; +} + +public sealed record ElkEdgeSection +{ + public required ElkPoint StartPoint { get; init; } + public required ElkPoint EndPoint { get; init; } + public IReadOnlyCollection BendPoints { get; init; } = []; +} + +public sealed record ElkRoutedEdge +{ + public required string Id { get; init; } + public required string SourceNodeId { get; init; } + public required string TargetNodeId { get; init; } + public string? SourcePortId { get; init; } + public string? TargetPortId { get; init; } + public string? Kind { get; init; } + public string? Label { get; init; } + public IReadOnlyCollection Sections { get; init; } = []; +} + +public sealed record ElkLayoutResult +{ + public required string GraphId { get; init; } + public required IReadOnlyCollection Nodes { get; init; } + public required IReadOnlyCollection Edges { get; init; } +} + +public interface IElkLayoutEngine +{ + Task LayoutAsync( + ElkGraph graph, + ElkLayoutOptions? options = null, + CancellationToken cancellationToken = default); +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs new file mode 100644 index 000000000..14c3fa67d --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs @@ -0,0 +1,4616 @@ +namespace StellaOps.ElkSharp; + +public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine +{ + public Task LayoutAsync( + ElkGraph graph, + ElkLayoutOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(graph); + cancellationToken.ThrowIfCancellationRequested(); + + options ??= new ElkLayoutOptions(); + ValidateGraph(graph); + + var nodesById = graph.Nodes.ToDictionary(x => x.Id, StringComparer.Ordinal); + var (inputOrder, backEdgeIds) = BuildTraversalInputOrder(graph.Nodes, graph.Edges, nodesById); + + var outgoing = graph.Nodes.ToDictionary(x => x.Id, _ => new List(), StringComparer.Ordinal); + var incomingNodeIds = graph.Nodes.ToDictionary(x => x.Id, _ => new List(), StringComparer.Ordinal); + var outgoingNodeIds = graph.Nodes.ToDictionary(x => x.Id, _ => new List(), StringComparer.Ordinal); + + foreach (var edge in graph.Edges) + { + outgoing[edge.SourceNodeId].Add(edge); + incomingNodeIds[edge.TargetNodeId].Add(edge.SourceNodeId); + outgoingNodeIds[edge.SourceNodeId].Add(edge.TargetNodeId); + } + + var layersByNodeId = AssignLayersByInputOrder(graph.Nodes, outgoing, inputOrder, backEdgeIds); + + var dummyResult = InsertDummyNodes(graph.Nodes, graph.Edges, layersByNodeId, inputOrder, backEdgeIds); + var allNodes = dummyResult.AllNodes; + var allEdges = dummyResult.AllEdges; + var augmentedNodesById = allNodes.ToDictionary(x => x.Id, StringComparer.Ordinal); + var augmentedInputOrder = dummyResult.AugmentedInputOrder; + var augmentedIncoming = allNodes.ToDictionary(x => x.Id, _ => new List(), StringComparer.Ordinal); + var augmentedOutgoing = allNodes.ToDictionary(x => x.Id, _ => new List(), StringComparer.Ordinal); + foreach (var edge in allEdges) + { + augmentedIncoming[edge.TargetNodeId].Add(edge.SourceNodeId); + augmentedOutgoing[edge.SourceNodeId].Add(edge.TargetNodeId); + } + + var orderingIterations = ResolveOrderingIterationCount(options, allEdges.Count, layersByNodeId.Count); + var layers = allNodes + .GroupBy(x => dummyResult.AugmentedLayers[x.Id]) + .OrderBy(x => x.Key) + .Select(x => x.OrderBy(node => augmentedInputOrder[node.Id]).ToArray()) + .ToArray(); + layers = OptimizeLayerOrdering(layers, augmentedIncoming, augmentedOutgoing, augmentedInputOrder, orderingIterations); + for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) + { + var realNodes = layers[layerIndex].Where(n => !dummyResult.DummyNodeIds.Contains(n.Id)).ToArray(); + var dummyNodes = layers[layerIndex].Where(n => dummyResult.DummyNodeIds.Contains(n.Id)).ToArray(); + layers[layerIndex] = realNodes.Concat(dummyNodes).ToArray(); + } + + var placementIterations = ResolvePlacementIterationCount(options, allNodes.Count, layers.Length); + + var positionedNodes = new Dictionary(StringComparer.Ordinal); + var globalNodeHeight = graph.Nodes.Max(x => x.Height); + var globalNodeWidth = graph.Nodes.Max(x => x.Width); + var edgeDensityFactor = Math.Min(1.8d, 1d + (Math.Max(0, allEdges.Count - 15) * 0.02d)); + var adaptiveNodeSpacing = options.NodeSpacing * edgeDensityFactor; + var adaptiveLayerSpacing = options.LayerSpacing * Math.Min(1.15d, 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d)); + if (options.Direction == ElkLayoutDirection.LeftToRight) + { + var layerXPositions = new double[layers.Length]; + var currentX = 0d; + for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) + { + layerXPositions[layerIndex] = currentX; + currentX += layers[layerIndex].Max(x => x.Width) + adaptiveLayerSpacing; + } + + var slotHeight = globalNodeHeight; + for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) + { + var layer = layers[layerIndex]; + var desiredY = new double[layer.Length]; + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + var centers = new List(); + foreach (var srcId in augmentedIncoming[node.Id]) + { + if (positionedNodes.TryGetValue(srcId, out var srcPos)) + { + centers.Add(srcPos.Y + (srcPos.Height / 2d)); + } + } + + if (centers.Count > 0) + { + centers.Sort(); + var mid = centers.Count / 2; + var median = centers.Count % 2 == 1 + ? centers[mid] + : (centers[mid - 1] + centers[mid]) / 2d; + desiredY[nodeIndex] = median - (node.Height / 2d); + } + else + { + desiredY[nodeIndex] = nodeIndex * (slotHeight + adaptiveNodeSpacing); + } + } + + for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) + { + var prevIsDummy = dummyResult.DummyNodeIds.Contains(layer[nodeIndex - 1].Id); + var currIsDummy = dummyResult.DummyNodeIds.Contains(layer[nodeIndex].Id); + var pairSpacing = (prevIsDummy && currIsDummy) ? 2d + : (prevIsDummy || currIsDummy) ? Math.Min(adaptiveNodeSpacing, options.NodeSpacing * 0.5d) + : adaptiveNodeSpacing; + var minY = desiredY[nodeIndex - 1] + layer[nodeIndex - 1].Height + pairSpacing; + if (desiredY[nodeIndex] < minY) + { + desiredY[nodeIndex] = minY; + } + } + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + positionedNodes[layer[nodeIndex].Id] = CreatePositionedNode( + layer[nodeIndex], layerXPositions[layerIndex], desiredY[nodeIndex], options.Direction); + } + } + + var minNodeY = positionedNodes.Values.Min(n => n.Y); + if (minNodeY < -0.01d) + { + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + var pos = positionedNodes[nodeId]; + positionedNodes[nodeId] = CreatePositionedNode( + augmentedNodesById[nodeId], pos.X, pos.Y - minNodeY, options.Direction); + } + } + + RefineHorizontalPlacement( + positionedNodes, + layers, + incomingNodeIds, + outgoingNodeIds, + augmentedNodesById, + options.NodeSpacing, + placementIterations, + options.Direction); + + SnapOriginalPrimaryAxes( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + incomingNodeIds, + outgoingNodeIds, + nodesById, + options.NodeSpacing, + options.Direction); + + CompactTowardIncomingFlow( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + incomingNodeIds, + nodesById, + options.NodeSpacing, + options.Direction); + + SnapOriginalPrimaryAxes( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + incomingNodeIds, + outgoingNodeIds, + nodesById, + options.NodeSpacing, + options.Direction); + + AlignDummyNodesToFlow( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + augmentedIncoming, + augmentedOutgoing, + augmentedNodesById, + options.Direction); + + CenterMultiIncomingNodes( + positionedNodes, + incomingNodeIds, + nodesById, + options.Direction); + + PropagateSuccessorPositionBackward( + positionedNodes, + outgoingNodeIds, + nodesById, + options.Direction); + + minNodeY = positionedNodes.Values.Min(n => n.Y); + if (minNodeY < -0.01d) + { + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + var pos = positionedNodes[nodeId]; + positionedNodes[nodeId] = CreatePositionedNode( + augmentedNodesById[nodeId], pos.X, pos.Y - minNodeY, options.Direction); + } + } + } + else + { + var layerYPositions = new double[layers.Length]; + var currentY = 0d; + for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) + { + layerYPositions[layerIndex] = currentY; + currentY += layers[layerIndex].Max(x => x.Height) + options.LayerSpacing; + } + + var slotWidth = globalNodeWidth; + for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) + { + var layer = layers[layerIndex]; + var desiredX = new double[layer.Length]; + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + var centers = new List(); + foreach (var srcId in augmentedIncoming[node.Id]) + { + if (positionedNodes.TryGetValue(srcId, out var srcPos)) + { + centers.Add(srcPos.X + (srcPos.Width / 2d)); + } + } + + if (centers.Count > 0) + { + centers.Sort(); + var mid = centers.Count / 2; + var median = centers.Count % 2 == 1 + ? centers[mid] + : (centers[mid - 1] + centers[mid]) / 2d; + desiredX[nodeIndex] = median - (node.Width / 2d); + } + else + { + desiredX[nodeIndex] = nodeIndex * (slotWidth + adaptiveNodeSpacing); + } + } + + for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) + { + var prevIsDummyX = dummyResult.DummyNodeIds.Contains(layer[nodeIndex - 1].Id); + var currIsDummyX = dummyResult.DummyNodeIds.Contains(layer[nodeIndex].Id); + var pairSpacingX = (prevIsDummyX && currIsDummyX) ? 2d + : (prevIsDummyX || currIsDummyX) ? Math.Min(adaptiveNodeSpacing, options.NodeSpacing * 0.5d) + : adaptiveNodeSpacing; + var minX = desiredX[nodeIndex - 1] + layer[nodeIndex - 1].Width + pairSpacingX; + if (desiredX[nodeIndex] < minX) + { + desiredX[nodeIndex] = minX; + } + } + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + positionedNodes[layer[nodeIndex].Id] = CreatePositionedNode( + layer[nodeIndex], desiredX[nodeIndex], layerYPositions[layerIndex], options.Direction); + } + } + + var minNodeX = positionedNodes.Values.Min(n => n.X); + if (minNodeX < -0.01d) + { + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + var pos = positionedNodes[nodeId]; + positionedNodes[nodeId] = CreatePositionedNode( + augmentedNodesById[nodeId], pos.X - minNodeX, pos.Y, options.Direction); + } + } + + RefineVerticalPlacement( + positionedNodes, + layers, + incomingNodeIds, + outgoingNodeIds, + augmentedNodesById, + options.NodeSpacing, + placementIterations, + options.Direction); + + SnapOriginalPrimaryAxes( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + incomingNodeIds, + outgoingNodeIds, + nodesById, + options.NodeSpacing, + options.Direction); + + CompactTowardIncomingFlow( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + incomingNodeIds, + nodesById, + options.NodeSpacing, + options.Direction); + + SnapOriginalPrimaryAxes( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + incomingNodeIds, + outgoingNodeIds, + nodesById, + options.NodeSpacing, + options.Direction); + + AlignDummyNodesToFlow( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + augmentedIncoming, + augmentedOutgoing, + augmentedNodesById, + options.Direction); + + CenterMultiIncomingNodes( + positionedNodes, + incomingNodeIds, + nodesById, + options.Direction); + + PropagateSuccessorPositionBackward( + positionedNodes, + outgoingNodeIds, + nodesById, + options.Direction); + + minNodeX = positionedNodes.Values.Min(n => n.X); + if (minNodeX < -0.01d) + { + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + var pos = positionedNodes[nodeId]; + positionedNodes[nodeId] = CreatePositionedNode( + augmentedNodesById[nodeId], pos.X - minNodeX, pos.Y, options.Direction); + } + } + } + + var graphBounds = ComputeGraphBounds(positionedNodes.Values + .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)).ToArray()); + var layerBoundariesByNodeId = BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); + var edgeChannels = ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); + var reconstructedEdges = ReconstructDummyEdges( + graph.Edges, + dummyResult, + positionedNodes, + augmentedNodesById, + options.Direction, + graphBounds, + edgeChannels, + layerBoundariesByNodeId); + var routedEdges = graph.Edges + .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var routed) + ? routed + : RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, + edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) + .ToArray(); + for (var gutterPass = 0; gutterPass < 3; gutterPass++) + { + if (!ExpandVerticalCorridorGutters( + positionedNodes, + routedEdges, + dummyResult.AugmentedLayers, + augmentedNodesById, + options.LayerSpacing, + options.Direction)) + { + break; + } + + graphBounds = ComputeGraphBounds(positionedNodes.Values + .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)).ToArray()); + layerBoundariesByNodeId = BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); + edgeChannels = ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); + reconstructedEdges = ReconstructDummyEdges( + graph.Edges, + dummyResult, + positionedNodes, + augmentedNodesById, + options.Direction, + graphBounds, + edgeChannels, + layerBoundariesByNodeId); + routedEdges = graph.Edges + .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) + ? rerouted + : RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, + edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) + .ToArray(); + } + + for (var compactPass = 0; compactPass < 2; compactPass++) + { + if (!CompactSparseVerticalCorridorGutters( + positionedNodes, + routedEdges, + dummyResult.AugmentedLayers, + augmentedNodesById, + options.LayerSpacing, + options.Direction)) + { + break; + } + + graphBounds = ComputeGraphBounds(positionedNodes.Values + .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)).ToArray()); + layerBoundariesByNodeId = BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); + edgeChannels = ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); + reconstructedEdges = ReconstructDummyEdges( + graph.Edges, + dummyResult, + positionedNodes, + augmentedNodesById, + options.Direction, + graphBounds, + edgeChannels, + layerBoundariesByNodeId); + routedEdges = graph.Edges + .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) + ? rerouted + : RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, + edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) + .ToArray(); + + if (!ExpandVerticalCorridorGutters( + positionedNodes, + routedEdges, + dummyResult.AugmentedLayers, + augmentedNodesById, + options.LayerSpacing, + options.Direction)) + { + continue; + } + + graphBounds = ComputeGraphBounds(positionedNodes.Values + .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)).ToArray()); + layerBoundariesByNodeId = BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); + edgeChannels = ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); + reconstructedEdges = ReconstructDummyEdges( + graph.Edges, + dummyResult, + positionedNodes, + augmentedNodesById, + options.Direction, + graphBounds, + edgeChannels, + layerBoundariesByNodeId); + routedEdges = graph.Edges + .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) + ? rerouted + : RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, + edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) + .ToArray(); + } + + var finalNodes = positionedNodes.Values + .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)) + .OrderBy(x => inputOrder.GetValueOrDefault(x.Id, int.MaxValue)) + .ToArray(); + + routedEdges = AvoidNodeCrossings(routedEdges, finalNodes, options.Direction); + + routedEdges = DistributeOverlappingPorts(routedEdges, finalNodes, options.Direction); + routedEdges = SimplifyEdgePaths(routedEdges, finalNodes); + routedEdges = SnapAnchorsToNodeBoundary(routedEdges, finalNodes); + routedEdges = TightenOuterCorridors(routedEdges, finalNodes); + + return Task.FromResult(new ElkLayoutResult + { + GraphId = graph.Id, + Nodes = finalNodes, + Edges = routedEdges, + }); + } + + private static void ValidateGraph(ElkGraph graph) + { + if (graph.Nodes.Count == 0) + { + throw new InvalidOperationException("ElkSharp requires at least one node."); + } + + var duplicateNodeId = graph.Nodes + .GroupBy(x => x.Id, StringComparer.Ordinal) + .FirstOrDefault(x => x.Count() > 1); + if (duplicateNodeId is not null) + { + throw new InvalidOperationException($"ElkSharp requires unique node ids. Duplicate '{duplicateNodeId.Key}' was found."); + } + + if (graph.Nodes.Any(x => !string.IsNullOrWhiteSpace(x.ParentNodeId))) + { + throw new NotSupportedException("ElkSharp currently supports flat graphs only. Compound nodes are not implemented in this spike."); + } + + var nodeIds = graph.Nodes.Select(x => x.Id).ToHashSet(StringComparer.Ordinal); + foreach (var edge in graph.Edges) + { + if (!nodeIds.Contains(edge.SourceNodeId) || !nodeIds.Contains(edge.TargetNodeId)) + { + throw new InvalidOperationException($"Edge '{edge.Id}' references an unknown node."); + } + } + } + + private static ElkPositionedNode CreatePositionedNode( + ElkNode node, + double x, + double y, + ElkLayoutDirection direction) + { + return new ElkPositionedNode + { + Id = node.Id, + Label = node.Label, + Kind = node.Kind, + IconKey = node.IconKey, + SemanticType = node.SemanticType, + SemanticKey = node.SemanticKey, + Route = node.Route, + TaskType = node.TaskType, + ParentNodeId = node.ParentNodeId, + X = x, + Y = y, + Width = node.Width, + Height = node.Height, + Ports = PositionPorts(node, x, y, direction), + }; + } + + private static IReadOnlyCollection PositionPorts( + ElkNode node, + double nodeX, + double nodeY, + ElkLayoutDirection direction) + { + if (node.Ports.Count == 0) + { + return []; + } + + var portsBySide = node.Ports + .GroupBy(x => NormalizeSide(x.Side, direction), StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.ToArray(), StringComparer.OrdinalIgnoreCase); + + var positionedPorts = new List(node.Ports.Count); + foreach (var sideGroup in portsBySide) + { + var side = sideGroup.Key; + var ports = sideGroup.Value; + for (var index = 0; index < ports.Length; index++) + { + positionedPorts.Add(PositionPort(nodeX, nodeY, node.Width, node.Height, ports[index], side, index, ports.Length)); + } + } + + return positionedPorts; + } + + private static ElkPositionedPort PositionPort( + double nodeX, + double nodeY, + double nodeWidth, + double nodeHeight, + ElkPort port, + string side, + int index, + int count) + { + var slot = (index + 1d) / (count + 1d); + var x = nodeX; + var y = nodeY; + + switch (side) + { + case "EAST": + x = nodeX + nodeWidth - (port.Width / 2d); + y = nodeY + (nodeHeight * slot) - (port.Height / 2d); + break; + case "WEST": + x = nodeX - (port.Width / 2d); + y = nodeY + (nodeHeight * slot) - (port.Height / 2d); + break; + case "NORTH": + x = nodeX + (nodeWidth * slot) - (port.Width / 2d); + y = nodeY - (port.Height / 2d); + break; + default: + x = nodeX + (nodeWidth * slot) - (port.Width / 2d); + y = nodeY + nodeHeight - (port.Height / 2d); + break; + } + + return new ElkPositionedPort + { + Id = port.Id, + Side = side, + X = x, + Y = y, + Width = port.Width, + Height = port.Height, + }; + } + + private static ElkRoutedEdge RouteEdge( + ElkEdge edge, + IReadOnlyDictionary nodesById, + IReadOnlyDictionary positionedNodes, + ElkLayoutDirection direction, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var sourceNode = positionedNodes[edge.SourceNodeId]; + var targetNode = positionedNodes[edge.TargetNodeId]; + + var (sourceSide, targetSide) = ResolveRouteSides(sourceNode, targetNode, direction); + var sourcePoint = ResolveAnchorPoint(sourceNode, targetNode, edge.SourcePortId, direction, sourceSide); + var targetPoint = ResolveAnchorPoint(targetNode, sourceNode, edge.TargetPortId, direction, targetSide); + + if (string.IsNullOrWhiteSpace(edge.SourcePortId) + && string.IsNullOrWhiteSpace(edge.TargetPortId) + && channel.RouteMode == EdgeRouteMode.Direct) + { + (sourcePoint, targetPoint) = ResolveStraightChainAnchors( + sourceNode, + targetNode, + sourcePoint, + targetPoint, + sourceSide, + targetSide, + channel, + direction); + } + + if (string.IsNullOrWhiteSpace(edge.TargetPortId) + && channel.TargetIncomingCount > 1 + && direction == ElkLayoutDirection.LeftToRight + && targetPoint.X >= sourcePoint.X) + { + var insetY = Math.Min(16d, (targetNode.Height - 12d) / Math.Max(1, channel.TargetIncomingCount)); + var totalInset = (channel.TargetIncomingCount - 1) * insetY; + var adjustedY = (targetNode.Y + (targetNode.Height / 2d)) - (totalInset / 2d) + (channel.TargetIncomingIndex * insetY); + adjustedY = Clamp(adjustedY, targetNode.Y + 6d, targetNode.Y + targetNode.Height - 6d); + targetPoint = new ElkPoint { X = targetPoint.X, Y = adjustedY }; + } + + if (string.IsNullOrWhiteSpace(edge.TargetPortId) + && channel.TargetIncomingCount > 1 + && direction == ElkLayoutDirection.TopToBottom + && targetPoint.Y >= sourcePoint.Y) + { + var insetX = Math.Min(16d, (targetNode.Width - 12d) / Math.Max(1, channel.TargetIncomingCount)); + var totalInset = (channel.TargetIncomingCount - 1) * insetX; + var adjustedX = (targetNode.X + (targetNode.Width / 2d)) - (totalInset / 2d) + (channel.TargetIncomingIndex * insetX); + adjustedX = Clamp(adjustedX, targetNode.X + 6d, targetNode.X + targetNode.Width - 6d); + targetPoint = new ElkPoint { X = adjustedX, Y = targetPoint.Y }; + } + + if (string.IsNullOrWhiteSpace(edge.TargetPortId) + && channel.BackwardTargetCount > 1 + && targetPoint.X < sourcePoint.X + && direction == ElkLayoutDirection.LeftToRight) + { + var spread = Math.Min(24d, (targetNode.Width - 16d) / Math.Max(1, channel.BackwardTargetCount)); + var totalSpread = (channel.BackwardTargetCount - 1) * spread; + var adjustedX = (targetNode.X + (targetNode.Width / 2d)) - (totalSpread / 2d) + (channel.BackwardTargetIndex * spread); + adjustedX = Clamp(adjustedX, targetNode.X + 8d, targetNode.X + targetNode.Width - 8d); + targetPoint = new ElkPoint { X = adjustedX, Y = targetNode.Y }; + } + + var bendPoints = direction == ElkLayoutDirection.LeftToRight + ? BuildHorizontalBendPoints(sourceNode, targetNode, sourcePoint, targetPoint, graphBounds, channel, layerBoundariesByNodeId) + : BuildVerticalBendPoints(sourceNode, targetNode, sourcePoint, targetPoint, graphBounds, channel, layerBoundariesByNodeId); + + return new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = + [ + new ElkEdgeSection + { + StartPoint = sourcePoint, + EndPoint = targetPoint, + BendPoints = bendPoints, + }, + ], + }; + } + + private static ElkPoint ResolveAnchorPoint( + ElkPositionedNode node, + ElkPositionedNode otherNode, + string? portId, + ElkLayoutDirection direction, + string? forcedSide = null) + { + if (!string.IsNullOrWhiteSpace(portId)) + { + var port = node.Ports.FirstOrDefault(x => string.Equals(x.Id, portId, StringComparison.Ordinal)); + if (port is not null) + { + return new ElkPoint + { + X = port.X + (port.Width / 2d), + Y = port.Y + (port.Height / 2d), + }; + } + } + + var nodeCenterX = node.X + (node.Width / 2d); + var nodeCenterY = node.Y + (node.Height / 2d); + var otherCenterX = otherNode.X + (otherNode.Width / 2d); + var otherCenterY = otherNode.Y + (otherNode.Height / 2d); + + if (Math.Abs(otherCenterX - nodeCenterX) < 0.001d + && Math.Abs(otherCenterY - nodeCenterY) < 0.001d) + { + return new ElkPoint + { + X = nodeCenterX, + Y = nodeCenterY, + }; + } + + return ResolvePreferredAnchorPoint(node, otherCenterX, otherCenterY, forcedSide, direction); + } + + private static (ElkPoint SourcePoint, ElkPoint TargetPoint) ResolveStraightChainAnchors( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint sourcePoint, + ElkPoint targetPoint, + string sourceSide, + string targetSide, + EdgeChannel channel, + ElkLayoutDirection direction) + { + if (direction == ElkLayoutDirection.LeftToRight) + { + if (channel.ForwardCount != 1 || channel.TargetIncomingCount != 1 || targetPoint.X < sourcePoint.X) + { + return (sourcePoint, targetPoint); + } + + var sharedY = sourceNode.Y + (sourceNode.Height / 2d); + return ( + ResolvePreferredAnchorPoint(sourceNode, targetNode.X, sharedY, sourceSide, direction), + ResolvePreferredAnchorPoint(targetNode, sourceNode.X + sourceNode.Width, sharedY, targetSide, direction)); + } + + if (channel.ForwardCount != 1 || channel.TargetIncomingCount != 1 || targetPoint.Y < sourcePoint.Y) + { + return (sourcePoint, targetPoint); + } + + var sharedX = sourceNode.X + (sourceNode.Width / 2d); + return ( + ResolvePreferredAnchorPoint(sourceNode, sharedX, targetNode.Y, sourceSide, direction), + ResolvePreferredAnchorPoint(targetNode, sharedX, sourceNode.Y + sourceNode.Height, targetSide, direction)); + } + + private static IReadOnlyCollection BuildHorizontalBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + if (channel.RouteMode == EdgeRouteMode.SinkOuter) + { + return BuildHorizontalSinkBendPoints( + sourceNode, + targetNode, + startPoint, + endPoint, + graphBounds, + channel, + layerBoundariesByNodeId); + } + + if (channel.RouteMode == EdgeRouteMode.SinkOuterTop) + { + return BuildHorizontalTopSinkBendPoints( + sourceNode, + targetNode, + startPoint, + endPoint, + graphBounds, + channel, + layerBoundariesByNodeId); + } + + if (Math.Abs(endPoint.Y - startPoint.Y) <= 6d && endPoint.X >= startPoint.X) + { + return []; + } + + if (channel.RouteMode == EdgeRouteMode.BackwardOuter || endPoint.X < startPoint.X) + { + return BuildHorizontalBackwardBendPoints( + sourceNode, + targetNode, + startPoint, + endPoint, + graphBounds, + channel, + layerBoundariesByNodeId); + } + + var baseChannelX = ResolveForwardChannelX(sourceNode, targetNode, startPoint, endPoint, channel); + + var channelX = Clamp(baseChannelX, startPoint.X + 12d, endPoint.X - 12d); + + return NormalizeBendPoints( + new ElkPoint { X = channelX, Y = startPoint.Y }, + new ElkPoint { X = channelX, Y = endPoint.Y }); + } + + private static double ResolveForwardChannelX( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + EdgeChannel channel) + { + if (!double.IsNaN(channel.PreferredDirectChannelX)) + { + return channel.PreferredDirectChannelX; + } + + if (ShouldPreferSourceLocalForwardDrop(sourceNode, targetNode, startPoint, endPoint, channel)) + { + var sourceLocalBase = Math.Max( + startPoint.X + 24d, + sourceNode.X + sourceNode.Width + 36d); + return sourceLocalBase + (channel.ForwardIndex * 36d); + } + + var baseChannelX = (sourceNode.X + sourceNode.Width + targetNode.X) / 2d; + if (channel.ForwardCount > 1) + { + var totalWidth = (channel.ForwardCount - 1) * 16d; + var offset = (channel.ForwardIndex * 16d) - (totalWidth / 2d); + baseChannelX += offset; + } + + return baseChannelX; + } + + private static double ResolveForwardSourceExitX( + ElkPositionedNode sourceNode, + ElkPoint startPoint, + EdgeChannel channel, + double baseOffset, + double spread) + { + var sourceLocalBase = Math.Max( + startPoint.X + 24d, + sourceNode.X + sourceNode.Width + baseOffset); + if (channel.ForwardCount <= 1) + { + return sourceLocalBase; + } + + return sourceLocalBase + (channel.ForwardIndex * spread); + } + + private static bool ShouldPreferSourceLocalForwardDrop( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + EdgeChannel channel) + { + if (channel.RouteMode != EdgeRouteMode.Direct || channel.ForwardCount <= 1) + { + return false; + } + + var verticalSpan = Math.Abs(endPoint.Y - startPoint.Y); + if (verticalSpan < 120d) + { + return false; + } + + var horizontalSpan = endPoint.X - startPoint.X; + if (horizontalSpan < 180d) + { + return false; + } + + return string.Equals(sourceNode.Kind, "Decision", StringComparison.OrdinalIgnoreCase) + || string.Equals(sourceNode.Kind, "Fork", StringComparison.OrdinalIgnoreCase) + || verticalSpan > horizontalSpan * 0.45d; + } + + private static IReadOnlyCollection BuildVerticalBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + if (Math.Abs(endPoint.X - startPoint.X) <= 6d && endPoint.Y >= startPoint.Y) + { + return []; + } + + if (endPoint.Y < startPoint.Y) + { + var lane = Math.Max(0, channel.BackwardLane); + var outerX = graphBounds.MinX - 48d - (lane * 24d); + + if (channel.BackwardTargetCount > 1) + { + var spread = Math.Min(18d, (targetNode.Width - 16d) / Math.Max(1, channel.BackwardTargetCount)); + var totalSpread = (channel.BackwardTargetCount - 1) * spread; + var adjustedEndX = (targetNode.X + (targetNode.Width / 2d)) - (totalSpread / 2d) + (channel.BackwardTargetIndex * spread); + adjustedEndX = Clamp(adjustedEndX, targetNode.X + 8d, targetNode.X + targetNode.Width - 8d); + return NormalizeBendPoints( + new ElkPoint { X = outerX, Y = startPoint.Y }, + new ElkPoint { X = outerX, Y = endPoint.Y }, + new ElkPoint { X = adjustedEndX, Y = endPoint.Y }); + } + + return NormalizeBendPoints( + new ElkPoint { X = outerX, Y = startPoint.Y }, + new ElkPoint { X = outerX, Y = endPoint.Y }); + } + + var baseChannelY = (sourceNode.Y + sourceNode.Height + targetNode.Y) / 2d; + if (channel.ForwardCount > 1) + { + var totalHeight = (channel.ForwardCount - 1) * 16d; + var offset = (channel.ForwardIndex * 16d) - (totalHeight / 2d); + baseChannelY += offset; + } + + var channelY = Clamp(baseChannelY, startPoint.Y + 12d, endPoint.Y - 12d); + + return NormalizeBendPoints( + new ElkPoint { X = startPoint.X, Y = channelY }, + new ElkPoint { X = endPoint.X, Y = channelY }); + } + + private static IReadOnlyCollection BuildHorizontalBackwardBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var lane = Math.Max(0, channel.BackwardLane); + var isLowerCorridor = !double.IsNaN(channel.PreferredOuterY) + && channel.PreferredOuterY > Math.Max(startPoint.Y, endPoint.Y) + 4d; + var outerY = double.IsNaN(channel.PreferredOuterY) + ? graphBounds.MinY - 56d - (lane * 28d) + : isLowerCorridor + ? channel.PreferredOuterY + (lane * 24d) + : channel.PreferredOuterY - (lane * 24d); + var sourceBoundary = ResolveLayerBoundary(sourceNode.Id, layerBoundariesByNodeId, sourceNode); + var sourceExitX = channel.UseSourceCollector + ? startPoint.X + : channel.SharedOuterX > 0d + ? Math.Max(startPoint.X + 18d, channel.SharedOuterX) + : Math.Max(startPoint.X + 18d, sourceBoundary.MaxX + 28d); + var approachX = endPoint.X; + + return NormalizeBendPoints( + new ElkPoint { X = startPoint.X, Y = outerY }, + new ElkPoint { X = sourceExitX, Y = outerY }, + new ElkPoint { X = approachX, Y = outerY }); + } + + private static IReadOnlyCollection BuildHorizontalSinkBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var targetBoundary = ResolveLayerBoundary(targetNode.Id, layerBoundariesByNodeId, targetNode); + var sourceExitX = ResolveForwardSourceExitX(sourceNode, startPoint, channel, 36d, 36d); + var targetApproachX = Math.Max(sourceExitX + 24d, targetBoundary.MinX - 32d); + var outerY = graphBounds.MaxY + 32d + ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex)); + + return NormalizeBendPoints( + new ElkPoint { X = sourceExitX, Y = startPoint.Y }, + new ElkPoint { X = sourceExitX, Y = outerY }, + new ElkPoint { X = targetApproachX, Y = outerY }, + new ElkPoint { X = targetApproachX, Y = endPoint.Y }); + } + + private static IReadOnlyCollection BuildHorizontalTopSinkBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var sourceBoundary = ResolveLayerBoundary(sourceNode.Id, layerBoundariesByNodeId, sourceNode); + var targetBoundary = ResolveLayerBoundary(targetNode.Id, layerBoundariesByNodeId, targetNode); + var sourceExitX = ResolveForwardSourceExitX(sourceNode, startPoint, channel, 40d, 40d); + var targetApproachX = Math.Max(sourceExitX + 24d, targetBoundary.MinX - 32d); + var outerY = double.IsNaN(channel.PreferredOuterY) + ? graphBounds.MinY - 56d - ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex), 36d, 28d) + : channel.PreferredOuterY + ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex), 28d, 24d); + + return NormalizeBendPoints( + new ElkPoint { X = sourceExitX, Y = startPoint.Y }, + new ElkPoint { X = sourceExitX, Y = outerY }, + new ElkPoint { X = targetApproachX, Y = outerY }, + new ElkPoint { X = targetApproachX, Y = endPoint.Y }); + } + + private static ElkPoint ResolvePreferredAnchorPoint( + ElkPositionedNode node, + double targetX, + double targetY, + string? forcedSide, + ElkLayoutDirection direction) + { + var nodeCenterX = node.X + (node.Width / 2d); + var nodeCenterY = node.Y + (node.Height / 2d); + var deltaX = targetX - nodeCenterX; + var deltaY = targetY - nodeCenterY; + var insetX = Math.Min(18d, node.Width / 4d); + var insetY = Math.Min(18d, node.Height / 4d); + + var preferredSide = forcedSide; + if (string.IsNullOrWhiteSpace(preferredSide)) + { + preferredSide = direction == ElkLayoutDirection.LeftToRight + ? (Math.Abs(deltaX) >= Math.Abs(deltaY) * 0.35d + ? (deltaX >= 0d ? "EAST" : "WEST") + : (deltaY >= 0d ? "SOUTH" : "NORTH")) + : (Math.Abs(deltaY) >= Math.Abs(deltaX) * 0.35d + ? (deltaY >= 0d ? "SOUTH" : "NORTH") + : (deltaX >= 0d ? "EAST" : "WEST")); + } + + var preferredTargetX = preferredSide switch + { + "EAST" => node.X + node.Width + 256d, + "WEST" => node.X - 256d, + _ => Clamp(targetX, node.X + insetX, node.X + node.Width - insetX), + }; + var preferredTargetY = preferredSide switch + { + "SOUTH" => node.Y + node.Height + 256d, + "NORTH" => node.Y - 256d, + _ => Clamp(targetY, node.Y + insetY, node.Y + node.Height - insetY), + }; + + var adjustedDeltaX = preferredTargetX - nodeCenterX; + var adjustedDeltaY = preferredTargetY - nodeCenterY; + + var candidate = new ElkPoint + { + X = preferredSide switch + { + "EAST" => node.X + node.Width, + "WEST" => node.X, + _ => Clamp(preferredTargetX, node.X + insetX, node.X + node.Width - insetX), + }, + Y = preferredSide switch + { + "SOUTH" => node.Y + node.Height, + "NORTH" => node.Y, + _ => Clamp(preferredTargetY, node.Y + insetY, node.Y + node.Height - insetY), + }, + }; + + return ResolveGatewayBoundaryPoint(node, candidate, adjustedDeltaX, adjustedDeltaY); + } + + private static Dictionary AssignLayersByInputOrder( + IReadOnlyCollection nodes, + IReadOnlyDictionary> outgoing, + IReadOnlyDictionary inputOrder, + IReadOnlySet backEdgeIds) + { + var layersByNodeId = nodes.ToDictionary(x => x.Id, _ => 0, StringComparer.Ordinal); + var orderedNodes = nodes + .OrderBy(node => inputOrder[node.Id], Comparer.Default) + .ToArray(); + + for (var iteration = 0; iteration < orderedNodes.Length; iteration++) + { + var changed = false; + foreach (var node in orderedNodes) + { + var sourceLayer = layersByNodeId[node.Id]; + foreach (var edge in outgoing[node.Id]) + { + if (backEdgeIds.Contains(edge.Id)) + { + continue; + } + + var candidateLayer = sourceLayer + 1; + if (candidateLayer <= layersByNodeId[edge.TargetNodeId]) + { + continue; + } + + layersByNodeId[edge.TargetNodeId] = candidateLayer; + changed = true; + } + } + + if (!changed) + { + break; + } + } + + for (var nodeIndex = orderedNodes.Length - 1; nodeIndex >= 0; nodeIndex--) + { + var node = orderedNodes[nodeIndex]; + var minSuccessorLayer = int.MaxValue; + foreach (var edge in outgoing[node.Id] + .Where(edge => !backEdgeIds.Contains(edge.Id))) + { + minSuccessorLayer = Math.Min(minSuccessorLayer, layersByNodeId[edge.TargetNodeId]); + } + + if (minSuccessorLayer != int.MaxValue) + { + var idealLayer = minSuccessorLayer - 1; + if (idealLayer > layersByNodeId[node.Id]) + { + layersByNodeId[node.Id] = idealLayer; + } + } + } + + return layersByNodeId; + } + + private static (string SourceSide, string TargetSide) ResolveRouteSides( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkLayoutDirection direction) + { + var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d); + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterX = targetNode.X + (targetNode.Width / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var deltaX = targetCenterX - sourceCenterX; + var deltaY = targetCenterY - sourceCenterY; + + if (direction == ElkLayoutDirection.LeftToRight) + { + if (Math.Abs(deltaX) >= 24d || Math.Abs(deltaX) >= Math.Abs(deltaY) * 0.35d) + { + return deltaX >= 0d + ? ("EAST", "WEST") + : ("NORTH", "NORTH"); + } + + return deltaY >= 0d + ? ("SOUTH", "NORTH") + : ("NORTH", "SOUTH"); + } + + if (Math.Abs(deltaY) >= 24d || Math.Abs(deltaY) >= Math.Abs(deltaX) * 0.35d) + { + return deltaY >= 0d + ? ("SOUTH", "NORTH") + : ("NORTH", "NORTH"); + } + + return deltaX >= 0d + ? ("EAST", "WEST") + : ("WEST", "EAST"); + } + + private static ElkNode[][] OptimizeLayerOrdering( + ElkNode[][] initialLayers, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary inputOrder, + int iterationCount = 8) + { + if (initialLayers.Length <= 2) + { + return initialLayers; + } + + var layers = initialLayers + .Select(layer => layer.ToList()) + .ToArray(); + + for (var iteration = 0; iteration < iterationCount; iteration++) + { + for (var layerIndex = 1; layerIndex < layers.Length; layerIndex++) + { + OrderLayer(layers, layerIndex, incomingNodeIds, inputOrder); + } + + for (var layerIndex = layers.Length - 2; layerIndex >= 0; layerIndex--) + { + OrderLayer(layers, layerIndex, outgoingNodeIds, inputOrder); + } + } + + return layers + .Select(layer => layer.ToArray()) + .ToArray(); + } + + private static int ResolveOrderingIterationCount( + ElkLayoutOptions options, + int edgeCount, + int nodeCount) + { + if (options.OrderingIterations is int explicitIterations) + { + return Math.Max(2, explicitIterations); + } + + var baseline = Math.Max(6, Math.Max(edgeCount / 4, nodeCount / 3)); + return options.Effort switch + { + ElkLayoutEffort.Draft => Math.Min(8, baseline), + ElkLayoutEffort.Balanced => Math.Min(14, Math.Max(8, baseline)), + _ => Math.Min(24, Math.Max(12, baseline + 4)), + }; + } + + private static int ResolvePlacementIterationCount( + ElkLayoutOptions options, + int nodeCount, + int layerCount) + { + if (options.PlacementIterations is int explicitIterations) + { + return Math.Max(1, explicitIterations); + } + + var baseline = Math.Max(2, Math.Max(nodeCount / 8, layerCount / 2)); + return options.Effort switch + { + ElkLayoutEffort.Draft => Math.Min(3, baseline), + ElkLayoutEffort.Balanced => Math.Min(6, Math.Max(3, baseline)), + _ => Math.Min(10, Math.Max(5, baseline + 2)), + }; + } + + private static void RefineHorizontalPlacement( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary nodesById, + double nodeSpacing, + int iterationCount, + ElkLayoutDirection direction) + { + if (iterationCount <= 0) + { + return; + } + + for (var iteration = 0; iteration < iterationCount; iteration++) + { + var layerIndices = iteration % 2 == 0 + ? Enumerable.Range(0, layers.Count) + : Enumerable.Range(0, layers.Count).Reverse(); + + foreach (var layerIndex in layerIndices) + { + var layer = layers[layerIndex]; + if (layer.Length == 0) + { + continue; + } + + var desiredY = new double[layer.Length]; + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + var preferredCenter = ResolvePreferredCenter( + node.Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: true); + desiredY[nodeIndex] = preferredCenter.HasValue + ? preferredCenter.Value - (node.Height / 2d) + : positionedNodes[node.Id].Y; + } + + for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) + { + var minY = desiredY[nodeIndex - 1] + layer[nodeIndex - 1].Height + nodeSpacing; + if (desiredY[nodeIndex] < minY) + { + desiredY[nodeIndex] = minY; + } + } + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var current = positionedNodes[layer[nodeIndex].Id]; + positionedNodes[layer[nodeIndex].Id] = CreatePositionedNode( + nodesById[layer[nodeIndex].Id], + current.X, + desiredY[nodeIndex], + direction); + } + } + } + } + + private static void RefineVerticalPlacement( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary nodesById, + double nodeSpacing, + int iterationCount, + ElkLayoutDirection direction) + { + if (iterationCount <= 0) + { + return; + } + + for (var iteration = 0; iteration < iterationCount; iteration++) + { + var layerIndices = iteration % 2 == 0 + ? Enumerable.Range(0, layers.Count) + : Enumerable.Range(0, layers.Count).Reverse(); + + foreach (var layerIndex in layerIndices) + { + var layer = layers[layerIndex]; + if (layer.Length == 0) + { + continue; + } + + var desiredX = new double[layer.Length]; + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + var preferredCenter = ResolvePreferredCenter( + node.Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: false); + desiredX[nodeIndex] = preferredCenter.HasValue + ? preferredCenter.Value - (node.Width / 2d) + : positionedNodes[node.Id].X; + } + + for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) + { + var minX = desiredX[nodeIndex - 1] + layer[nodeIndex - 1].Width + nodeSpacing; + if (desiredX[nodeIndex] < minX) + { + desiredX[nodeIndex] = minX; + } + } + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var current = positionedNodes[layer[nodeIndex].Id]; + positionedNodes[layer[nodeIndex].Id] = CreatePositionedNode( + nodesById[layer[nodeIndex].Id], + desiredX[nodeIndex], + current.Y, + direction); + } + } + } + } + + private static void SnapOriginalPrimaryAxes( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlySet dummyNodeIds, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary originalNodesById, + double nodeSpacing, + ElkLayoutDirection direction) + { + for (var iteration = 0; iteration < 3; iteration++) + { + foreach (var layer in layers) + { + var actualNodes = layer + .Where(node => !dummyNodeIds.Contains(node.Id) && originalNodesById.ContainsKey(node.Id)) + .Select(node => originalNodesById[node.Id]) + .ToArray(); + if (actualNodes.Length == 0) + { + continue; + } + + var nodeDesiredPairs = new (ElkNode Node, double Desired)[actualNodes.Length]; + for (var nodeIndex = 0; nodeIndex < actualNodes.Length; nodeIndex++) + { + var positioned = positionedNodes[actualNodes[nodeIndex].Id]; + var preferredCenter = ResolveOriginalPreferredCenter( + actualNodes[nodeIndex].Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: direction == ElkLayoutDirection.LeftToRight); + nodeDesiredPairs[nodeIndex] = (actualNodes[nodeIndex], preferredCenter.HasValue + ? preferredCenter.Value - ((direction == ElkLayoutDirection.LeftToRight ? positioned.Height : positioned.Width) / 2d) + : (direction == ElkLayoutDirection.LeftToRight ? positioned.Y : positioned.X)); + } + + Array.Sort(nodeDesiredPairs, (a, b) => a.Desired.CompareTo(b.Desired)); + var sortedNodes = nodeDesiredPairs.Select(p => p.Node).ToArray(); + var desiredCoordinates = nodeDesiredPairs.Select(p => p.Desired).ToArray(); + + EnforceLinearSpacing( + sortedNodes, + desiredCoordinates, + nodeSpacing, + horizontal: direction == ElkLayoutDirection.LeftToRight); + + for (var nodeIndex = 0; nodeIndex < sortedNodes.Length; nodeIndex++) + { + var current = positionedNodes[sortedNodes[nodeIndex].Id]; + positionedNodes[sortedNodes[nodeIndex].Id] = direction == ElkLayoutDirection.LeftToRight + ? CreatePositionedNode(sortedNodes[nodeIndex], current.X, desiredCoordinates[nodeIndex], direction) + : CreatePositionedNode(sortedNodes[nodeIndex], desiredCoordinates[nodeIndex], current.Y, direction); + } + } + } + } + + private static void PropagateSuccessorPositionBackward( + Dictionary positionedNodes, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary originalNodesById, + ElkLayoutDirection direction) + { + var horizontal = direction == ElkLayoutDirection.LeftToRight; + + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + if (!originalNodesById.ContainsKey(nodeId)) + { + continue; + } + + var current = positionedNodes[nodeId]; + if (!string.Equals(current.Kind, "Fork", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!outgoingNodeIds.TryGetValue(nodeId, out var forkOutgoing) || forkOutgoing.Count < 2) + { + continue; + } + + var joinSuccessor = forkOutgoing + .Select(id => positionedNodes.GetValueOrDefault(id)) + .FirstOrDefault(n => n is not null + && string.Equals(n.Kind, "Join", StringComparison.OrdinalIgnoreCase)); + if (joinSuccessor is null) + { + continue; + } + + var joinCenter = horizontal + ? joinSuccessor.Y + (joinSuccessor.Height / 2d) + : joinSuccessor.X + (joinSuccessor.Width / 2d); + var forkCenter = horizontal + ? current.Y + (current.Height / 2d) + : current.X + (current.Width / 2d); + if (Math.Abs(joinCenter - forkCenter) < 20d) + { + continue; + } + + var chainNodeIds = new List { nodeId }; + var walkId = nodeId; + for (var step = 0; step < 20; step++) + { + var predecessor = positionedNodes.Keys + .Where(id => originalNodesById.ContainsKey(id) + && outgoingNodeIds.TryGetValue(id, out var outs) && outs.Count == 1 && outs[0] == walkId) + .FirstOrDefault(); + if (predecessor is null) + { + break; + } + + chainNodeIds.Add(predecessor); + walkId = predecessor; + } + + foreach (var chainId in chainNodeIds) + { + var pos = positionedNodes[chainId]; + var orig = originalNodesById[chainId]; + if (horizontal) + { + positionedNodes[chainId] = CreatePositionedNode( + orig, pos.X, joinCenter - (pos.Height / 2d), direction); + } + else + { + positionedNodes[chainId] = CreatePositionedNode( + orig, joinCenter - (pos.Width / 2d), pos.Y, direction); + } + } + } + } + + private static void CenterMultiIncomingNodes( + Dictionary positionedNodes, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary originalNodesById, + ElkLayoutDirection direction) + { + var horizontal = direction == ElkLayoutDirection.LeftToRight; + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + if (!originalNodesById.TryGetValue(nodeId, out var originalNode)) + { + continue; + } + + var current = positionedNodes[nodeId]; + if (!string.Equals(current.Kind, "Join", StringComparison.OrdinalIgnoreCase) + && !string.Equals(current.Kind, "End", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!incomingNodeIds.TryGetValue(nodeId, out var incomingIds) || incomingIds.Count < 2) + { + continue; + } + + var centers = incomingIds + .Select(id => positionedNodes.GetValueOrDefault(id)) + .Where(n => n is not null) + .Select(n => horizontal ? n!.Y + (n.Height / 2d) : n!.X + (n.Width / 2d)) + .OrderBy(c => c) + .ToArray(); + + if (centers.Length < 2) + { + continue; + } + + double medianCenter; + if (string.Equals(current.Kind, "End", StringComparison.OrdinalIgnoreCase)) + { + medianCenter = (centers[0] + centers[^1]) / 2d; + } + else + { + var mid = centers.Length / 2; + medianCenter = centers.Length % 2 == 1 + ? centers[mid] + : (centers[mid - 1] + centers[mid]) / 2d; + } + + if (horizontal) + { + var desiredY = medianCenter - (current.Height / 2d); + positionedNodes[nodeId] = CreatePositionedNode(originalNode, current.X, desiredY, direction); + } + else + { + var desiredX = medianCenter - (current.Width / 2d); + positionedNodes[nodeId] = CreatePositionedNode(originalNode, desiredX, current.Y, direction); + } + } + } + + private static void CompactTowardIncomingFlow( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlySet dummyNodeIds, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary originalNodesById, + double nodeSpacing, + ElkLayoutDirection direction) + { + for (var iteration = 0; iteration < 3; iteration++) + { + foreach (var layer in layers) + { + var actualNodes = layer + .Where(node => !dummyNodeIds.Contains(node.Id) && originalNodesById.ContainsKey(node.Id)) + .Select(node => originalNodesById[node.Id]) + .ToArray(); + if (actualNodes.Length == 0) + { + continue; + } + + if (direction == ElkLayoutDirection.LeftToRight) + { + var previousBottom = double.NegativeInfinity; + for (var nodeIndex = 0; nodeIndex < actualNodes.Length; nodeIndex++) + { + var current = positionedNodes[actualNodes[nodeIndex].Id]; + var targetY = current.Y; + if (nodeIndex > 0) + { + targetY = Math.Max(targetY, previousBottom + nodeSpacing); + } + + if (ShouldCompactTowardIncoming(actualNodes[nodeIndex].Id, incomingNodeIds, positionedNodes)) + { + var preferredCenter = ResolveIncomingPreferredCenter( + actualNodes[nodeIndex].Id, + incomingNodeIds, + positionedNodes, + horizontal: true); + if (preferredCenter.HasValue) + { + targetY = Math.Max( + nodeIndex > 0 ? previousBottom + nodeSpacing : double.NegativeInfinity, + Math.Min(current.Y, preferredCenter.Value - (current.Height / 2d))); + } + } + + positionedNodes[actualNodes[nodeIndex].Id] = CreatePositionedNode( + actualNodes[nodeIndex], + current.X, + targetY, + direction); + previousBottom = targetY + current.Height; + } + + continue; + } + + var previousRight = double.NegativeInfinity; + for (var nodeIndex = 0; nodeIndex < actualNodes.Length; nodeIndex++) + { + var current = positionedNodes[actualNodes[nodeIndex].Id]; + var targetX = current.X; + if (nodeIndex > 0) + { + targetX = Math.Max(targetX, previousRight + nodeSpacing); + } + + if (ShouldCompactTowardIncoming(actualNodes[nodeIndex].Id, incomingNodeIds, positionedNodes)) + { + var preferredCenter = ResolveIncomingPreferredCenter( + actualNodes[nodeIndex].Id, + incomingNodeIds, + positionedNodes, + horizontal: false); + if (preferredCenter.HasValue) + { + targetX = Math.Max( + nodeIndex > 0 ? previousRight + nodeSpacing : double.NegativeInfinity, + Math.Min(current.X, preferredCenter.Value - (current.Width / 2d))); + } + } + + positionedNodes[actualNodes[nodeIndex].Id] = CreatePositionedNode( + actualNodes[nodeIndex], + targetX, + current.Y, + direction); + previousRight = targetX + current.Width; + } + } + } + } + + private static bool ShouldCompactTowardIncoming( + string nodeId, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary positionedNodes) + { + if (!positionedNodes.TryGetValue(nodeId, out var currentNode) + || string.Equals(currentNode.Kind, "Start", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!incomingNodeIds.TryGetValue(nodeId, out var incomingIds) || incomingIds.Count == 0) + { + return false; + } + + return incomingIds + .Select(incomingId => positionedNodes.GetValueOrDefault(incomingId)) + .Where(incomingNode => incomingNode is not null) + .All(incomingNode => !string.Equals(incomingNode!.Kind, "Fork", StringComparison.OrdinalIgnoreCase)); + } + + private static double? ResolveIncomingPreferredCenter( + string nodeId, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary positionedNodes, + bool horizontal) + { + if (!incomingNodeIds.TryGetValue(nodeId, out var incomingIds) || incomingIds.Count == 0) + { + return null; + } + + var centers = incomingIds + .Select(incomingId => positionedNodes.GetValueOrDefault(incomingId)) + .Where(incomingNode => incomingNode is not null) + .Select(incomingNode => horizontal + ? incomingNode!.Y + (incomingNode.Height / 2d) + : incomingNode!.X + (incomingNode.Width / 2d)) + .OrderBy(center => center) + .ToArray(); + if (centers.Length == 0) + { + return null; + } + + var mid = centers.Length / 2; + return centers.Length % 2 == 1 + ? centers[mid] + : (centers[mid - 1] + centers[mid]) / 2d; + } + + private static void AlignDummyNodesToFlow( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlySet dummyNodeIds, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary nodesById, + ElkLayoutDirection direction) + { + for (var iteration = 0; iteration < 2; iteration++) + { + foreach (var layer in layers) + { + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + if (!dummyNodeIds.Contains(node.Id)) + { + continue; + } + + var current = positionedNodes[node.Id]; + var preferredCenter = ResolvePreferredCenter( + node.Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: direction == ElkLayoutDirection.LeftToRight); + if (!preferredCenter.HasValue) + { + continue; + } + + if (direction == ElkLayoutDirection.LeftToRight) + { + var minY = nodeIndex == 0 + ? double.NegativeInfinity + : positionedNodes[layer[nodeIndex - 1].Id].Y + positionedNodes[layer[nodeIndex - 1].Id].Height + 1d; + var maxY = nodeIndex == layer.Length - 1 + ? double.PositiveInfinity + : positionedNodes[layer[nodeIndex + 1].Id].Y - current.Height - 1d; + var desiredY = Clamp(preferredCenter.Value - (current.Height / 2d), minY, maxY); + positionedNodes[node.Id] = CreatePositionedNode(nodesById[node.Id], current.X, desiredY, direction); + continue; + } + + var minX = nodeIndex == 0 + ? double.NegativeInfinity + : positionedNodes[layer[nodeIndex - 1].Id].X + positionedNodes[layer[nodeIndex - 1].Id].Width + 1d; + var maxX = nodeIndex == layer.Length - 1 + ? double.PositiveInfinity + : positionedNodes[layer[nodeIndex + 1].Id].X - current.Width - 1d; + var desiredX = Clamp(preferredCenter.Value - (current.Width / 2d), minX, maxX); + positionedNodes[node.Id] = CreatePositionedNode(nodesById[node.Id], desiredX, current.Y, direction); + } + } + } + } + + private static double? ResolveOriginalPreferredCenter( + string nodeId, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary positionedNodes, + bool horizontal) + { + if (!positionedNodes.TryGetValue(nodeId, out var currentNode)) + { + return null; + } + + incomingNodeIds.TryGetValue(nodeId, out var incomingIds); + if (incomingIds is not null + && string.Equals(currentNode.Kind, "Join", StringComparison.OrdinalIgnoreCase) + && incomingIds.Count > 0) + { + var branchCenters = incomingIds + .Select(adjacentNodeId => positionedNodes.GetValueOrDefault(adjacentNodeId)) + .Where(adjacentNode => adjacentNode is not null) + .Select(adjacentNode => horizontal + ? adjacentNode!.Y + (adjacentNode.Height / 2d) + : adjacentNode!.X + (adjacentNode.Width / 2d)) + .OrderBy(center => center) + .ToArray(); + if (branchCenters.Length > 0) + { + var mid = branchCenters.Length / 2; + return branchCenters.Length % 2 == 1 + ? branchCenters[mid] + : (branchCenters[mid - 1] + branchCenters[mid]) / 2d; + } + } + + if (incomingIds is not null + && incomingIds.Count == 1 + && positionedNodes.TryGetValue(incomingIds[0], out var linearPredecessor) + && outgoingNodeIds.TryGetValue(incomingIds[0], out var predecessorOutgoing) + && predecessorOutgoing.Count == 1) + { + return horizontal + ? linearPredecessor.Y + (linearPredecessor.Height / 2d) + : linearPredecessor.X + (linearPredecessor.Width / 2d); + } + + if (outgoingNodeIds.TryGetValue(nodeId, out var outgoingIds) + && outgoingIds.Count == 1 + && positionedNodes.TryGetValue(outgoingIds[0], out var linearSuccessor) + && incomingNodeIds.TryGetValue(outgoingIds[0], out var successorIncoming) + && successorIncoming.Count == 1) + { + return horizontal + ? linearSuccessor.Y + (linearSuccessor.Height / 2d) + : linearSuccessor.X + (linearSuccessor.Width / 2d); + } + + var allCenters = new List(); + if (incomingIds is not null) + { + foreach (var adjacentNodeId in incomingIds) + { + if (positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + allCenters.Add(horizontal + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d)); + } + } + } + + if (outgoingNodeIds.TryGetValue(nodeId, out outgoingIds)) + { + foreach (var adjacentNodeId in outgoingIds) + { + if (positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + allCenters.Add(horizontal + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d)); + } + } + } + + if (allCenters.Count > 0) + { + allCenters.Sort(); + var mid = allCenters.Count / 2; + return allCenters.Count % 2 == 1 + ? allCenters[mid] + : (allCenters[mid - 1] + allCenters[mid]) / 2d; + } + + return null; + } + + private static double? ResolvePreferredCenter( + string nodeId, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary positionedNodes, + bool horizontal) + { + if (incomingNodeIds.TryGetValue(nodeId, out var incomingIds) + && incomingIds.Count == 1 + && positionedNodes.TryGetValue(incomingIds[0], out var linearPredecessor) + && outgoingNodeIds.TryGetValue(incomingIds[0], out var predecessorOutgoing) + && predecessorOutgoing.Count == 1) + { + return horizontal + ? linearPredecessor.Y + (linearPredecessor.Height / 2d) + : linearPredecessor.X + (linearPredecessor.Width / 2d); + } + + if (outgoingNodeIds.TryGetValue(nodeId, out var outgoingIds) + && outgoingIds.Count == 1 + && positionedNodes.TryGetValue(outgoingIds[0], out var linearSuccessor) + && incomingNodeIds.TryGetValue(outgoingIds[0], out var successorIncoming) + && successorIncoming.Count == 1) + { + return horizontal + ? linearSuccessor.Y + (linearSuccessor.Height / 2d) + : linearSuccessor.X + (linearSuccessor.Width / 2d); + } + + var coordinates = new List(); + if (incomingNodeIds.TryGetValue(nodeId, out incomingIds)) + { + foreach (var adjacentNodeId in incomingIds) + { + if (!positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + continue; + } + + coordinates.Add(horizontal + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d)); + } + } + + if (outgoingNodeIds.TryGetValue(nodeId, out outgoingIds)) + { + foreach (var adjacentNodeId in outgoingIds) + { + if (!positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + continue; + } + + coordinates.Add(horizontal + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d)); + } + } + + if (coordinates.Count == 0) + { + return null; + } + + coordinates.Sort(); + var mid = coordinates.Count / 2; + return coordinates.Count % 2 == 1 + ? coordinates[mid] + : (coordinates[mid - 1] + coordinates[mid]) / 2d; + } + + private static void EnforceLinearSpacing( + IReadOnlyList layer, + double[] desiredCoordinates, + double spacing, + bool horizontal) + { + for (var index = 1; index < layer.Count; index++) + { + var extent = horizontal ? layer[index - 1].Height : layer[index - 1].Width; + desiredCoordinates[index] = Math.Max( + desiredCoordinates[index], + desiredCoordinates[index - 1] + extent + spacing); + } + + for (var index = layer.Count - 2; index >= 0; index--) + { + var extent = horizontal ? layer[index].Height : layer[index].Width; + desiredCoordinates[index] = Math.Min( + desiredCoordinates[index], + desiredCoordinates[index + 1] - extent - spacing); + } + + for (var index = 1; index < layer.Count; index++) + { + var extent = horizontal ? layer[index - 1].Height : layer[index - 1].Width; + desiredCoordinates[index] = Math.Max( + desiredCoordinates[index], + desiredCoordinates[index - 1] + extent + spacing); + } + } + + private static void OrderLayer( + IReadOnlyList> layers, + int layerIndex, + IReadOnlyDictionary> adjacentNodeIds, + IReadOnlyDictionary inputOrder) + { + var positions = BuildNodeOrderPositions(layers); + var currentLayer = layers[layerIndex]; + currentLayer.Sort((left, right) => + { + var leftRank = ResolveOrderingRank(left.Id, adjacentNodeIds, positions); + var rightRank = ResolveOrderingRank(right.Id, adjacentNodeIds, positions); + var comparison = leftRank.CompareTo(rightRank); + if (comparison != 0) + { + return comparison; + } + + comparison = positions[left.Id].CompareTo(positions[right.Id]); + if (comparison != 0) + { + return comparison; + } + + return inputOrder[left.Id].CompareTo(inputOrder[right.Id]); + }); + } + + private static Dictionary BuildNodeOrderPositions(IReadOnlyList> layers) + { + var positions = new Dictionary(StringComparer.Ordinal); + foreach (var layer in layers) + { + for (var index = 0; index < layer.Count; index++) + { + positions[layer[index].Id] = index; + } + } + + return positions; + } + + private static double ResolveOrderingRank( + string nodeId, + IReadOnlyDictionary> adjacentNodeIds, + IReadOnlyDictionary positions) + { + if (!adjacentNodeIds.TryGetValue(nodeId, out var neighbors) || neighbors.Count == 0) + { + return double.PositiveInfinity; + } + + var ordered = neighbors + .Where(positions.ContainsKey) + .Select(neighborId => (double)positions[neighborId]) + .OrderBy(value => value) + .ToArray(); + + if (ordered.Length == 0) + { + return double.PositiveInfinity; + } + + var middle = ordered.Length / 2; + return ordered.Length % 2 == 1 + ? ordered[middle] + : (ordered[middle - 1] + ordered[middle]) / 2d; + } + + private static IReadOnlyCollection NormalizeBendPoints(params ElkPoint[] points) + { + if (points.Length == 0) + { + return []; + } + + var normalized = new List(points.Length); + foreach (var point in points) + { + if (normalized.Count > 0 + && Math.Abs(normalized[^1].X - point.X) <= 0.01d + && Math.Abs(normalized[^1].Y - point.Y) <= 0.01d) + { + continue; + } + + normalized.Add(point); + } + + return normalized; + } + + private static (Dictionary InputOrder, HashSet BackEdgeIds) BuildTraversalInputOrder( + IReadOnlyCollection nodes, + IReadOnlyCollection edges, + IReadOnlyDictionary nodesById) + { + var originalOrder = nodes + .Select((node, index) => new KeyValuePair(node.Id, index)) + .ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); + var outgoing = nodes.ToDictionary(node => node.Id, _ => new List(), StringComparer.Ordinal); + var incomingCount = nodes.ToDictionary(node => node.Id, _ => 0, StringComparer.Ordinal); + + foreach (var edge in edges) + { + outgoing[edge.SourceNodeId].Add(edge); + incomingCount[edge.TargetNodeId] = incomingCount[edge.TargetNodeId] + 1; + } + + var orderedNodeIds = new List(nodes.Count); + var visited = new HashSet(StringComparer.Ordinal); + var onStack = new HashSet(StringComparer.Ordinal); + var backEdgeIds = new HashSet(StringComparer.Ordinal); + var preferredRoots = nodes + .Where(node => string.Equals(node.Kind, "Start", StringComparison.Ordinal)) + .Concat(nodes.Where(node => !string.Equals(node.Kind, "Start", StringComparison.Ordinal) && incomingCount[node.Id] == 0)) + .OrderBy(node => originalOrder[node.Id], Comparer.Default) + .ToArray(); + + foreach (var root in preferredRoots) + { + Visit(root.Id); + } + + foreach (var node in nodes + .Where(node => !string.Equals(node.Kind, "End", StringComparison.Ordinal)) + .OrderBy(node => originalOrder[node.Id], Comparer.Default)) + { + Visit(node.Id); + } + + foreach (var endNode in nodes + .Where(node => string.Equals(node.Kind, "End", StringComparison.Ordinal)) + .OrderBy(node => originalOrder[node.Id], Comparer.Default)) + { + Visit(endNode.Id); + } + + var inputOrder = orderedNodeIds + .Select((nodeId, index) => new KeyValuePair(nodeId, index)) + .ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); + + return (inputOrder, backEdgeIds); + + void Visit(string nodeId) + { + if (!visited.Add(nodeId)) + { + return; + } + + onStack.Add(nodeId); + orderedNodeIds.Add(nodeId); + foreach (var edge in outgoing[nodeId] + .OrderBy(edge => string.Equals(nodesById[edge.TargetNodeId].Kind, "End", StringComparison.Ordinal) ? 1 : 0) + .ThenBy(edge => originalOrder[edge.TargetNodeId], Comparer.Default)) + { + if (onStack.Contains(edge.TargetNodeId)) + { + backEdgeIds.Add(edge.Id); + } + + Visit(edge.TargetNodeId); + } + + onStack.Remove(nodeId); + } + } + + private static ElkPoint IntersectDiamondBoundary( + double centerX, + double centerY, + double halfWidth, + double halfHeight, + double deltaX, + double deltaY) + { + if (Math.Abs(deltaX) < 0.001d && Math.Abs(deltaY) < 0.001d) + { + return new ElkPoint + { + X = centerX, + Y = centerY, + }; + } + + var scale = 1d / ((Math.Abs(deltaX) / Math.Max(halfWidth, 0.001d)) + (Math.Abs(deltaY) / Math.Max(halfHeight, 0.001d))); + return new ElkPoint + { + X = centerX + (deltaX * scale), + Y = centerY + (deltaY * scale), + }; + } + + private static ElkPoint ResolveGatewayBoundaryPoint( + ElkPositionedNode node, + ElkPoint candidate, + double deltaX, + double deltaY) + { + if (node.Kind is not ("Decision" or "Fork" or "Join")) + { + return candidate; + } + + var centerX = node.X + (node.Width / 2d); + var centerY = node.Y + (node.Height / 2d); + if (Math.Abs(deltaX) < 0.001d && Math.Abs(deltaY) < 0.001d) + { + deltaX = candidate.X - centerX; + deltaY = candidate.Y - centerY; + } + + if (node.Kind == "Decision") + { + return IntersectDiamondBoundary(centerX, centerY, node.Width / 2d, node.Height / 2d, deltaX, deltaY); + } + + return IntersectPolygonBoundary( + centerX, + centerY, + deltaX, + deltaY, + BuildForkBoundaryPoints(node)); + } + + private static IReadOnlyList BuildForkBoundaryPoints(ElkPositionedNode node) + { + var cornerInset = Math.Min(22d, Math.Max(6d, node.Width * 0.125d)); + var verticalInset = Math.Min(8d, Math.Max(4d, node.Height * 0.065d)); + return + [ + new ElkPoint { X = node.X + cornerInset, Y = node.Y + verticalInset }, + new ElkPoint { X = node.X + node.Width - cornerInset, Y = node.Y + verticalInset }, + new ElkPoint { X = node.X + node.Width, Y = node.Y + (node.Height / 2d) }, + new ElkPoint { X = node.X + node.Width - cornerInset, Y = node.Y + node.Height - verticalInset }, + new ElkPoint { X = node.X + cornerInset, Y = node.Y + node.Height - verticalInset }, + new ElkPoint { X = node.X, Y = node.Y + (node.Height / 2d) }, + ]; + } + + private static ElkPoint IntersectPolygonBoundary( + double originX, + double originY, + double deltaX, + double deltaY, + IReadOnlyList polygon) + { + var bestScale = double.PositiveInfinity; + ElkPoint? bestPoint = null; + for (var index = 0; index < polygon.Count; index++) + { + var start = polygon[index]; + var end = polygon[(index + 1) % polygon.Count]; + if (!TryIntersectRayWithSegment(originX, originY, deltaX, deltaY, start, end, out var scale, out var point)) + { + continue; + } + + if (scale < bestScale) + { + bestScale = scale; + bestPoint = point; + } + } + + return bestPoint ?? new ElkPoint + { + X = originX + deltaX, + Y = originY + deltaY, + }; + } + + private static bool TryIntersectRayWithSegment( + double originX, + double originY, + double deltaX, + double deltaY, + ElkPoint segmentStart, + ElkPoint segmentEnd, + out double scale, + out ElkPoint point) + { + scale = double.PositiveInfinity; + point = default!; + + var segmentDeltaX = segmentEnd.X - segmentStart.X; + var segmentDeltaY = segmentEnd.Y - segmentStart.Y; + var denominator = Cross(deltaX, deltaY, segmentDeltaX, segmentDeltaY); + if (Math.Abs(denominator) <= 0.001d) + { + return false; + } + + var relativeX = segmentStart.X - originX; + var relativeY = segmentStart.Y - originY; + var rayScale = Cross(relativeX, relativeY, segmentDeltaX, segmentDeltaY) / denominator; + var segmentScale = Cross(relativeX, relativeY, deltaX, deltaY) / denominator; + if (rayScale < 0d || segmentScale < 0d || segmentScale > 1d) + { + return false; + } + + scale = rayScale; + point = new ElkPoint + { + X = originX + (deltaX * rayScale), + Y = originY + (deltaY * rayScale), + }; + return true; + } + + private static double Cross(double ax, double ay, double bx, double by) + { + return (ax * by) - (ay * bx); + } + + private static DummyNodeResult InsertDummyNodes( + IReadOnlyCollection originalNodes, + IReadOnlyCollection originalEdges, + Dictionary layersByNodeId, + IReadOnlyDictionary inputOrder, + IReadOnlySet backEdgeIds) + { + var allNodes = new List(originalNodes); + var allEdges = new List(); + var augmentedLayers = new Dictionary(layersByNodeId, StringComparer.Ordinal); + var augmentedInputOrder = new Dictionary(inputOrder, StringComparer.Ordinal); + var dummyNodeIds = new HashSet(StringComparer.Ordinal); + var edgeDummyChains = new Dictionary>(StringComparer.Ordinal); + var nextInputOrder = inputOrder.Values.Max() + 1; + + foreach (var edge in originalEdges) + { + if (backEdgeIds.Contains(edge.Id)) + { + allEdges.Add(edge); + continue; + } + + var sourceLayer = layersByNodeId.GetValueOrDefault(edge.SourceNodeId, 0); + var targetLayer = layersByNodeId.GetValueOrDefault(edge.TargetNodeId, 0); + var span = targetLayer - sourceLayer; + + if (span <= 1) + { + allEdges.Add(edge); + continue; + } + + var chain = new List(); + var previousNodeId = edge.SourceNodeId; + + for (var layer = sourceLayer + 1; layer < targetLayer; layer++) + { + var dummyId = $"__dummy_{edge.Id}_{layer}"; + var dummyNode = new ElkNode + { + Id = dummyId, + Label = string.Empty, + Kind = "Dummy", + Width = 1, + Height = 1, + }; + + allNodes.Add(dummyNode); + augmentedLayers[dummyId] = layer; + augmentedInputOrder[dummyId] = inputOrder.GetValueOrDefault(edge.SourceNodeId, nextInputOrder); + dummyNodeIds.Add(dummyId); + chain.Add(dummyId); + + allEdges.Add(new ElkEdge + { + Id = $"{edge.Id}__seg_{layer}", + SourceNodeId = previousNodeId, + TargetNodeId = dummyId, + }); + + previousNodeId = dummyId; + } + + allEdges.Add(new ElkEdge + { + Id = $"{edge.Id}__seg_{targetLayer}", + SourceNodeId = previousNodeId, + TargetNodeId = edge.TargetNodeId, + }); + + edgeDummyChains[edge.Id] = chain; + } + + return new DummyNodeResult(allNodes, allEdges, augmentedLayers, augmentedInputOrder, dummyNodeIds, edgeDummyChains); + } + + private static Dictionary ReconstructDummyEdges( + IReadOnlyCollection originalEdges, + DummyNodeResult dummyResult, + IReadOnlyDictionary positionedNodes, + IReadOnlyDictionary augmentedNodesById, + ElkLayoutDirection direction, + GraphBounds graphBounds, + IReadOnlyDictionary edgeChannels, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var edgesWithChains = originalEdges + .Where(e => dummyResult.EdgeDummyChains.ContainsKey(e.Id)) + .ToArray(); + + var incomingByTarget = edgesWithChains + .GroupBy(e => e.TargetNodeId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.OrderBy(e => + { + var s = positionedNodes[e.SourceNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? s.Y + (s.Height / 2d) + : s.X + (s.Width / 2d); + }).ToArray(), StringComparer.Ordinal); + + var outgoingBySource = edgesWithChains + .GroupBy(e => e.SourceNodeId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.OrderBy(e => + { + var t = positionedNodes[e.TargetNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? t.Y + (t.Height / 2d) + : t.X + (t.Width / 2d); + }).ToArray(), StringComparer.Ordinal); + + var reconstructed = new Dictionary(StringComparer.Ordinal); + + foreach (var edge in edgesWithChains) + { + if (!dummyResult.EdgeDummyChains.TryGetValue(edge.Id, out var chain) || chain.Count == 0) + { + continue; + } + + var channel = edgeChannels.GetValueOrDefault(edge.Id); + if (channel.RouteMode != EdgeRouteMode.Direct + || !string.IsNullOrWhiteSpace(edge.SourcePortId) + || !string.IsNullOrWhiteSpace(edge.TargetPortId)) + { + reconstructed[edge.Id] = RouteEdge( + edge, + augmentedNodesById, + positionedNodes, + direction, + graphBounds, + channel, + layerBoundariesByNodeId); + continue; + } + + var sourceNode = positionedNodes[edge.SourceNodeId]; + var targetNode = positionedNodes[edge.TargetNodeId]; + var sourceGroup = outgoingBySource.GetValueOrDefault(edge.SourceNodeId); + var targetGroup = incomingByTarget.GetValueOrDefault(edge.TargetNodeId); + + if (ShouldRouteLongEdgeViaDirectRouter(edge, sourceNode, targetNode, sourceGroup, targetGroup, positionedNodes, direction)) + { + reconstructed[edge.Id] = RouteEdge( + edge, + augmentedNodesById, + positionedNodes, + direction, + graphBounds, + channel, + layerBoundariesByNodeId); + continue; + } + + var bendPoints = new List(); + foreach (var dummyId in chain) + { + if (positionedNodes.TryGetValue(dummyId, out var dummyPos)) + { + bendPoints.Add(new ElkPoint + { + X = dummyPos.X + (dummyPos.Width / 2d), + Y = dummyPos.Y + (dummyPos.Height / 2d), + }); + } + } + + var sourceExitY = ResolveGroupedAnchorCoordinate(sourceNode, edge, sourceGroup, positionedNodes, isSource: true, direction); + var targetEntryY = ResolveGroupedAnchorCoordinate(targetNode, edge, targetGroup, positionedNodes, isSource: false, direction); + + var targetCenter = new ElkPoint + { + X = targetNode.X + (targetNode.Width / 2d), + Y = targetNode.Y + (targetNode.Height / 2d), + }; + var sourceAnchor = ComputeSmartAnchor(sourceNode, targetCenter, + true, sourceExitY, sourceGroup?.Length ?? 1, direction); + var targetAnchor = ComputeSmartAnchor(targetNode, bendPoints.Count > 0 ? bendPoints[^1] : null, + false, targetEntryY, targetGroup?.Length ?? 1, direction); + + reconstructed[edge.Id] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = + [ + new ElkEdgeSection + { + StartPoint = sourceAnchor, + EndPoint = targetAnchor, + BendPoints = bendPoints, + }, + ], + }; + } + + return reconstructed; + } + + private static bool ShouldRouteLongEdgeViaDirectRouter( + ElkEdge edge, + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyList? sourceGroup, + IReadOnlyList? targetGroup, + IReadOnlyDictionary positionedNodes, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return false; + } + + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var rowTolerance = Math.Max(28d, Math.Min(sourceNode.Height, targetNode.Height) * 0.4d); + if (Math.Abs(sourceCenterY - targetCenterY) <= rowTolerance) + { + return true; + } + + var sourcePrimaryIndex = ResolvePrimaryAxisGroupIndex(sourceNode, sourceGroup, positionedNodes, isSource: true, direction); + var targetPrimaryIndex = ResolvePrimaryAxisGroupIndex(targetNode, targetGroup, positionedNodes, isSource: false, direction); + if (sourcePrimaryIndex < 0 && targetPrimaryIndex < 0) + { + return false; + } + + var sourceIndex = sourceGroup is null + ? -1 + : Array.FindIndex(sourceGroup.ToArray(), candidate => string.Equals(candidate.Id, edge.Id, StringComparison.Ordinal)); + var targetIndex = targetGroup is null + ? -1 + : Array.FindIndex(targetGroup.ToArray(), candidate => string.Equals(candidate.Id, edge.Id, StringComparison.Ordinal)); + + return sourceIndex >= 0 && sourceIndex == sourcePrimaryIndex + || targetIndex >= 0 && targetIndex == targetPrimaryIndex; + } + + private static double ResolveGroupedAnchorCoordinate( + ElkPositionedNode node, + ElkEdge edge, + IReadOnlyList? group, + IReadOnlyDictionary positionedNodes, + bool isSource, + ElkLayoutDirection direction) + { + var center = direction == ElkLayoutDirection.LeftToRight + ? node.Y + (node.Height / 2d) + : node.X + (node.Width / 2d); + if (group is null || group.Count <= 1) + { + return center; + } + + var index = Array.FindIndex(group.ToArray(), candidate => string.Equals(candidate.Id, edge.Id, StringComparison.Ordinal)); + if (index < 0) + { + return center; + } + + var primaryIndex = ResolvePrimaryAxisGroupIndex(node, group, positionedNodes, isSource, direction); + if (primaryIndex >= 0) + { + var spread = direction == ElkLayoutDirection.LeftToRight + ? Math.Min(14d, (node.Height - 12d) / Math.Max(1, group.Count)) + : Math.Min(14d, (node.Width - 12d) / Math.Max(1, group.Count)); + var coordinate = center + ((index - primaryIndex) * spread); + return direction == ElkLayoutDirection.LeftToRight + ? Clamp(coordinate, node.Y + 6d, node.Y + node.Height - 6d) + : Clamp(coordinate, node.X + 6d, node.X + node.Width - 6d); + } + + var fallbackSpread = direction == ElkLayoutDirection.LeftToRight + ? Math.Min(14d, (node.Height - 12d) / Math.Max(1, group.Count)) + : Math.Min(14d, (node.Width - 12d) / Math.Max(1, group.Count)); + var total = (group.Count - 1) * fallbackSpread; + var fallback = center - (total / 2d) + (index * fallbackSpread); + return direction == ElkLayoutDirection.LeftToRight + ? Clamp(fallback, node.Y + 6d, node.Y + node.Height - 6d) + : Clamp(fallback, node.X + 6d, node.X + node.Width - 6d); + } + + private static int ResolvePrimaryAxisGroupIndex( + ElkPositionedNode node, + IReadOnlyList? group, + IReadOnlyDictionary positionedNodes, + bool isSource, + ElkLayoutDirection direction) + { + if (group is null || group.Count == 0) + { + return -1; + } + + var reserveAxis = isSource + ? string.Equals(node.Kind, "Fork", StringComparison.OrdinalIgnoreCase) + || string.Equals(node.Kind, "Decision", StringComparison.OrdinalIgnoreCase) + : string.Equals(node.Kind, "Join", StringComparison.OrdinalIgnoreCase) + || string.Equals(node.Kind, "Decision", StringComparison.OrdinalIgnoreCase); + if (!reserveAxis) + { + return -1; + } + + var center = direction == ElkLayoutDirection.LeftToRight + ? node.Y + (node.Height / 2d) + : node.X + (node.Width / 2d); + var bestIndex = 0; + var bestDistance = double.PositiveInfinity; + for (var index = 0; index < group.Count; index++) + { + var adjacentNodeId = isSource ? group[index].TargetNodeId : group[index].SourceNodeId; + if (!positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + continue; + } + + var adjacentCenter = direction == ElkLayoutDirection.LeftToRight + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d); + var distance = Math.Abs(adjacentCenter - center); + if (distance < bestDistance) + { + bestDistance = distance; + bestIndex = index; + } + } + + return bestIndex; + } + + private static ElkPoint ComputeSmartAnchor( + ElkPositionedNode node, + ElkPoint? approachPoint, + bool isSource, + double spreadY, + int groupSize, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight || approachPoint is null) + { + var fallback = isSource + ? new ElkPoint { X = node.X + node.Width, Y = Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) } + : new ElkPoint { X = node.X, Y = Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) }; + return ResolveGatewayBoundaryPoint( + node, + fallback, + fallback.X - (node.X + (node.Width / 2d)), + fallback.Y - (node.Y + (node.Height / 2d))); + } + + var nodeCenterX = node.X + (node.Width / 2d); + var nodeCenterY = node.Y + (node.Height / 2d); + var deltaX = approachPoint.X - nodeCenterX; + var deltaY = approachPoint.Y - nodeCenterY; + + if (isSource) + { + if (Math.Abs(deltaY) > Math.Abs(deltaX) * 1.5d && deltaY < 0d) + { + var topCandidate = new ElkPoint + { + X = Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), + Y = node.Y, + }; + return ResolveGatewayBoundaryPoint(node, topCandidate, topCandidate.X - nodeCenterX, topCandidate.Y - nodeCenterY); + } + + if (Math.Abs(deltaY) > Math.Abs(deltaX) * 1.5d && deltaY > 0d) + { + var bottomCandidate = new ElkPoint + { + X = Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), + Y = node.Y + node.Height, + }; + return ResolveGatewayBoundaryPoint(node, bottomCandidate, bottomCandidate.X - nodeCenterX, bottomCandidate.Y - nodeCenterY); + } + + var eastCandidate = new ElkPoint + { + X = node.X + node.Width, + Y = groupSize > 1 ? Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) + : Clamp(approachPoint.Y, node.Y + 6d, node.Y + node.Height - 6d), + }; + return ResolveGatewayBoundaryPoint(node, eastCandidate, eastCandidate.X - nodeCenterX, eastCandidate.Y - nodeCenterY); + } + + if (Math.Abs(deltaY) > Math.Abs(deltaX) * 0.8d && deltaY < 0d) + { + var topCandidate = new ElkPoint + { + X = Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), + Y = node.Y, + }; + return ResolveGatewayBoundaryPoint(node, topCandidate, topCandidate.X - nodeCenterX, topCandidate.Y - nodeCenterY); + } + + if (Math.Abs(deltaY) > Math.Abs(deltaX) * 0.8d && deltaY > 0d) + { + var bottomCandidate = new ElkPoint + { + X = Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), + Y = node.Y + node.Height, + }; + return ResolveGatewayBoundaryPoint(node, bottomCandidate, bottomCandidate.X - nodeCenterX, bottomCandidate.Y - nodeCenterY); + } + + var westCandidate = new ElkPoint + { + X = node.X, + Y = groupSize > 1 ? Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) + : Clamp(approachPoint.Y, node.Y + 6d, node.Y + node.Height - 6d), + }; + return ResolveGatewayBoundaryPoint(node, westCandidate, westCandidate.X - nodeCenterX, westCandidate.Y - nodeCenterY); + } + + private sealed record DummyNodeResult( + List AllNodes, + List AllEdges, + Dictionary AugmentedLayers, + Dictionary AugmentedInputOrder, + HashSet DummyNodeIds, + Dictionary> EdgeDummyChains); + + private static GraphBounds ComputeGraphBounds(ICollection nodes) + { + return new GraphBounds( + nodes.Min(n => n.X), + nodes.Min(n => n.Y), + nodes.Max(n => n.X + n.Width), + nodes.Max(n => n.Y + n.Height)); + } + + private static bool ExpandVerticalCorridorGutters( + Dictionary positionedNodes, + IReadOnlyCollection routedEdges, + IReadOnlyDictionary layersByNodeId, + IReadOnlyDictionary nodesById, + double baseLayerSpacing, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return false; + } + + var boundariesByLayer = layersByNodeId + .Where(entry => positionedNodes.ContainsKey(entry.Key)) + .GroupBy(entry => entry.Value) + .OrderBy(group => group.Key) + .Select(group => + { + var nodes = group.Select(entry => positionedNodes[entry.Key]).ToArray(); + return new + { + Layer = group.Key, + Boundary = new LayerBoundary( + nodes.Min(node => node.X), + nodes.Max(node => node.X + node.Width), + nodes.Min(node => node.Y), + nodes.Max(node => node.Y + node.Height)), + }; + }) + .ToArray(); + if (boundariesByLayer.Length < 2) + { + return false; + } + + var requiredBoundaryDeltas = new Dictionary(); + for (var boundaryIndex = 0; boundaryIndex < boundariesByLayer.Length - 1; boundaryIndex++) + { + var current = boundariesByLayer[boundaryIndex]; + var next = boundariesByLayer[boundaryIndex + 1]; + var gap = next.Boundary.MinX - current.Boundary.MaxX; + if (gap <= 0d) + { + continue; + } + + var verticalSegments = routedEdges + .SelectMany(edge => edge.Sections.SelectMany(section => + { + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); + return points.Zip(points.Skip(1), (start, end) => new + { + Edge = edge, + Start = start, + End = end, + }); + })) + .Where(segment => + Math.Abs(segment.Start.X - segment.End.X) <= 0.01d + && Math.Abs(segment.End.Y - segment.Start.Y) >= 36d + && segment.Start.X > current.Boundary.MaxX + 8d + && segment.Start.X < next.Boundary.MinX - 8d) + .ToArray(); + var laneCount = verticalSegments + .Select(segment => Math.Round(segment.Start.X / 12d) * 12d) + .Distinct() + .Count(); + if (laneCount == 0) + { + continue; + } + + var familyCount = verticalSegments + .Select(segment => ResolveLaneFamilyKey(segment.Edge.Label)) + .Distinct(StringComparer.Ordinal) + .Count(); + var desiredGap = Math.Max( + baseLayerSpacing + 88d, + 136d + (laneCount * 28d) + (Math.Max(0, familyCount - 1) * 24d)); + if (gap >= desiredGap) + { + continue; + } + + requiredBoundaryDeltas[current.Layer] = desiredGap - gap; + } + + if (requiredBoundaryDeltas.Count == 0) + { + return false; + } + + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + if (!layersByNodeId.TryGetValue(nodeId, out var nodeLayer)) + { + continue; + } + + var shiftX = requiredBoundaryDeltas + .Where(entry => nodeLayer > entry.Key) + .Sum(entry => entry.Value); + if (shiftX <= 0.01d) + { + continue; + } + + var current = positionedNodes[nodeId]; + positionedNodes[nodeId] = CreatePositionedNode(nodesById[nodeId], current.X + shiftX, current.Y, direction); + } + + return true; + } + + private static bool CompactSparseVerticalCorridorGutters( + Dictionary positionedNodes, + IReadOnlyCollection routedEdges, + IReadOnlyDictionary layersByNodeId, + IReadOnlyDictionary nodesById, + double baseLayerSpacing, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return false; + } + + var boundariesByLayer = layersByNodeId + .Where(entry => positionedNodes.ContainsKey(entry.Key)) + .GroupBy(entry => entry.Value) + .OrderBy(group => group.Key) + .Select(group => + { + var nodes = group.Select(entry => positionedNodes[entry.Key]).ToArray(); + return new + { + Layer = group.Key, + Boundary = new LayerBoundary( + nodes.Min(node => node.X), + nodes.Max(node => node.X + node.Width), + nodes.Min(node => node.Y), + nodes.Max(node => node.Y + node.Height)), + }; + }) + .ToArray(); + if (boundariesByLayer.Length < 2) + { + return false; + } + + var boundaryShifts = new Dictionary(); + for (var boundaryIndex = 0; boundaryIndex < boundariesByLayer.Length - 1; boundaryIndex++) + { + var current = boundariesByLayer[boundaryIndex]; + var next = boundariesByLayer[boundaryIndex + 1]; + var gap = next.Boundary.MinX - current.Boundary.MaxX; + if (gap <= 0d) + { + continue; + } + + var verticalSegments = routedEdges + .SelectMany(edge => edge.Sections.SelectMany(section => + { + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); + return points.Zip(points.Skip(1), (start, end) => new + { + Edge = edge, + Start = start, + End = end, + }); + })) + .Where(segment => + Math.Abs(segment.Start.X - segment.End.X) <= 0.01d + && Math.Abs(segment.End.Y - segment.Start.Y) >= 36d + && segment.Start.X > current.Boundary.MaxX + 8d + && segment.Start.X < next.Boundary.MinX - 8d) + .ToArray(); + var laneCount = verticalSegments + .Select(segment => Math.Round(segment.Start.X / 12d) * 12d) + .Distinct() + .Count(); + var familyCount = verticalSegments + .Select(segment => ResolveLaneFamilyKey(segment.Edge.Label)) + .Distinct(StringComparer.Ordinal) + .Count(); + + var desiredGap = Math.Max( + baseLayerSpacing * 0.72d, + 120d + (laneCount * 20d) + (Math.Max(0, familyCount - 1) * 16d)); + var maxGap = desiredGap + 28d; + if (gap <= maxGap) + { + continue; + } + + boundaryShifts[current.Layer] = desiredGap - gap; + } + + if (boundaryShifts.Count == 0) + { + return false; + } + + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + if (!layersByNodeId.TryGetValue(nodeId, out var nodeLayer)) + { + continue; + } + + var shiftX = boundaryShifts + .Where(entry => nodeLayer > entry.Key) + .Sum(entry => entry.Value); + if (Math.Abs(shiftX) <= 0.01d) + { + continue; + } + + var current = positionedNodes[nodeId]; + positionedNodes[nodeId] = CreatePositionedNode(nodesById[nodeId], current.X + shiftX, current.Y, direction); + } + + return true; + } + + private static double ResolveBackwardCorridorY( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes) + { + if (edges.Count == 0) + { + return double.NaN; + } + + var spanMinX = double.PositiveInfinity; + var spanMaxX = double.NegativeInfinity; + var endpointTop = double.PositiveInfinity; + foreach (var edge in edges) + { + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + spanMinX = Math.Min(spanMinX, Math.Min(source.X, target.X)); + spanMaxX = Math.Max(spanMaxX, Math.Max(source.X + source.Width, target.X + target.Width)); + endpointTop = Math.Min(endpointTop, Math.Min(source.Y, target.Y)); + } + + var spanNodes = positionedNodes.Values + .Where(node => + !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase) + && (node.X + node.Width) >= spanMinX - 1d + && node.X <= spanMaxX + 1d) + .ToArray(); + var occupiedIntervals = spanNodes + .Select(node => (Top: node.Y, Bottom: node.Y + node.Height)) + .OrderBy(interval => interval.Top) + .ToArray(); + if (occupiedIntervals.Length == 0) + { + return double.NaN; + } + + var merged = new List<(double Top, double Bottom)>(); + foreach (var interval in occupiedIntervals) + { + if (merged.Count == 0 || interval.Top > merged[^1].Bottom + 0.01d) + { + merged.Add(interval); + continue; + } + + merged[^1] = (merged[^1].Top, Math.Max(merged[^1].Bottom, interval.Bottom)); + } + + var maxAllowed = endpointTop - 24d; + if (maxAllowed <= merged[0].Top) + { + return double.NaN; + } + + double bestScore = double.NegativeInfinity; + double bestCandidate = double.NaN; + for (var index = 0; index < merged.Count - 1; index++) + { + var freeTop = merged[index].Bottom; + var freeBottom = Math.Min(maxAllowed, merged[index + 1].Top); + var gapHeight = freeBottom - freeTop; + if (gapHeight < 56d) + { + continue; + } + + var candidateMin = freeTop + 18d; + var candidateMax = freeBottom - 12d; + if (candidateMax <= candidateMin) + { + continue; + } + + var desiredY = Math.Min(maxAllowed - 8d, freeTop + (gapHeight * 0.72d)); + foreach (var fraction in s_corridorSampleFractions) + { + var candidate = candidateMin + ((candidateMax - candidateMin) * fraction); + var score = ScoreHorizontalCorridorCandidate( + spanNodes, + freeTop, + freeBottom, + candidate, + desiredY, + [], + false, + freeTop); + if (score <= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + + return bestCandidate; + } + + private static double ResolveBackwardLowerCorridorY( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes) + { + if (edges.Count == 0) + { + return double.NaN; + } + + var spanMinX = double.PositiveInfinity; + var spanMaxX = double.NegativeInfinity; + var minAllowed = double.NegativeInfinity; + foreach (var edge in edges) + { + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + spanMinX = Math.Min(spanMinX, Math.Min(source.X, target.X)); + spanMaxX = Math.Max(spanMaxX, Math.Max(source.X + source.Width, target.X + target.Width)); + minAllowed = Math.Max(minAllowed, Math.Max(source.Y + source.Height, target.Y + target.Height) + 14d); + } + + var spanNodes = positionedNodes.Values + .Where(node => + !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase) + && (node.X + node.Width) >= spanMinX - 1d + && node.X <= spanMaxX + 1d) + .ToArray(); + var occupiedIntervals = spanNodes + .Select(node => (Top: node.Y, Bottom: node.Y + node.Height)) + .OrderBy(interval => interval.Top) + .ToArray(); + if (occupiedIntervals.Length == 0) + { + return double.NaN; + } + + var merged = new List<(double Top, double Bottom)>(); + foreach (var interval in occupiedIntervals) + { + if (merged.Count == 0 || interval.Top > merged[^1].Bottom + 0.01d) + { + merged.Add(interval); + continue; + } + + merged[^1] = (merged[^1].Top, Math.Max(merged[^1].Bottom, interval.Bottom)); + } + + double bestScore = double.NegativeInfinity; + double bestCandidate = double.NaN; + for (var index = 0; index < merged.Count - 1; index++) + { + var freeTop = Math.Max(minAllowed, merged[index].Bottom); + var freeBottom = merged[index + 1].Top; + var gapHeight = freeBottom - freeTop; + if (gapHeight < 44d) + { + continue; + } + + var candidateMin = freeTop + 12d; + var candidateMax = freeBottom - 12d; + if (candidateMax <= candidateMin) + { + continue; + } + + var desiredY = freeTop + Math.Min(40d, gapHeight * 0.32d); + foreach (var fraction in s_corridorSampleFractions) + { + var candidate = candidateMin + ((candidateMax - candidateMin) * fraction); + var score = ScoreHorizontalCorridorCandidate( + spanNodes, + freeTop, + freeBottom, + candidate, + desiredY, + [], + true, + minAllowed) + + 28d; + if (score <= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + + return bestCandidate; + } + + private static double ResolveSinkCorridorY( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes, + IReadOnlyCollection reservedBands) + { + if (edges.Count == 0) + { + return double.NaN; + } + + var spanMinX = double.PositiveInfinity; + var spanMaxX = double.NegativeInfinity; + var minAllowed = double.NegativeInfinity; + foreach (var edge in edges) + { + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + spanMinX = Math.Min(spanMinX, Math.Min(source.X, target.X)); + spanMaxX = Math.Max(spanMaxX, Math.Max(source.X + source.Width, target.X + target.Width)); + minAllowed = Math.Max(minAllowed, source.Y + source.Height + 18d); + } + + var spanNodes = positionedNodes.Values + .Where(node => + !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase) + && (node.X + node.Width) >= spanMinX - 1d + && node.X <= spanMaxX + 1d) + .ToArray(); + var occupiedIntervals = spanNodes + .Select(node => (Top: node.Y, Bottom: node.Y + node.Height)) + .OrderBy(interval => interval.Top) + .ToArray(); + if (occupiedIntervals.Length == 0) + { + return double.NaN; + } + + var merged = new List<(double Top, double Bottom)>(); + foreach (var interval in occupiedIntervals) + { + if (merged.Count == 0 || interval.Top > merged[^1].Bottom + 0.01d) + { + merged.Add(interval); + continue; + } + + merged[^1] = (merged[^1].Top, Math.Max(merged[^1].Bottom, interval.Bottom)); + } + + var occupiedBottom = merged[^1].Bottom; + var desiredY = minAllowed + Math.Min(280d, Math.Max(0d, occupiedBottom - minAllowed) * 0.35d); + double bestScore = double.NegativeInfinity; + double bestCandidate = double.NaN; + + for (var index = 0; index < merged.Count - 1; index++) + { + var freeTop = Math.Max(minAllowed, merged[index].Bottom); + var freeBottom = merged[index + 1].Top; + var gapHeight = freeBottom - freeTop; + if (gapHeight < 56d) + { + continue; + } + + var candidateMin = freeTop + 20d; + var candidateMax = freeBottom - 20d; + if (candidateMax <= candidateMin) + { + continue; + } + + foreach (var fraction in s_corridorSampleFractions) + { + var candidate = candidateMin + ((candidateMax - candidateMin) * fraction); + var score = ScoreHorizontalCorridorCandidate( + spanNodes, + freeTop, + freeBottom, + candidate, + desiredY, + reservedBands, + true, + minAllowed); + if (score <= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + + return bestCandidate; + } + + private static double ResolveSinkBandOffset(int bandIndex, double firstSpacing = 36d, double subsequentSpacing = 28d) + { + if (bandIndex <= 0) + { + return 0d; + } + + return firstSpacing + ((bandIndex - 1) * subsequentSpacing); + } + + private static readonly double[] s_corridorSampleFractions = [0.2d, 0.35d, 0.5d, 0.65d, 0.8d]; + + private static double ScoreHorizontalCorridorCandidate( + IReadOnlyCollection spanNodes, + double freeTop, + double freeBottom, + double candidate, + double desiredY, + IReadOnlyCollection reservedBands, + bool rewardInterior, + double minAllowed) + { + var gapHeight = freeBottom - freeTop; + var clearance = Math.Min(candidate - freeTop, freeBottom - candidate); + var bandSeparation = reservedBands.Count == 0 + ? 144d + : reservedBands.Min(band => Math.Abs(candidate - band)); + var rowPenalty = spanNodes.Sum(node => + { + var nodeTop = node.Y - 6d; + var nodeBottom = node.Y + node.Height + 6d; + if (candidate >= nodeTop && candidate <= nodeBottom) + { + return 100000d; + } + + var centerY = node.Y + (node.Height / 2d); + var distance = Math.Abs(centerY - candidate); + return distance >= 96d ? 0d : (96d - distance) * 0.32d; + }); + + return gapHeight + + (clearance * 1.8d) + + (Math.Min(120d, bandSeparation) * 0.7d) + - (Math.Abs(candidate - desiredY) * 0.35d) + - rowPenalty + + (rewardInterior && freeTop >= minAllowed + 64d ? 22d : 0d); + } + + private static bool CanUseLocalSinkDrop( + ElkEdge edge, + IReadOnlyDictionary positionedNodes, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return false; + } + + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + var sourceExitX = source.X + source.Width + 28d; + var sourceBottom = source.Y + source.Height; + var targetMidY = target.Y + (target.Height / 2d); + + return positionedNodes.Values + .Where(node => + !string.Equals(node.Id, source.Id, StringComparison.Ordinal) + && !string.Equals(node.Id, target.Id, StringComparison.Ordinal) + && !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase)) + .All(node => + sourceExitX < node.X - 12d + || sourceExitX > node.X + node.Width + 12d + || node.Y >= targetMidY + || node.Y + node.Height <= sourceBottom + 8d); + } + + private static bool ShouldPreferBottomSinkGutter( + ElkEdge edge, + IReadOnlyDictionary positionedNodes, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return false; + } + + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + if (!string.Equals(target.Kind, "End", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var horizontalSpan = target.X - (source.X + source.Width); + var verticalGap = target.Y - (source.Y + source.Height); + return horizontalSpan >= 960d && verticalGap >= 160d; + } + + private static Dictionary ComputeEdgeChannels( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes, + ElkLayoutDirection direction, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var channels = new Dictionary(edges.Count, StringComparer.Ordinal); + var backwardEdges = new List(); + var forwardEdgesBySource = new Dictionary>(StringComparer.Ordinal); + var outgoingCounts = edges + .GroupBy(edge => edge.SourceNodeId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + + foreach (var edge in edges) + { + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + var isBackward = direction == ElkLayoutDirection.LeftToRight + ? (target.X + (target.Width / 2d)) < (source.X + (source.Width / 2d)) + : (target.Y + (target.Height / 2d)) < (source.Y + (source.Height / 2d)); + + if (isBackward) + { + backwardEdges.Add(edge); + } + else + { + if (!forwardEdgesBySource.TryGetValue(edge.SourceNodeId, out var list)) + { + list = []; + forwardEdgesBySource[edge.SourceNodeId] = list; + } + + list.Add(edge); + } + } + + var backwardGroups = backwardEdges + .GroupBy(edge => edge.TargetNodeId, StringComparer.Ordinal) + .SelectMany(targetGroup => + { + var families = targetGroup + .GroupBy(edge => ResolveLaneFamilyKey(edge.Label), StringComparer.Ordinal) + .OrderBy(group => ResolveLaneFamilyPriority(group.First().Label)) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .ToArray(); + return families.Select((familyGroup, familyIndex) => new + { + TargetNodeId = targetGroup.Key, + FamilyKey = familyGroup.Key, + FamilyIndex = familyIndex, + FamilyCount = families.Length, + Edges = familyGroup.ToArray(), + SharedOuterX = familyGroup.Max(edge => + { + var s = positionedNodes[edge.SourceNodeId]; + return s.X + s.Width; + }) + 56d, + Priority = ResolveLaneFamilyPriority(familyGroup.First().Label), + Span = familyGroup.Max(edge => + { + var s = positionedNodes[edge.SourceNodeId]; + var t = positionedNodes[edge.TargetNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? Math.Abs((s.X + (s.Width / 2d)) - (t.X + (t.Width / 2d))) + : Math.Abs((s.Y + (s.Height / 2d)) - (t.Y + (t.Height / 2d))); + }), + }); + }) + .OrderByDescending(group => group.Span) + .ThenBy(group => group.Priority) + .ThenBy(group => group.TargetNodeId, StringComparer.Ordinal) + .ToArray(); + + var reservedHorizontalBands = new List(); + for (var laneIndex = 0; laneIndex < backwardGroups.Length; laneIndex++) + { + var group = backwardGroups[laneIndex]; + var useSourceCollector = string.Equals(group.FamilyKey, "repeat", StringComparison.Ordinal); + var preferredOuterY = double.NaN; + if (direction == ElkLayoutDirection.LeftToRight && useSourceCollector) + { + var lowerCorridorY = ResolveBackwardLowerCorridorY(group.Edges, positionedNodes); + preferredOuterY = !double.IsNaN(lowerCorridorY) + ? lowerCorridorY + : ResolveBackwardCorridorY(group.Edges, positionedNodes); + } + + if (!double.IsNaN(preferredOuterY)) + { + reservedHorizontalBands.Add(preferredOuterY); + } + + foreach (var edge in group.Edges) + { + channels[edge.Id] = new EdgeChannel( + EdgeRouteMode.BackwardOuter, + laneIndex, + group.FamilyIndex, + group.FamilyCount, + 0, + 1, + 0, + 1, + -1, + 0, + group.SharedOuterX, + preferredOuterY, + useSourceCollector, + double.NaN); + } + } + + var forwardEdgesByTarget = new Dictionary>(StringComparer.Ordinal); + foreach (var sourceEdges in forwardEdgesBySource.Values) + { + foreach (var edge in sourceEdges) + { + if (!forwardEdgesByTarget.TryGetValue(edge.TargetNodeId, out var list)) + { + list = []; + forwardEdgesByTarget[edge.TargetNodeId] = list; + } + + list.Add(edge); + } + } + + var sinkBandsByEdgeId = new Dictionary(StringComparer.Ordinal); + if (direction == ElkLayoutDirection.LeftToRight) + { + var reservedSinkBands = new List(reservedHorizontalBands); + foreach (var targetEdges in forwardEdgesByTarget) + { + var targetNode = positionedNodes[targetEdges.Key]; + var isSinkTarget = string.Equals(targetNode.Kind, "End", StringComparison.OrdinalIgnoreCase) + || !outgoingCounts.ContainsKey(targetEdges.Key); + if (!isSinkTarget || targetEdges.Value.Count < 2) + { + continue; + } + + var sinkBands = targetEdges.Value + .GroupBy(edge => ResolveLaneFamilyKey(edge.Label), StringComparer.Ordinal) + .OrderBy(group => ResolveSinkLanePriority(group.First().Label)) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .ToArray(); + for (var bandIndex = 0; bandIndex < sinkBands.Length; bandIndex++) + { + var sinkBandEdges = sinkBands[bandIndex].ToArray(); + var sharedOuterX = sinkBands[bandIndex].Max(edge => + { + var s = positionedNodes[edge.SourceNodeId]; + return s.X + s.Width; + }) + 56d; + var familyKey = ResolveLaneFamilyKey(sinkBands[bandIndex].First().Label); + var preferredOuterY = familyKey is "failure" or "timeout" + ? ResolveSinkCorridorY(sinkBandEdges, positionedNodes, reservedSinkBands) + : double.NaN; + if (!double.IsNaN(preferredOuterY)) + { + reservedSinkBands.Add(preferredOuterY + (bandIndex * 24d)); + } + + foreach (var edge in sinkBandEdges) + { + sinkBandsByEdgeId[edge.Id] = (bandIndex, sinkBands.Length, sharedOuterX, preferredOuterY); + } + } + } + } + + foreach (var sourceEdges in forwardEdgesBySource.Values) + { + var sorted = sourceEdges + .OrderBy(e => + { + var t = positionedNodes[e.TargetNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? t.Y + (t.Height / 2d) + : t.X + (t.Width / 2d); + }) + .ToArray(); + + for (var index = 0; index < sorted.Length; index++) + { + var targetEdges = forwardEdgesByTarget.GetValueOrDefault(sorted[index].TargetNodeId); + var targetIncomingIndex = 0; + var targetIncomingCount = 1; + if (targetEdges is not null && targetEdges.Count > 1) + { + var sortedBySourceY = targetEdges + .OrderBy(e => + { + var s = positionedNodes[e.SourceNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? s.Y + (s.Height / 2d) + : s.X + (s.Width / 2d); + }) + .ToList(); + targetIncomingIndex = sortedBySourceY.FindIndex(e => string.Equals(e.Id, sorted[index].Id, StringComparison.Ordinal)); + targetIncomingCount = sortedBySourceY.Count; + } + + var sinkBand = sinkBandsByEdgeId.GetValueOrDefault(sorted[index].Id, (-1, 0, 0d, double.NaN)); + var routeMode = EdgeRouteMode.Direct; + if (sinkBandsByEdgeId.ContainsKey(sorted[index].Id)) + { + var familyKey = ResolveLaneFamilyKey(sorted[index].Label); + if (familyKey is "failure" or "timeout") + { + routeMode = EdgeRouteMode.SinkOuterTop; + } + else + { + routeMode = EdgeRouteMode.SinkOuter; + } + } + + channels[sorted[index].Id] = new EdgeChannel( + routeMode, + -1, + 0, + 1, + index, + sorted.Length, + targetIncomingIndex, + targetIncomingCount, + sinkBand.Item1, + sinkBand.Item2, + sinkBand.Item3, + sinkBand.Item4, + false, + double.NaN); + } + } + + AllocateDirectForwardChannelBands(edges, positionedNodes, layerBoundariesByNodeId, channels, direction); + + return channels; + } + + private static void AllocateDirectForwardChannelBands( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes, + IReadOnlyDictionary layerBoundariesByNodeId, + Dictionary channels, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return; + } + + var candidates = edges + .Select(edge => TryCreateDirectChannelCandidate(edge, positionedNodes, layerBoundariesByNodeId, channels)) + .Where(candidate => candidate is not null) + .Select(candidate => candidate!.Value) + .GroupBy(candidate => candidate.GapKey, StringComparer.Ordinal); + + foreach (var gapGroup in candidates) + { + var ordered = gapGroup + .OrderBy(candidate => candidate.TargetCenterY) + .ThenBy(candidate => candidate.TargetX) + .ThenBy(candidate => candidate.SourceCenterY) + .ThenBy(candidate => candidate.FamilyPriority) + .ThenBy(candidate => candidate.EdgeId, StringComparer.Ordinal) + .ToArray(); + if (ordered.Length == 0) + { + continue; + } + + var gapMinX = ordered.Max(candidate => candidate.GapMinX); + var gapMaxX = ordered.Min(candidate => candidate.GapMaxX); + if (gapMaxX - gapMinX < 24d) + { + continue; + } + + var edgePadding = Math.Min(28d, Math.Max(16d, (gapMaxX - gapMinX) * 0.12d)); + var usableMinX = gapMinX + edgePadding; + var usableMaxX = gapMaxX - edgePadding; + if (usableMaxX <= usableMinX) + { + usableMinX = gapMinX + 12d; + usableMaxX = gapMaxX - 12d; + } + + for (var index = 0; index < ordered.Length; index++) + { + var preferredX = ordered.Length == 1 + ? (usableMinX + usableMaxX) / 2d + : usableMinX + ((usableMaxX - usableMinX) * (index / (double)(ordered.Length - 1))); + preferredX = Clamp(preferredX, ordered[index].GapMinX + 8d, ordered[index].GapMaxX - 8d); + + if (!channels.TryGetValue(ordered[index].EdgeId, out var channel)) + { + continue; + } + + channels[ordered[index].EdgeId] = channel with + { + PreferredDirectChannelX = preferredX, + }; + } + } + } + + private static DirectChannelCandidate? TryCreateDirectChannelCandidate( + ElkEdge edge, + IReadOnlyDictionary positionedNodes, + IReadOnlyDictionary layerBoundariesByNodeId, + IReadOnlyDictionary channels) + { + if (!channels.TryGetValue(edge.Id, out var channel) || channel.RouteMode != EdgeRouteMode.Direct) + { + return null; + } + + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + var sourceCenterX = source.X + (source.Width / 2d); + var targetCenterX = target.X + (target.Width / 2d); + if (targetCenterX <= sourceCenterX + 1d) + { + return null; + } + + var sourceCenterY = source.Y + (source.Height / 2d); + var targetCenterY = target.Y + (target.Height / 2d); + if (Math.Abs(targetCenterY - sourceCenterY) < 56d) + { + return null; + } + + var sourceBoundary = ResolveLayerBoundary(edge.SourceNodeId, layerBoundariesByNodeId, source); + var targetBoundary = ResolveLayerBoundary(edge.TargetNodeId, layerBoundariesByNodeId, target); + var gapMinX = sourceBoundary.MaxX + 12d; + var gapMaxX = targetBoundary.MinX - 12d; + if (gapMaxX - gapMinX < 48d) + { + return null; + } + + var gapKey = $"{Math.Round(gapMinX, 2):0.##}|{Math.Round(gapMaxX, 2):0.##}"; + return new DirectChannelCandidate( + edge.Id, + gapKey, + gapMinX, + gapMaxX, + ResolveLaneFamilyPriority(edge.Label), + sourceCenterY, + targetCenterY, + target.X); + } + + private static string ResolveLaneFamilyKey(string? label) + { + if (string.IsNullOrWhiteSpace(label)) + { + return "default"; + } + + var normalized = label.Trim().ToLowerInvariant(); + if (normalized.Contains("failure", StringComparison.Ordinal)) + { + return "failure"; + } + + if (normalized.Contains("timeout", StringComparison.Ordinal)) + { + return "timeout"; + } + + if (normalized.StartsWith("repeat ", StringComparison.Ordinal) + || normalized.Equals("body", StringComparison.Ordinal)) + { + return "repeat"; + } + + if (normalized.StartsWith("when ", StringComparison.Ordinal)) + { + return "success"; + } + + if (normalized.Contains("otherwise", StringComparison.Ordinal) + || normalized.Contains("default", StringComparison.Ordinal)) + { + return "default"; + } + + if (normalized.Contains("missing condition", StringComparison.Ordinal)) + { + return "missing-condition"; + } + + return "default"; + } + + private static int ResolveLaneFamilyPriority(string? label) + { + return ResolveLaneFamilyKey(label) switch + { + "failure" => 0, + "timeout" => 1, + "repeat" => 2, + "default" => 3, + "success" => 4, + "missing-condition" => 5, + _ => 6, + }; + } + + private static int ResolveSinkLanePriority(string? label) + { + return ResolveLaneFamilyKey(label) switch + { + "default" => 0, + "success" => 1, + "repeat" => 2, + "timeout" => 3, + "failure" => 4, + "missing-condition" => 5, + _ => 6, + }; + } + + private static Dictionary BuildLayerBoundariesByNodeId( + IReadOnlyDictionary positionedNodes, + IReadOnlyDictionary layersByNodeId) + { + var boundariesByLayer = layersByNodeId + .Where(entry => positionedNodes.ContainsKey(entry.Key)) + .GroupBy(entry => entry.Value) + .ToDictionary( + group => group.Key, + group => + { + var nodes = group.Select(entry => positionedNodes[entry.Key]).ToArray(); + return new LayerBoundary( + nodes.Min(node => node.X), + nodes.Max(node => node.X + node.Width), + nodes.Min(node => node.Y), + nodes.Max(node => node.Y + node.Height)); + }); + + return layersByNodeId + .Where(entry => boundariesByLayer.ContainsKey(entry.Value)) + .ToDictionary(entry => entry.Key, entry => boundariesByLayer[entry.Value], StringComparer.Ordinal); + } + + private static LayerBoundary ResolveLayerBoundary( + string nodeId, + IReadOnlyDictionary layerBoundariesByNodeId, + ElkPositionedNode fallbackNode) + { + return layerBoundariesByNodeId.TryGetValue(nodeId, out var boundary) + ? boundary + : new LayerBoundary( + fallbackNode.X, + fallbackNode.X + fallbackNode.Width, + fallbackNode.Y, + fallbackNode.Y + fallbackNode.Height); + } + + private static ElkRoutedEdge[] AvoidNodeCrossings( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight || nodes.Length == 0 || true) + { + return edges; + } + + const double margin = 10d; + var obstacles = nodes.Select(n => ( + Left: n.X - margin, Top: n.Y - margin, + Right: n.X + n.Width + margin, Bottom: n.Y + n.Height + margin, + Id: n.Id + )).ToArray(); + + var result = new ElkRoutedEdge[edges.Length]; + for (var edgeIndex = 0; edgeIndex < edges.Length; edgeIndex++) + { + var edge = edges[edgeIndex]; + var sourceId = edge.SourceNodeId ?? ""; + var targetId = edge.TargetNodeId ?? ""; + + var hasCrossing = false; + foreach (var section in edge.Sections) + { + var pts = new List { section.StartPoint }; + pts.AddRange(section.BendPoints); + pts.Add(section.EndPoint); + for (var i = 0; i < pts.Count - 1 && !hasCrossing; i++) + { + hasCrossing = SegmentCrossesObstacle(pts[i], pts[i + 1], obstacles, sourceId, targetId); + } + } + + if (!hasCrossing) + { + result[edgeIndex] = edge; + continue; + } + + var newSections = new List(edge.Sections.Count); + foreach (var section in edge.Sections) + { + var rerouted = RerouteWithGridAStar( + section.StartPoint, section.EndPoint, + obstacles, sourceId, targetId, margin); + + if (rerouted is not null && rerouted.Count >= 2) + { + newSections.Add(new ElkEdgeSection + { + StartPoint = rerouted[0], + EndPoint = rerouted[^1], + BendPoints = rerouted.Skip(1).Take(rerouted.Count - 2).ToArray(), + }); + } + else + { + newSections.Add(section); + } + } + + result[edgeIndex] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Label = edge.Label, + Sections = newSections, + }; + } + + return result; + } + + private static bool SegmentCrossesObstacle( + ElkPoint p1, ElkPoint p2, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, string targetId) + { + var isH = Math.Abs(p1.Y - p2.Y) < 2d; + var isV = Math.Abs(p1.X - p2.X) < 2d; + if (!isH && !isV) return false; + + foreach (var ob in obstacles) + { + if (ob.Id == sourceId || ob.Id == targetId) continue; + if (isH && p1.Y > ob.Top && p1.Y < ob.Bottom) + { + var minX = Math.Min(p1.X, p2.X); + var maxX = Math.Max(p1.X, p2.X); + if (maxX > ob.Left && minX < ob.Right) return true; + } + else if (isV && p1.X > ob.Left && p1.X < ob.Right) + { + var minY = Math.Min(p1.Y, p2.Y); + var maxY = Math.Max(p1.Y, p2.Y); + if (maxY > ob.Top && minY < ob.Bottom) return true; + } + } + + return false; + } + + private static List? RerouteWithGridAStar( + ElkPoint start, ElkPoint end, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, string targetId, + double margin) + { + var xs = new SortedSet { start.X, end.X }; + var ys = new SortedSet { start.Y, end.Y }; + foreach (var ob in obstacles) + { + if (ob.Id == sourceId || ob.Id == targetId) continue; + xs.Add(ob.Left - margin); + xs.Add(ob.Right + margin); + ys.Add(ob.Top - margin); + ys.Add(ob.Bottom + margin); + } + + var xArr = xs.ToArray(); + var yArr = ys.ToArray(); + var xCount = xArr.Length; + var yCount = yArr.Length; + if (xCount < 2 || yCount < 2) return null; + + var startIx = Array.BinarySearch(xArr, start.X); + var startIy = Array.BinarySearch(yArr, start.Y); + var endIx = Array.BinarySearch(xArr, end.X); + var endIy = Array.BinarySearch(yArr, end.Y); + if (startIx < 0 || startIy < 0 || endIx < 0 || endIy < 0) return null; + + bool IsBlocked(int ix1, int iy1, int ix2, int iy2) + { + var x1 = xArr[ix1]; var y1 = yArr[iy1]; + var x2 = xArr[ix2]; var y2 = yArr[iy2]; + foreach (var ob in obstacles) + { + if (ob.Id == sourceId || ob.Id == targetId) continue; + if (ix1 == ix2) + { + var segX = x1; + if (segX > ob.Left && segX < ob.Right) + { + var minY = Math.Min(y1, y2); + var maxY = Math.Max(y1, y2); + if (maxY > ob.Top && minY < ob.Bottom) return true; + } + } + else if (iy1 == iy2) + { + var segY = y1; + if (segY > ob.Top && segY < ob.Bottom) + { + var minX = Math.Min(x1, x2); + var maxX = Math.Max(x1, x2); + if (maxX > ob.Left && minX < ob.Right) return true; + } + } + } + + return false; + } + + // A* with (ix, iy, direction) state; direction: 0=none, 1=horizontal, 2=vertical + const double bendPenalty = 200d; + var stateCount = xCount * yCount * 3; + var gScore = new double[stateCount]; + Array.Fill(gScore, double.MaxValue); + var cameFrom = new int[stateCount]; + Array.Fill(cameFrom, -1); + + int StateId(int ix, int iy, int dir) => (ix * yCount + iy) * 3 + dir; + double Heuristic(int ix, int iy) => + Math.Abs(xArr[ix] - xArr[endIx]) + Math.Abs(yArr[iy] - yArr[endIy]); + + var startState = StateId(startIx, startIy, 0); + gScore[startState] = 0d; + var openSet = new PriorityQueue(); + openSet.Enqueue(startState, Heuristic(startIx, startIy)); + + var dx = new[] { 1, -1, 0, 0 }; + var dy = new[] { 0, 0, 1, -1 }; + var dirs = new[] { 1, 1, 2, 2 }; + + var maxIterations = xCount * yCount * 6; + var iterations = 0; + var closed = new HashSet(); + while (openSet.Count > 0 && iterations++ < maxIterations) + { + var current = openSet.Dequeue(); + + if (!closed.Add(current)) + { + continue; + } + + var curDir = current % 3; + var curIy = (current / 3) % yCount; + var curIx = (current / 3) / yCount; + + if (curIx == endIx && curIy == endIy) + { + var path = new List(); + var state = current; + while (state >= 0) + { + var sIy = (state / 3) % yCount; + var sIx = (state / 3) / yCount; + path.Add(new ElkPoint { X = xArr[sIx], Y = yArr[sIy] }); + state = cameFrom[state]; + } + + path.Reverse(); + var simplified = new List { path[0] }; + for (var i = 1; i < path.Count - 1; i++) + { + var prev = simplified[^1]; + var next = path[i + 1]; + if (Math.Abs(prev.X - path[i].X) > 0.5d || Math.Abs(path[i].X - next.X) > 0.5d) + { + if (Math.Abs(prev.Y - path[i].Y) > 0.5d || Math.Abs(path[i].Y - next.Y) > 0.5d) + { + simplified.Add(path[i]); + } + } + } + + simplified.Add(path[^1]); + return simplified; + } + + for (var d = 0; d < 4; d++) + { + var nx = curIx + dx[d]; + var ny = curIy + dy[d]; + if (nx < 0 || nx >= xCount || ny < 0 || ny >= yCount) continue; + if (IsBlocked(curIx, curIy, nx, ny)) continue; + + var newDir = dirs[d]; + var bend = (curDir != 0 && curDir != newDir) ? bendPenalty : 0d; + var dist = Math.Abs(xArr[nx] - xArr[curIx]) + Math.Abs(yArr[ny] - yArr[curIy]); + var tentativeG = gScore[current] + dist + bend; + var neighborState = StateId(nx, ny, newDir); + + if (tentativeG < gScore[neighborState]) + { + gScore[neighborState] = tentativeG; + cameFrom[neighborState] = current; + var f = tentativeG + Heuristic(nx, ny); + openSet.Enqueue(neighborState, f); + } + } + } + + return null; + } + + private static ElkRoutedEdge[] DistributeOverlappingPorts( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return edges; + } + + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var result = edges.ToArray(); + + var incomingByTarget = edges + .Select((e, i) => (Edge: e, Index: i)) + .Where(x => x.Edge.TargetNodeId is not null) + .GroupBy(x => x.Edge.TargetNodeId!, StringComparer.Ordinal) + .Where(g => g.Count() >= 2 && nodesById.ContainsKey(g.Key)); + + foreach (var group in incomingByTarget) + { + var targetNode = nodesById[group.Key]; + var edgesWithIndices = group.ToArray(); + + var sideGroups = edgesWithIndices + .GroupBy(x => + { + var sec = x.Edge.Sections.Last(); + var ep = sec.EndPoint; + if (Math.Abs(ep.X - targetNode.X) < 2d) return "WEST"; + if (Math.Abs(ep.X - (targetNode.X + targetNode.Width)) < 2d) return "EAST"; + if (Math.Abs(ep.Y - targetNode.Y) < 2d) return "NORTH"; + if (Math.Abs(ep.Y - (targetNode.Y + targetNode.Height)) < 2d) return "SOUTH"; + if (ep.X < targetNode.X + targetNode.Width / 2d) return "WEST"; + return "EAST"; + }); + + foreach (var sideGroup in sideGroups) + { + var items = sideGroup.OrderBy(x => + { + var sec = x.Edge.Sections.Last(); + var ep = sec.EndPoint; + return sideGroup.Key is "WEST" or "EAST" ? ep.Y : ep.X; + }).ToArray(); + + if (items.Length < 2) + { + continue; + } + + var hasOverlap = false; + for (var i = 1; i < items.Length; i++) + { + var prevEp = items[i - 1].Edge.Sections.Last().EndPoint; + var currEp = items[i].Edge.Sections.Last().EndPoint; + if (Math.Abs(prevEp.X - currEp.X) < 4d && Math.Abs(prevEp.Y - currEp.Y) < 4d) + { + hasOverlap = true; + break; + } + } + + if (!hasOverlap) + { + continue; + } + + var isVerticalSide = sideGroup.Key is "WEST" or "EAST"; + var sideExtent = isVerticalSide ? targetNode.Height : targetNode.Width; + var spacing = Math.Min(20d, (sideExtent - 12d) / Math.Max(1, items.Length)); + var totalSpread = (items.Length - 1) * spacing; + var startOffset = (sideExtent / 2d) - (totalSpread / 2d); + + for (var i = 0; i < items.Length; i++) + { + var edge = items[i].Edge; + var edgeIdx = items[i].Index; + var lastSection = edge.Sections.Last(); + var oldEnd = lastSection.EndPoint; + var offset = startOffset + (i * spacing); + + ElkPoint newEnd; + if (isVerticalSide) + { + newEnd = new ElkPoint { X = oldEnd.X, Y = targetNode.Y + offset }; + } + else + { + newEnd = new ElkPoint { X = targetNode.X + offset, Y = oldEnd.Y }; + } + + newEnd = new ElkPoint + { + X = Clamp(newEnd.X, targetNode.X, targetNode.X + targetNode.Width), + Y = Clamp(newEnd.Y, targetNode.Y + 6d, targetNode.Y + targetNode.Height - 6d), + }; + + var newBendPoints = lastSection.BendPoints.ToList(); + if (newBendPoints.Count > 0) + { + var lastBend = newBendPoints[^1]; + if (isVerticalSide && Math.Abs(lastBend.X - oldEnd.X) < 2d) + { + newBendPoints[^1] = new ElkPoint { X = lastBend.X, Y = newEnd.Y }; + } + else if (!isVerticalSide && Math.Abs(lastBend.Y - oldEnd.Y) < 2d) + { + newBendPoints[^1] = new ElkPoint { X = newEnd.X, Y = lastBend.Y }; + } + } + + var newSections = edge.Sections.ToList(); + newSections[^1] = new ElkEdgeSection + { + StartPoint = lastSection.StartPoint, + EndPoint = newEnd, + BendPoints = newBendPoints.ToArray(), + }; + + result[edgeIdx] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Label = edge.Label, + Sections = newSections, + }; + } + } + } + + return result; + } + + private static ElkRoutedEdge[] SimplifyEdgePaths( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + var obstacles = nodes.Select(n => (L: n.X - 4d, T: n.Y - 4d, R: n.X + n.Width + 4d, B: n.Y + n.Height + 4d, Id: n.Id)).ToArray(); + var result = new ElkRoutedEdge[edges.Length]; + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + var excludeIds = new HashSet(StringComparer.Ordinal) { edge.SourceNodeId ?? "", edge.TargetNodeId ?? "" }; + var anyChanged = false; + var newSections = new List(edge.Sections.Count); + + foreach (var section in edge.Sections) + { + var pts = new List { section.StartPoint }; + pts.AddRange(section.BendPoints); + pts.Add(section.EndPoint); + + // Pass 1: Remove collinear points + var cleaned = new List { pts[0] }; + for (var j = 1; j < pts.Count - 1; j++) + { + var prev = cleaned[^1]; + var curr = pts[j]; + var next = pts[j + 1]; + var sameX = Math.Abs(prev.X - curr.X) < 1d && Math.Abs(curr.X - next.X) < 1d; + var sameY = Math.Abs(prev.Y - curr.Y) < 1d && Math.Abs(curr.Y - next.Y) < 1d; + if (!sameX && !sameY) + { + cleaned.Add(curr); + } + } + cleaned.Add(pts[^1]); + + // Pass 2: Try L-shape shortcuts for each triple + var changed = true; + while (changed) + { + changed = false; + for (var j = 0; j + 2 < cleaned.Count; j++) + { + var a = cleaned[j]; + var c = cleaned[j + 2]; + var corner1 = new ElkPoint { X = a.X, Y = c.Y }; + var corner2 = new ElkPoint { X = c.X, Y = a.Y }; + + foreach (var corner in new[] { corner1, corner2 }) + { + if (SegmentClearsObstacles(a, corner, obstacles, excludeIds) + && SegmentClearsObstacles(corner, c, obstacles, excludeIds)) + { + cleaned[j + 1] = corner; + changed = true; + anyChanged = true; + break; + } + } + } + } + + newSections.Add(new ElkEdgeSection + { + StartPoint = cleaned[0], + EndPoint = cleaned[^1], + BendPoints = cleaned.Skip(1).Take(cleaned.Count - 2).ToArray(), + }); + } + + result[i] = anyChanged + ? new ElkRoutedEdge { Id = edge.Id, SourceNodeId = edge.SourceNodeId, TargetNodeId = edge.TargetNodeId, Label = edge.Label, Sections = newSections } + : edge; + } + return result; + } + + private static bool SegmentClearsObstacles( + ElkPoint p1, ElkPoint p2, + (double L, double T, double R, double B, string Id)[] obstacles, + HashSet excludeIds) + { + var isH = Math.Abs(p1.Y - p2.Y) < 1d; + var isV = Math.Abs(p1.X - p2.X) < 1d; + if (!isH && !isV) return true; + + foreach (var ob in obstacles) + { + if (excludeIds.Contains(ob.Id)) continue; + if (isH && p1.Y > ob.T && p1.Y < ob.B) + { + if (Math.Max(p1.X, p2.X) > ob.L && Math.Min(p1.X, p2.X) < ob.R) return false; + } + else if (isV && p1.X > ob.L && p1.X < ob.R) + { + if (Math.Max(p1.Y, p2.Y) > ob.T && Math.Min(p1.Y, p2.Y) < ob.B) return false; + } + } + return true; + } + + private static ElkRoutedEdge[] SnapAnchorsToNodeBoundary( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var result = new ElkRoutedEdge[edges.Length]; + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + var anyChanged = false; + var newSections = edge.Sections.ToList(); + + for (var s = 0; s < newSections.Count; s++) + { + var section = newSections[s]; + var startFixed = false; + var endFixed = false; + var newStart = section.StartPoint; + var newEnd = section.EndPoint; + + if (edge.SourceNodeId is not null && nodesById.TryGetValue(edge.SourceNodeId, out var srcNode) && s == 0) + { + if (newStart.X > srcNode.X + 1d && newStart.X < srcNode.X + srcNode.Width - 1d + && newStart.Y > srcNode.Y + 1d && newStart.Y < srcNode.Y + srcNode.Height - 1d) + { + var target = section.BendPoints.Count > 0 ? section.BendPoints.First() : section.EndPoint; + newStart = ProjectOntoRectBoundary(srcNode, target); + startFixed = true; + } + } + + if (edge.TargetNodeId is not null && nodesById.TryGetValue(edge.TargetNodeId, out var tgtNode) && s == newSections.Count - 1) + { + if (newEnd.X > tgtNode.X + 1d && newEnd.X < tgtNode.X + tgtNode.Width - 1d + && newEnd.Y > tgtNode.Y + 1d && newEnd.Y < tgtNode.Y + tgtNode.Height - 1d) + { + var source = section.BendPoints.Count > 0 ? section.BendPoints.Last() : section.StartPoint; + newEnd = ProjectOntoRectBoundary(tgtNode, source); + endFixed = true; + } + } + + if (startFixed || endFixed) + { + anyChanged = true; + newSections[s] = new ElkEdgeSection + { + StartPoint = newStart, + EndPoint = newEnd, + BendPoints = section.BendPoints, + }; + } + } + + result[i] = anyChanged + ? new ElkRoutedEdge { Id = edge.Id, SourceNodeId = edge.SourceNodeId, TargetNodeId = edge.TargetNodeId, Label = edge.Label, Sections = newSections } + : edge; + } + return result; + } + + private static ElkPoint ProjectOntoRectBoundary(ElkPositionedNode node, ElkPoint toward) + { + var cx = node.X + node.Width / 2d; + var cy = node.Y + node.Height / 2d; + var hw = node.Width / 2d; + var hh = node.Height / 2d; + var dx = toward.X - cx; + var dy = toward.Y - cy; + + if (Math.Abs(dx) < 0.1d && Math.Abs(dy) < 0.1d) + { + return new ElkPoint { X = cx + hw, Y = cy }; + } + + var tMin = double.MaxValue; + if (dx > 0.1d) { var t = hw / dx; if (Math.Abs(dy * t) <= hh + 0.1d && t < tMin) tMin = t; } + if (dx < -0.1d) { var t = -hw / dx; if (Math.Abs(dy * t) <= hh + 0.1d && t < tMin) tMin = t; } + if (dy > 0.1d) { var t = hh / dy; if (Math.Abs(dx * t) <= hw + 0.1d && t < tMin) tMin = t; } + if (dy < -0.1d) { var t = -hh / dy; if (Math.Abs(dx * t) <= hw + 0.1d && t < tMin) tMin = t; } + + return tMin < double.MaxValue + ? new ElkPoint { X = cx + dx * tMin, Y = cy + dy * tMin } + : new ElkPoint { X = cx + hw, Y = cy }; + } + + private static ElkRoutedEdge[] TightenOuterCorridors( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + if (nodes.Length == 0) return edges; + + var graphMinY = nodes.Min(n => n.Y); + var graphMaxY = nodes.Max(n => n.Y + n.Height); + const double minMargin = 16d; + const double laneGap = 10d; + + var outerEdges = new List<(int Index, double CorridorY, bool IsAbove)>(); + for (var i = 0; i < edges.Length; i++) + { + foreach (var section in edges[i].Sections) + { + var pts = new List { section.StartPoint }; + pts.AddRange(section.BendPoints); + pts.Add(section.EndPoint); + foreach (var pt in pts) + { + if (pt.Y < graphMinY - 8d) + { + outerEdges.Add((i, pt.Y, true)); + break; + } + if (pt.Y > graphMaxY + 8d) + { + outerEdges.Add((i, pt.Y, false)); + break; + } + } + } + } + + if (outerEdges.Count == 0) return edges; + + var aboveEdges = outerEdges.Where(e => e.IsAbove).OrderBy(e => e.CorridorY).ToArray(); + var belowEdges = outerEdges.Where(e => !e.IsAbove).OrderByDescending(e => e.CorridorY).ToArray(); + + var result = edges.ToArray(); + var shifts = new Dictionary(); + + for (var lane = 0; lane < aboveEdges.Length; lane++) + { + var targetY = graphMinY - minMargin - (lane * laneGap); + var currentY = aboveEdges[lane].CorridorY; + var shift = targetY - currentY; + if (Math.Abs(shift) > 2d) + { + shifts[aboveEdges[lane].Index] = shift; + } + } + + for (var lane = 0; lane < belowEdges.Length; lane++) + { + var targetY = graphMaxY + minMargin + (lane * laneGap); + var currentY = belowEdges[lane].CorridorY; + var shift = targetY - currentY; + if (Math.Abs(shift) > 2d) + { + shifts[belowEdges[lane].Index] = shift; + } + } + + foreach (var (edgeIndex, shift) in shifts) + { + var edge = result[edgeIndex]; + var boundary = shift > 0 ? graphMaxY : graphMinY; + var newSections = new List(); + foreach (var section in edge.Sections) + { + var newBendPoints = section.BendPoints.Select(bp => + { + if ((shift < 0 && bp.Y < graphMinY - 4d) || (shift > 0 && bp.Y > graphMaxY + 4d)) + { + return new ElkPoint { X = bp.X, Y = bp.Y + shift }; + } + return bp; + }).ToArray(); + + newSections.Add(new ElkEdgeSection + { + StartPoint = section.StartPoint, + EndPoint = section.EndPoint, + BendPoints = newBendPoints, + }); + } + + result[edgeIndex] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Label = edge.Label, + Sections = newSections, + }; + } + + return result; + } + + private static double Clamp(double value, double minimum, double maximum) + { + return Math.Min(Math.Max(value, minimum), maximum); + } + + private enum EdgeRouteMode + { + Direct = 0, + BackwardOuter = 1, + SinkOuter = 2, + SinkOuterTop = 3, + } + + private readonly record struct GraphBounds(double MinX, double MinY, double MaxX, double MaxY); + + private readonly record struct LayerBoundary(double MinX, double MaxX, double MinY, double MaxY); + + private readonly record struct EdgeChannel( + EdgeRouteMode RouteMode, + int BackwardLane, + int BackwardTargetIndex, + int BackwardTargetCount, + int ForwardIndex, + int ForwardCount, + int TargetIncomingIndex, + int TargetIncomingCount, + int SinkBandIndex, + int SinkBandCount, + double SharedOuterX, + double PreferredOuterY, + bool UseSourceCollector, + double PreferredDirectChannelX); + + private readonly record struct DirectChannelCandidate( + string EdgeId, + string GapKey, + double GapMinX, + double GapMaxX, + int FamilyPriority, + double SourceCenterY, + double TargetCenterY, + double TargetX); + + private static string NormalizeSide(string? side, ElkLayoutDirection direction) + { + if (string.IsNullOrWhiteSpace(side)) + { + return direction == ElkLayoutDirection.LeftToRight ? "EAST" : "SOUTH"; + } + + return side.Trim().ToUpperInvariant() switch + { + "LEFT" => "WEST", + "RIGHT" => "EAST", + "TOP" => "NORTH", + "BOTTOM" => "SOUTH", + var normalized => normalized, + }; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkSharpSourceAnalyzer.cs b/src/__Libraries/StellaOps.ElkSharp/ElkSharpSourceAnalyzer.cs new file mode 100644 index 000000000..a680d5aff --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkSharpSourceAnalyzer.cs @@ -0,0 +1,85 @@ +using System.Text.RegularExpressions; + +namespace StellaOps.ElkSharp; + +public sealed record ElkSharpSourceProfile +{ + public required string SourceName { get; init; } + public int LineCount { get; init; } + public int CharacterCount { get; init; } + public int FunctionCount { get; init; } + public int DefineClassCount { get; init; } + public int CreateForClassCount { get; init; } + public int InternConstantCount { get; init; } + public int LayoutCommandCount { get; init; } + public int RegisterAlgorithmCount { get; init; } +} + +public static partial class ElkSharpSourceAnalyzer +{ + [GeneratedRegex(@"(?m)^\s*function\s+[A-Za-z0-9_$]+\s*\(", RegexOptions.CultureInvariant)] + private static partial Regex FunctionRegex(); + + [GeneratedRegex(@"defineClass\(", RegexOptions.CultureInvariant)] + private static partial Regex DefineClassRegex(); + + [GeneratedRegex(@"createForClass\(", RegexOptions.CultureInvariant)] + private static partial Regex CreateForClassRegex(); + + [GeneratedRegex(@"\$intern_", RegexOptions.CultureInvariant)] + private static partial Regex InternConstantRegex(); + + [GeneratedRegex(@"cmd:\s*'layout'|layout_[0-9]+\(", RegexOptions.CultureInvariant)] + private static partial Regex LayoutCommandRegex(); + + [GeneratedRegex(@"registerLayoutAlgorithms\(", RegexOptions.CultureInvariant)] + private static partial Regex RegisterAlgorithmRegex(); + + public static ElkSharpSourceProfile Analyze(string sourceName, string sourceText) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceName); + ArgumentNullException.ThrowIfNull(sourceText); + + return new ElkSharpSourceProfile + { + SourceName = sourceName, + LineCount = CountLines(sourceText), + CharacterCount = sourceText.Length, + FunctionCount = FunctionRegex().Matches(sourceText).Count, + DefineClassCount = DefineClassRegex().Matches(sourceText).Count, + CreateForClassCount = CreateForClassRegex().Matches(sourceText).Count, + InternConstantCount = InternConstantRegex().Matches(sourceText).Count, + LayoutCommandCount = LayoutCommandRegex().Matches(sourceText).Count, + RegisterAlgorithmCount = RegisterAlgorithmRegex().Matches(sourceText).Count, + }; + } + + public static async Task AnalyzeFileAsync( + string path, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var sourceText = await File.ReadAllTextAsync(path, cancellationToken); + return Analyze(Path.GetFileName(path), sourceText); + } + + private static int CountLines(string text) + { + if (text.Length == 0) + { + return 0; + } + + var lines = 1; + foreach (var character in text) + { + if (character == '\n') + { + ++lines; + } + } + + return lines; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.csproj b/src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.csproj new file mode 100644 index 000000000..1a44af860 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.csproj @@ -0,0 +1,9 @@ + + + net10.0 + enable + enable + + $(NoWarn);CS8601;CS8602;CS8604 + +