diff --git a/Monorepo/CrossModuleTests/Fixture/Flow/CrossModuleFlowScenarioCatalogue.php b/Monorepo/CrossModuleTests/Fixture/Flow/CrossModuleFlowScenarioCatalogue.php new file mode 100644 index 0000000000..f777f9cba9 --- /dev/null +++ b/Monorepo/CrossModuleTests/Fixture/Flow/CrossModuleFlowScenarioCatalogue.php @@ -0,0 +1,35 @@ + + */ + public static function dataProvider(): iterable + { + foreach (self::all() as $scenario) { + yield $scenario->name => [$scenario]; + } + } + + /** + * @return list + */ + public static function all(): array + { + return [ + ...DbalFlowScenarios::all(), + ...EventSourcingFlowScenarios::all(), + ...MultiTenantFlowScenarios::all(), + ]; + } +} diff --git a/Monorepo/CrossModuleTests/Fixture/Flow/DbalFlowScenarios.php b/Monorepo/CrossModuleTests/Fixture/Flow/DbalFlowScenarios.php new file mode 100644 index 0000000000..58e0c534c3 --- /dev/null +++ b/Monorepo/CrossModuleTests/Fixture/Flow/DbalFlowScenarios.php @@ -0,0 +1,300 @@ + + */ + public static function all(): array + { + return [ + self::commandConsumedFromADatabaseChannel(), + self::failedMessageStoredInTheDeadLetterAndReplayed(), + self::duplicatedCommandHandledOnce(), + self::commandForwardedFromTheOutbox(), + self::commandsOfTwoFlowsForwardedFromTheOutboxInOneHighThroughputBatch(), + self::commandsOfOneFlowForwardedFromTheOutboxInOneHighThroughputBatch(), + ]; + } + + private static function commandConsumedFromADatabaseChannel(): FlowScenario + { + $orders = new class () { + #[Asynchronous('orders')] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + } + }; + + return new FlowScenario('command consumed from a database channel', [$orders::class], [$orders], [DbalBackedMessageChannelBuilder::create('orders')], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect(ExpectedSpan::named('send command')->containing(ExpectedSpan::named('send orders')->ofKind(SpanKind::Producer))) + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('send orders')->containing( + ExpectedSpan::named('receive orders')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process class@anonymous::place')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placing']), + ), + ), + ), + ]); + } + + private static function failedMessageStoredInTheDeadLetterAndReplayed(): FlowScenario + { + $orders = new class () { + private int $attempts = 0; + + #[Asynchronous('orders')] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + if (++$this->attempts <= 2) { + throw new RuntimeException('Payment provider unavailable'); + } + } + }; + + return new FlowScenario('failed message stored in the dead letter and replayed', [$orders::class], [$orders], [ + DbalBackedMessageChannelBuilder::create('orders'), + ErrorHandlerConfiguration::createWithDeadLetterChannel('errorChannel', RetryTemplateBuilder::fixedBackOff(0)->maxRetries(1), DbalDeadLetterBuilder::STORE_CHANNEL), + InstantRetryConfiguration::createWithDefaults()->withAsynchronousEndpointsRetry(false), + ServiceConfiguration::createWithDefaults()->withDefaultErrorChannel('errorChannel'), + ], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect(ExpectedSpan::named('send command')->containing(ExpectedSpan::named('send orders'))) + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup(stopOnError: false))) + ->expect( + ExpectedSpan::named('receive orders')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('process class@anonymous::place')->withStatus(SpanStatus::Error, 'Payment provider unavailable'), + ExpectedSpan::named('send orders')->containing( + ExpectedSpan::named('receive orders')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('process class@anonymous::place')->withStatus(SpanStatus::Error), + ), + ), + ), + ), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->getGateway(DeadLetterGateway::class)->replayAll()) + ->expect( + ExpectedSpan::named('receive orders')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('send orders')->containing( + ExpectedSpan::named('receive orders')->withStatus(SpanStatus::Error), + ExpectedSpan::named('send orders')->withStatus(SpanStatus::Ok), + ), + ), + ) + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('send orders')->withStatus(SpanStatus::Ok)->containing( + ExpectedSpan::named('receive orders')->withStatus(SpanStatus::Ok)->containing( + ExpectedSpan::named('process class@anonymous::place')->withStatus(SpanStatus::Ok)->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placing']), + ), + ), + ), + ]); + } + + private static function commandForwardedFromTheOutbox(): FlowScenario + { + $orders = new class () { + #[Asynchronous('orders')] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + } + }; + + return new FlowScenario('command forwarded from the outbox', [$orders::class], [$orders], [ + OutboxForwardingMessageChannel::create('orders', DbalBackedMessageChannelBuilder::create('outbox'), 'order_processing'), + DbalBackedMessageChannelBuilder::create('order_processing'), + ], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect(ExpectedSpan::named('send command')->containing(ExpectedSpan::named('send outbox')->ofKind(SpanKind::Producer))) + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('outbox', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('send command')->asRoot()->containing( + ExpectedSpan::named('send outbox')->containing(ExpectedSpan::named('send order_processing')->ofKind(SpanKind::Producer)), + ), + ) + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('order_processing', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('send order_processing')->containing( + ExpectedSpan::named('receive order_processing')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process class@anonymous::place')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placing']), + ), + ), + ), + ]); + } + + private static function commandsOfTwoFlowsForwardedFromTheOutboxInOneHighThroughputBatch(): FlowScenario + { + $orders = self::placingAndCancellingOrders(); + $sent = fn (string $routingKey, array $trackingAttributes) => ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => $routingKey, ...$trackingAttributes])->containing( + ExpectedSpan::named('send outbox'), + ); + $forwarded = fn (string $routingKey, ExpectedSpan $relay) => ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => $routingKey])->containing( + ExpectedSpan::named('send outbox')->containing($relay), + ); + $handled = fn (string $method) => ExpectedSpan::named('receive order_processing')->containing(ExpectedSpan::named('process class@anonymous::' . $method)); + + return new FlowScenario('commands of two flows forwarded from the outbox in one high-throughput batch', [$orders::class], [$orders], self::outboxForwardingInBatches(), [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: [...$metadata, MessageHeaders::TRACEPARENT => '00-' . bin2hex(random_bytes(16)) . '-' . bin2hex(random_bytes(8)) . '-01'])) + ->expect($sent('order.place', [SpanAttribute::TRACE_CARRIED => true])) + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.cancel', '1', metadata: $metadata)) + ->expect($sent('order.cancel', [])->withoutAttributes(SpanAttribute::TRACE_CARRIED)) + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('outbox', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + $forwarded('order.place', ExpectedSpan::named('send order_processing')->ofKind(SpanKind::Producer)->withAttributes([SpanAttribute::TRACE_CARRIED => true])), + $forwarded('order.cancel', ExpectedSpan::named('send order_processing')->ofKind(SpanKind::Producer)->withoutAttributes(SpanAttribute::TRACE_CARRIED)), + ) + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('order_processing', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + $forwarded('order.place', ExpectedSpan::named('send order_processing')->containing($handled('place'))), + $forwarded('order.cancel', ExpectedSpan::named('send order_processing')->containing($handled('cancel'))), + ), + ]); + } + + private static function commandsOfOneFlowForwardedFromTheOutboxInOneHighThroughputBatch(): FlowScenario + { + $orders = self::placingAndCancellingOrders(); + $replacing = new class () { + #[Asynchronous('replacements', [new WithoutMessageCollector()])] + #[CommandHandler('order.replace', endpointId: 'order.replacing')] + public function replace(string $orderId, CommandBus $commandBus): void + { + $commandBus->sendWithRouting('order.cancel', $orderId); + $commandBus->sendWithRouting('order.place', $orderId); + } + }; + + $handledAfter = fn (string $routingKey, string $method) => ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => $routingKey])->containing( + ExpectedSpan::named('send outbox')->containing( + ExpectedSpan::named('send order_processing')->containing( + ExpectedSpan::named('receive order_processing')->containing(ExpectedSpan::named('process class@anonymous::' . $method)), + ), + ), + ); + + return new FlowScenario('commands of one flow forwarded from the outbox in one high-throughput batch', [$orders::class, $replacing::class], [$orders, $replacing], [SimpleMessageChannelBuilder::createQueueChannel('replacements'), ...self::outboxForwardingInBatches()], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.replace', '1', metadata: $metadata)) + ->expect(ExpectedSpan::named('send command')->asRoot()->containing(ExpectedSpan::named('send replacements'))) + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('replacements', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('receive replacements')->containing( + ExpectedSpan::named('process class@anonymous::replace')->containing( + ExpectedSpan::named('send command')->containing(ExpectedSpan::named('send outbox')), + ExpectedSpan::named('send command')->containing(ExpectedSpan::named('send outbox')), + ), + ), + ) + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('outbox', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect(ExpectedSpan::named('send order_processing')->ofKind(SpanKind::Producer)) + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('order_processing', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('process class@anonymous::replace')->containing( + $handledAfter('order.cancel', 'cancel'), + $handledAfter('order.place', 'place'), + ), + ), + ]); + } + + private static function placingAndCancellingOrders(): object + { + return new class () { + #[Asynchronous('orders')] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + } + + #[Asynchronous('orders')] + #[CommandHandler('order.cancel', endpointId: 'order.cancelling')] + public function cancel(string $orderId): void + { + } + }; + } + + /** + * @return list + */ + private static function outboxForwardingInBatches(): array + { + return [ + OutboxForwardingMessageChannel::create('orders', DbalBackedMessageChannelBuilder::create('outbox'), 'order_processing'), + DbalBackedMessageChannelBuilder::create('order_processing')->withHighThroughputPublishing(), + ]; + } + + private static function duplicatedCommandHandledOnce(): FlowScenario + { + $orders = new class () { + #[Deduplicated] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(): void + { + } + }; + $messageId = Uuid::v7()->toRfc4122(); + + return new FlowScenario('duplicated command handled once', [$orders::class], [$orders], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', metadata: [...$metadata, MessageHeaders::MESSAGE_ID => $messageId])) + ->expect(ExpectedSpan::named('send command')->containing(ExpectedSpan::named('process class@anonymous::place')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placing']))) + ->withNoOtherSpans(), + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', metadata: [...$metadata, MessageHeaders::MESSAGE_ID => $messageId])) + ->expect(ExpectedSpan::named('send command')->asRoot()->withStatus(SpanStatus::Ok)) + ->withNoOtherSpans(), + ]); + } +} diff --git a/Monorepo/CrossModuleTests/Fixture/Flow/EventSourcingFlowScenarios.php b/Monorepo/CrossModuleTests/Fixture/Flow/EventSourcingFlowScenarios.php new file mode 100644 index 0000000000..3f4136a538 --- /dev/null +++ b/Monorepo/CrossModuleTests/Fixture/Flow/EventSourcingFlowScenarios.php @@ -0,0 +1,126 @@ + + */ + public static function all(): array + { + return [ + self::projectionOnTheDatabaseEventStoreRebuilt(), + self::partitionedProjectionRebuiltThroughADatabaseChannel(), + self::projectionDeletedInitializedAndBackfilledFromTheDatabaseEventStore(), + ]; + } + + private static function projectionOnTheDatabaseEventStoreRebuilt(): FlowScenario + { + $projection = new #[Projection('order_list'), FromAggregateStream(FlowOrder::class)] class () { + #[EventHandler(endpointId: 'order_list.placed')] + public function whenPlaced(FlowOrderPlaced $event): void + { + } + }; + + return new FlowScenario('projection on the database event store rebuilt from the console', [FlowOrder::class, FlowOrderPlaced::class, $projection::class], [$projection], [], [ + self::orderPlaced(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->runConsoleCommand('ecotone:projection:rebuild', ['name' => 'order_list'])) + ->expect(self::operation('rebuild')->containing(self::projectedOrder())), + ]); + } + + private static function partitionedProjectionRebuiltThroughADatabaseChannel(): FlowScenario + { + $projection = new #[Projection('order_list'), Partitioned, FromAggregateStream(FlowOrder::class), ProjectionRebuild(asyncChannelName: 'order_list_rebuild')] class () { + #[EventHandler(endpointId: 'order_list.placed')] + public function whenPlaced(FlowOrderPlaced $event): void + { + } + }; + + return new FlowScenario('partitioned projection rebuilt through a database channel', [FlowOrder::class, FlowOrderPlaced::class, $projection::class], [$projection], [DbalBackedMessageChannelBuilder::create('order_list_rebuild')], [ + self::orderPlaced(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->runConsoleCommand('ecotone:projection:rebuild', ['name' => 'order_list'])) + ->expect(self::operation('rebuild')->containing(ExpectedSpan::named('send order_list_rebuild')->ofKind(SpanKind::Producer))) + ->expectNoSpanNamed('receive order_list_rebuild') + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('order_list_rebuild', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + self::operation('rebuild')->containing( + ExpectedSpan::named('send order_list_rebuild')->containing( + ExpectedSpan::named('receive order_list_rebuild')->ofKind(SpanKind::Consumer)->containing(self::projectedOrder()), + ), + ), + ), + ]); + } + + private static function projectionDeletedInitializedAndBackfilledFromTheDatabaseEventStore(): FlowScenario + { + $projection = new #[Projection('order_list'), FromAggregateStream(FlowOrder::class)] class () { + #[EventHandler(endpointId: 'order_list.placed')] + public function whenPlaced(FlowOrderPlaced $event): void + { + } + }; + + return new FlowScenario('projection deleted, initialized and backfilled from the database event store', [FlowOrder::class, FlowOrderPlaced::class, $projection::class], [$projection], [], [ + self::orderPlaced(), + FlowStage::when(function (FlowTestSupport $ecotone): void { + $ecotone->deleteProjection('order_list'); + $ecotone->initializeProjection('order_list'); + $ecotone->runConsoleCommand('ecotone:projection:backfill', ['name' => 'order_list']); + }) + ->expect(self::operation('delete'), self::operation('initialize'), self::operation('backfill')->containing(self::projectedOrder())), + ]); + } + + private static function orderPlaced(): FlowStage + { + return FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('flow_order.place', ['orderId' => 'order-1'], metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->containing( + ExpectedSpan::named('process FlowOrder::place')->withAttributes([SpanAttribute::ENDPOINT_ID => 'flow_order.placing']), + ExpectedSpan::named('send event')->containing( + ExpectedSpan::named('route order_list')->containing( + ExpectedSpan::named('process order_list')->containing(self::projectedOrder()), + ), + ), + ), + ); + } + + private static function operation(string $operation): ExpectedSpan + { + return ExpectedSpan::named($operation . ' order_list')->asRoot()->ofKind(SpanKind::Server)->withAttributes([SpanAttribute::PROJECTION_NAME => 'order_list', SpanAttribute::PROJECTION_OPERATION => $operation]); + } + + private static function projectedOrder(): ExpectedSpan + { + return ExpectedSpan::named('process class@anonymous::whenPlaced')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order_list.placed']); + } +} diff --git a/Monorepo/CrossModuleTests/Fixture/Flow/MultiTenantFlowScenarios.php b/Monorepo/CrossModuleTests/Fixture/Flow/MultiTenantFlowScenarios.php new file mode 100644 index 0000000000..6f6da2454c --- /dev/null +++ b/Monorepo/CrossModuleTests/Fixture/Flow/MultiTenantFlowScenarios.php @@ -0,0 +1,74 @@ + + */ + public static function all(): array + { + return [ + self::commandsOfTwoTenantsConsumedFromTheirOwnDatabases(), + ]; + } + + private static function commandsOfTwoTenantsConsumedFromTheirOwnDatabases(): FlowScenario + { + $orders = new class () { + #[Asynchronous('orders')] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + } + }; + $sentFor = fn (string $tenant) => ExpectedSpan::named('send command')->asRoot()->withAttributes(['tenant' => $tenant])->containing( + ExpectedSpan::named('send orders')->ofKind(SpanKind::Producer), + ); + $handledFor = fn (string $tenant) => ExpectedSpan::named('send command')->asRoot()->withAttributes(['tenant' => $tenant])->containing( + ExpectedSpan::named('send orders')->containing( + ExpectedSpan::named('receive orders')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process class@anonymous::place')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placing']), + ), + ), + ); + + return new FlowScenario('commands of two tenants consumed from their own databases', [$orders::class], [ + $orders, + 'tenant_a_connection' => new DbalConnectionFactory(getenv('DATABASE_DSN') ?: 'pgsql://ecotone:secret@127.0.0.1:5432/ecotone'), + 'tenant_b_connection' => new DbalConnectionFactory(SecondaryTestDatabaseConnectionFactory::resolveDsn()), + ], [ + MultiTenantConfiguration::create('tenant', ['tenant_a' => 'tenant_a_connection', 'tenant_b' => 'tenant_b_connection']), + DbalBackedMessageChannelBuilder::create('orders'), + ], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: [...$metadata, 'tenant' => 'tenant_a'])) + ->expect($sentFor('tenant_a')) + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '2', metadata: [...$metadata, 'tenant' => 'tenant_b'])) + ->expect($sentFor('tenant_b')) + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect($handledFor('tenant_a'), $handledFor('tenant_b')), + ]); + } +} diff --git a/Monorepo/CrossModuleTests/Tests/Tracing/CrossModuleFlowConformanceTest.php b/Monorepo/CrossModuleTests/Tests/Tracing/CrossModuleFlowConformanceTest.php new file mode 100644 index 0000000000..6a7f6fad92 --- /dev/null +++ b/Monorepo/CrossModuleTests/Tests/Tracing/CrossModuleFlowConformanceTest.php @@ -0,0 +1,74 @@ +createContext()->getDbalConnection()); + EventSourcingMessagingTestCase::clearDataTables((new DbalConnectionFactory(SecondaryTestDatabaseConnectionFactory::resolveDsn()))->createContext()->getDbalConnection()); + } + + #[DataProviderExternal(CrossModuleFlowScenarioCatalogue::class, 'dataProvider')] + public function test_every_cross_module_flow_is_traced_exported_stored_and_drawn_alike(FlowScenario $scenario): void + { + $recorder = new InMemorySpanRecorder(); + $exporter = new InMemoryExporter(); + $ecotone = EcotoneLite::bootstrapFlowTesting( + $scenario->classesToResolve, + [ + ...$scenario->services, + InMemorySpanRecorder::class => $recorder, + TracerProviderInterface::class => new TracerProvider(new SimpleSpanProcessor($exporter)), + DbalConnectionFactory::class => new DbalConnectionFactory(getenv('DATABASE_DSN') ?: 'pgsql://ecotone:secret@127.0.0.1:5432/ecotone'), + ], + [ + FlowTrackingConfiguration::createWithDefaults(), + TracingConfiguration::createWithDefaults()->withSpanRecorder(InMemorySpanRecorder::class), + ...$scenario->extensionObjects, + ], + enabledModules: [ModulePackageList::DBAL_PACKAGE, ModulePackageList::EVENT_SOURCING_PACKAGE, ModulePackageList::JMS_CONVERTER_PACKAGE, ModulePackageList::TRACING_PACKAGE], + licenceKey: LicenceTesting::VALID_LICENCE, + )->setupDatabaseTables(); + $tracing = $ecotone->getGateway(TracingGateway::class); + + foreach (array_keys($scenario->stages) as $index) { + self::assertNull($scenario->runStage($index, $ecotone)); + + self::assertNull($scenario->mismatchAfterStage($index, $recorder->recordedSpans())); + self::assertNull($scenario->mismatchAfterStage($index, ExportedSpans::of($exporter))); + self::assertNull(StoredFlowVerifier::mismatchAfterStage($scenario, $index, $tracing, $recorder->recordedSpans())); + } + self::assertNull(MessageFlowGraphVerifier::mismatchIn($ecotone->getMessageFlowGraph(), $recorder->recordedSpans())); + } +} diff --git a/packages/Dbal/Api/ExtensionObject/DbalDeadLetterBuilder.php b/packages/Dbal/Api/ExtensionObject/DbalDeadLetterBuilder.php index 8550e56fc4..bb437a9814 100644 --- a/packages/Dbal/Api/ExtensionObject/DbalDeadLetterBuilder.php +++ b/packages/Dbal/Api/ExtensionObject/DbalDeadLetterBuilder.php @@ -5,6 +5,7 @@ use Ecotone\Api\Messaging\MessageHeaders; use Ecotone\Dbal\Recoverability\DeadLetterHandler; use Ecotone\Dbal\Recoverability\DeadLetterStore; +use Ecotone\Dbal\Tracing\FlowTrackingScope; use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\Container\InterfaceToCallReference; use Ecotone\Messaging\Config\Container\MessagingContainerBuilder; @@ -158,6 +159,7 @@ public function compile(MessagingContainerBuilder $builder): Definition $builder->register($deadLetterHandlerReference, new Definition(DeadLetterHandler::class, [ Reference::to(self::storeReferenceFor($this->referenceName)), Reference::to(MessageBuilder::class), + Reference::to(FlowTrackingScope::class), ])); } diff --git a/packages/Dbal/src/BatchForwarding/DbalBatchForwardingModule.php b/packages/Dbal/src/BatchForwarding/DbalBatchForwardingModule.php index 5240c33e37..e63ee2dbeb 100644 --- a/packages/Dbal/src/BatchForwarding/DbalBatchForwardingModule.php +++ b/packages/Dbal/src/BatchForwarding/DbalBatchForwardingModule.php @@ -13,6 +13,7 @@ use Ecotone\Api\ExtensionObject\ModulePackageList; use Ecotone\Api\Gateway\EcotoneClockInterface; use Ecotone\Dbal\DbalReconnectableConnectionFactory; +use Ecotone\Dbal\Tracing\FlowTrackingScope; use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Messaging\Config\Annotation\AnnotationModule; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ExtensionObjectResolver; @@ -134,6 +135,7 @@ private function createOutboxPublisherDefinition(DbalBackedMessageChannelBuilder $outboxForwardingChannel->getMaxForwardingBatchSize(), $outboxForwardingChannel->getFinalFailureStrategy() ?? $inboundChannelAdapter->getFinalFailureStrategy(), new Reference(MessageBuilder::class), + new Reference(FlowTrackingScope::class), ]); } } diff --git a/packages/Dbal/src/BatchForwarding/DbalOutboxPublisher.php b/packages/Dbal/src/BatchForwarding/DbalOutboxPublisher.php index 2554155191..8abbde3116 100644 --- a/packages/Dbal/src/BatchForwarding/DbalOutboxPublisher.php +++ b/packages/Dbal/src/BatchForwarding/DbalOutboxPublisher.php @@ -14,6 +14,7 @@ use Ecotone\Api\Messaging\MessageHeaders; use Ecotone\Dbal\Connection\DbalContext; use Ecotone\Dbal\Connection\DbalType; +use Ecotone\Dbal\Tracing\FlowTrackingScope; use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Messaging\Channel\BatchSupportingMessageChannel; use Ecotone\Messaging\Channel\MessageChannelInterceptorAdapter; @@ -59,10 +60,16 @@ public function __construct( private int $maxBatchSize, private FinalFailureStrategy $finalFailureStrategy, private MessageBuilder $messageBuilder, + private FlowTrackingScope $flowTracking, ) { } public function publishBatch(): int + { + return $this->flowTracking->publishingStepsAfterCommit(fn (): int => $this->forwardClaimedBatch()); + } + + private function forwardClaimedBatch(): int { /** @var DbalContext $context */ $context = $this->connectionFactory->createContext(); @@ -83,9 +90,11 @@ public function publishBatch(): int $deliveredRowIds = []; $releasedRowIds = []; - foreach ($this->groupByTargetChannel($drainedRecords, $releasedRowIds) as $groupTargetChannelName => $groupedRecords) { - $this->forwardGroup((string) $groupTargetChannelName, $groupedRecords, $deliveredRowIds, $releasedRowIds); - } + $this->flowTracking->storingSpansOf(function () use ($drainedRecords, &$deliveredRowIds, &$releasedRowIds): void { + foreach ($this->groupByTargetChannel($drainedRecords, $releasedRowIds) as $groupTargetChannelName => $groupedRecords) { + $this->forwardGroup((string) $groupTargetChannelName, $groupedRecords, $deliveredRowIds, $releasedRowIds); + } + }); $this->deleteRows($context, $connection, $deliveredRowIds); $this->releaseRows($context, $connection, $releasedRowIds); diff --git a/packages/Dbal/src/Recoverability/DeadLetterHandler.php b/packages/Dbal/src/Recoverability/DeadLetterHandler.php index 1b7194abf4..9ea22e7544 100644 --- a/packages/Dbal/src/Recoverability/DeadLetterHandler.php +++ b/packages/Dbal/src/Recoverability/DeadLetterHandler.php @@ -8,6 +8,7 @@ use Ecotone\Api\Messaging\Message; use Ecotone\Api\Messaging\MessageChannel; use Ecotone\Api\Messaging\MessageHeaders; +use Ecotone\Dbal\Tracing\FlowTrackingScope; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ErrorChannelExceptionMessages; use Ecotone\Messaging\Gateway\MessagingEntrypointService; use Ecotone\Messaging\Support\InvalidArgumentException; @@ -21,6 +22,7 @@ final class DeadLetterHandler public function __construct( private DeadLetterStore $deadLetterStore, private MessageBuilder $messageBuilder, + private FlowTrackingScope $flowTracking, ) { } @@ -124,7 +126,9 @@ private function replayMessage(string $messageId, MessagingEntrypointService $me ) ->build(); - $messagingEntrypoint->sendMessage($message); - $this->deadLetterStore->delete($messageId); + $this->flowTracking->publishingStepsAfterCommit(fn () => $this->flowTracking->storingSpansOf(function () use ($messagingEntrypoint, $message, $messageId): void { + $messagingEntrypoint->sendMessage($message); + $this->deadLetterStore->delete($messageId); + })); } } diff --git a/packages/Dbal/src/Test/StoredFlowVerifier.php b/packages/Dbal/src/Test/StoredFlowVerifier.php new file mode 100644 index 0000000000..33e26ea80a --- /dev/null +++ b/packages/Dbal/src/Test/StoredFlowVerifier.php @@ -0,0 +1,80 @@ + $metadata) { + $steps = [...$steps, ...($tracing->fetchFlow($traceId, $metadata)?->steps ?? [])]; + } + $mismatch = $scenario->asStoredByFlowTracking()->mismatchAfterStage($stageIndex, $steps); + if ($mismatch !== null) { + return $mismatch; + } + + $completed = array_map(fn (string $traceId): bool => $tracing->isCompletedFor($traceId, metadata: $metadataByTrace[$traceId]), array_keys($metadataByTrace)); + $finished = $scenario->isFinishedAfterStage($stageIndex); + if ($finished && in_array(false, $completed, true)) { + return "Stage {$stageIndex} leaves no work pending, yet a flow is not completed: " . self::pendingOf($tracing, $metadataByTrace); + } + if (! $finished && $completed !== [] && ! in_array(false, $completed, true)) { + return "Stage {$stageIndex} leaves work pending or failed, yet every flow is completed"; + } + + return null; + } + + /** + * @param Span[] $recordedSpans + * @return array> + */ + private static function flowMetadataByTrace(FlowScenario $scenario, array $recordedSpans): array + { + $tenantHeader = null; + foreach ($scenario->extensionObjects as $extensionObject) { + if ($extensionObject instanceof MultiTenantConfiguration) { + $tenantHeader = $extensionObject->tenantHeaderName; + } + } + $metadataByTrace = []; + foreach ($recordedSpans as $span) { + $metadataByTrace[$span->traceId] ??= []; + if ($tenantHeader !== null && isset($span->attributes[$tenantHeader])) { + $metadataByTrace[$span->traceId] = [$tenantHeader => (string) $span->attributes[$tenantHeader]]; + } + } + + return $metadataByTrace; + } + + /** + * @param array> $metadataByTrace + */ + private static function pendingOf(TracingGateway $tracing, array $metadataByTrace): string + { + return (string) json_encode(array_map(fn (string $traceId) => $tracing->fetchFlow($traceId, $metadataByTrace[$traceId])?->pending, array_keys($metadataByTrace))); + } +} diff --git a/packages/Dbal/src/Tracing/DbalTracingModule.php b/packages/Dbal/src/Tracing/DbalTracingModule.php index dded367b4f..da67987334 100644 --- a/packages/Dbal/src/Tracing/DbalTracingModule.php +++ b/packages/Dbal/src/Tracing/DbalTracingModule.php @@ -56,6 +56,7 @@ use Ecotone\Messaging\Handler\ServiceActivator\MessageProcessorActivatorBuilder; use Ecotone\Messaging\Handler\Type; use Ecotone\Messaging\Support\LicensingException; +use Ecotone\Projecting\ProjectionOperationEndpoint; use Ecotone\Tracing\Config\SpanRecorderReference; use Ecotone\Tracing\Tracer; @@ -120,6 +121,7 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO new Definition(SpansTableManager::class, [SpansTableManager::DEFAULT_TABLE, $storesOnDatabase, $consoleInvocationPrefix, $connectionReference]) ); if ($flowTracking === null) { + $messagingConfiguration->registerServiceDefinition(FlowTrackingScope::class, new Definition(UntrackedFlowScope::class)); self::registerTracingGateway($messagingConfiguration, UnavailableTracingGateway::class, new Definition(UnavailableTracingGateway::class, ['TracingGateway needs flow tracking, which records every flow and decides when it is complete. Register FlowTrackingConfiguration::createWithDefaults() from a #[ServiceContext] method.'])); return; @@ -143,13 +145,14 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO TraceRecordingInterceptor::class, new Definition(TraceRecordingInterceptor::class, [new Reference(StoringSpanRecorder::class), new Reference(Tracer::class), new Reference(MessagingEntrypointService::class), new Reference(LoggingGateway::class)]) ); + $messagingConfiguration->registerServiceDefinition(FlowTrackingScope::class, new Reference(TraceRecordingInterceptor::class)); foreach (['publishAfterCommit' => Precedence::TRACE_PUBLICATION_PRECEDENCE, 'recordUnitOfWork' => Precedence::TRACE_RECORDING_PRECEDENCE] as $method => $precedence) { $messagingConfiguration->registerAroundMethodInterceptor( AroundInterceptorBuilder::create( TraceRecordingInterceptor::class, $interfaceToCallRegistry->getFor(TraceRecordingInterceptor::class, $method), $precedence, - CommandBus::class . '||' . EventBus::class . '||' . QueryBus::class . '||' . DistributedBus::class . '||' . AsynchronousRunningEndpoint::class . '||' . OrchestratorGateway::class . '||(' . self::USER_MESSAGE_GATEWAYS_POINTCUT . ')' + CommandBus::class . '||' . EventBus::class . '||' . QueryBus::class . '||' . DistributedBus::class . '||' . AsynchronousRunningEndpoint::class . '||' . OrchestratorGateway::class . '||' . ProjectionOperationEndpoint::class . '||(' . self::USER_MESSAGE_GATEWAYS_POINTCUT . ')' ) ); } diff --git a/packages/Dbal/src/Tracing/FlowTrackingScope.php b/packages/Dbal/src/Tracing/FlowTrackingScope.php new file mode 100644 index 0000000000..971b327dbd --- /dev/null +++ b/packages/Dbal/src/Tracing/FlowTrackingScope.php @@ -0,0 +1,17 @@ +attributes[SpanAttribute::MESSAGING_OPERATION_TYPE] ?? null; + if ($destination === null || ($span->attributes[SpanAttribute::ROUTE_UNDELIVERED] ?? false) || ($span->attributes[SpanAttribute::ROUTE_COLLECTED] ?? false)) { continue; } - $operation = $span->attributes[SpanAttribute::MESSAGING_OPERATION_TYPE] ?? null; $messageId = (string) ($span->attributes[MessageHeaders::MESSAGE_ID] ?? $span->spanId); if ($operation === SpanAttribute::OPERATION_SEND) { $sent[$destination][$messageId] = true; diff --git a/packages/Dbal/src/Tracing/TraceRecordingInterceptor.php b/packages/Dbal/src/Tracing/TraceRecordingInterceptor.php index 8bc2fe94d1..20dc3de98d 100644 --- a/packages/Dbal/src/Tracing/TraceRecordingInterceptor.php +++ b/packages/Dbal/src/Tracing/TraceRecordingInterceptor.php @@ -4,6 +4,7 @@ namespace Ecotone\Dbal\Tracing; +use Closure; use Ecotone\Api\Dbal\Tracing\FlowStepCompleted; use Ecotone\Api\Dbal\Tracing\OnFlowStepCompleted; use Ecotone\Api\Interceptor\MethodInvocation; @@ -32,7 +33,7 @@ * * Changes belong here, as a pull request to github.com/ecotoneframework/ecotone-dev */ -final class TraceRecordingInterceptor +final class TraceRecordingInterceptor implements FlowTrackingScope { public function __construct( private StoringSpanRecorder $recorder, @@ -43,14 +44,24 @@ public function __construct( } public function publishAfterCommit(MethodInvocation $methodInvocation): mixed + { + return $this->publishingStepsAfterCommit(fn (): mixed => $methodInvocation->proceed()); + } + + public function recordUnitOfWork(MethodInvocation $methodInvocation): mixed + { + return $this->storingSpansOf(fn (): mixed => $methodInvocation->proceed()); + } + + public function publishingStepsAfterCommit(Closure $work): mixed { if ($this->recorder->isUnitOfWorkActive()) { - return $methodInvocation->proceed(); + return $work(); } $this->recorder->beginUnitOfWork(); try { - $result = $methodInvocation->proceed(); + $result = $work(); } catch (Throwable $failure) { $this->recorder->endUnitOfWork(); @@ -63,15 +74,15 @@ public function publishAfterCommit(MethodInvocation $methodInvocation): mixed return $result; } - public function recordUnitOfWork(MethodInvocation $methodInvocation): mixed + public function storingSpansOf(Closure $work): mixed { if ($this->recorder->isScopeActive()) { - return $methodInvocation->proceed(); + return $work(); } $this->recorder->openScope($this->tracer->currentSpan()); try { - $result = $methodInvocation->proceed(); + $result = $work(); $this->recorder->flushWithOpenSpans(); return $result; diff --git a/packages/Dbal/src/Tracing/UntrackedFlowScope.php b/packages/Dbal/src/Tracing/UntrackedFlowScope.php new file mode 100644 index 0000000000..70ad8bd739 --- /dev/null +++ b/packages/Dbal/src/Tracing/UntrackedFlowScope.php @@ -0,0 +1,23 @@ +name} [{$implementation}]" => [$scenario, $implementation]; + } + } + } + + #[DataProvider('scenariosOnEveryStore')] + public function test_every_catalogued_flow_stores_its_spans_except_failed_ones_and_completes_once_no_work_is_pending_and_nothing_failed(FlowScenario $scenario, string $implementation): void + { + $recorder = new InMemorySpanRecorder(); + $ecotone = $this->bootstrapFlowTesting( + classesToResolve: $scenario->classesToResolve, + containerOrAvailableServices: [...$scenario->services, InMemorySpanRecorder::class => $recorder, DbalConnectionFactory::class => $this->getConnectionFactory()], + extensionObjects: [ + FlowTrackingConfiguration::createWithDefaults()->withInMemoryStorage($implementation === 'in-memory'), + TracingConfiguration::createWithDefaults()->withSpanRecorder(InMemorySpanRecorder::class), + ...$scenario->extensionObjects, + ], + enabledModules: [ModulePackageList::DBAL_PACKAGE], + licenceKey: LicenceTesting::VALID_LICENCE, + )->setupDatabaseTables(); + $tracing = $ecotone->getGateway(TracingGateway::class); + + foreach (array_keys($scenario->stages) as $index) { + self::assertNull($scenario->runStage($index, $ecotone)); + + self::assertNull(StoredFlowVerifier::mismatchAfterStage($scenario, $index, $tracing, $recorder->recordedSpans())); + } + } +} diff --git a/packages/Ecotone/Api/ExtensionObject/DistributedServiceMap.php b/packages/Ecotone/Api/ExtensionObject/DistributedServiceMap.php index 9085c310fb..0d167434f3 100644 --- a/packages/Ecotone/Api/ExtensionObject/DistributedServiceMap.php +++ b/packages/Ecotone/Api/ExtensionObject/DistributedServiceMap.php @@ -33,7 +33,7 @@ final class DistributedServiceMap implements DefinedObject public function __construct( private readonly string $referenceName, public readonly array $commandMapping = [], - private readonly array $eventSubscriptions = [], + public readonly array $eventSubscriptions = [], public readonly array $distributedBusAnnotations = [], ) { Assert::allObjects($this->distributedBusAnnotations, 'Annotations passed to DistributedServiceMap, must all be objects'); diff --git a/packages/Ecotone/Api/Lite/Test/FlowTestSupport.php b/packages/Ecotone/Api/Lite/Test/FlowTestSupport.php index 164b772772..fa507798a5 100644 --- a/packages/Ecotone/Api/Lite/Test/FlowTestSupport.php +++ b/packages/Ecotone/Api/Lite/Test/FlowTestSupport.php @@ -17,6 +17,7 @@ use Ecotone\Api\Gateway\MessagePublisher; use Ecotone\Api\Gateway\QueryBus; use Ecotone\Api\Messaging\ConfiguredMessagingSystem; +use Ecotone\Api\Messaging\Flow\MessageFlowGraph; use Ecotone\Api\Messaging\Message; use Ecotone\Api\Messaging\MessageHeaders; use Ecotone\Api\Scheduling\DatePoint; @@ -32,7 +33,7 @@ use Ecotone\Modelling\AggregateMessage; use Ecotone\Modelling\Config\AggregateModule; use Ecotone\Modelling\Config\MessageBusChannel; -use Ecotone\Projecting\ProjectionRegistry; +use Ecotone\Projecting\ProjectionOperations; use Ecotone\Test\StaticPsrClock; use InvalidArgumentException; @@ -307,7 +308,7 @@ public function triggerProjection(string|array $projectionName): self Assert::allStrings($projectionName, '$projectionName must be single or collection of strings'); foreach ($projectionName as $name) { - $this->getGateway(ProjectionRegistry::class)->get($name)->executeAll(); + $this->getGateway(ProjectionOperations::class)->trigger($name); } return $this; @@ -315,21 +316,21 @@ public function triggerProjection(string|array $projectionName): self public function initializeProjection(string $projectionName): self { - $this->getGateway(ProjectionRegistry::class)->get($projectionName)->init(); + $this->getGateway(ProjectionOperations::class)->initialize($projectionName); return $this; } public function resetProjection(string $projectionName): self { - $this->getGateway(ProjectionRegistry::class)->get($projectionName)->executeAllWithReset(); + $this->getGateway(ProjectionOperations::class)->reset($projectionName); return $this; } public function deleteProjection(string $projectionName): self { - $this->getGateway(ProjectionRegistry::class)->get($projectionName)->delete(); + $this->getGateway(ProjectionOperations::class)->delete($projectionName); return $this; } @@ -527,6 +528,11 @@ public function getInMemoryConsoleWriter(): InMemoryConsoleWriter return $this->configuredMessagingSystem->getServiceFromContainer(InMemoryConsoleWriter::class); } + public function getMessageFlowGraph(): MessageFlowGraph + { + return $this->configuredMessagingSystem->getMessageFlowGraph(); + } + /** * @return string[] List of registered consumer endpoint IDs */ diff --git a/packages/Ecotone/Api/Messaging/ConfiguredMessagingSystem.php b/packages/Ecotone/Api/Messaging/ConfiguredMessagingSystem.php index 8d1a30a9d4..ff656a8663 100644 --- a/packages/Ecotone/Api/Messaging/ConfiguredMessagingSystem.php +++ b/packages/Ecotone/Api/Messaging/ConfiguredMessagingSystem.php @@ -8,6 +8,7 @@ use Ecotone\Api\Gateway\EventBus; use Ecotone\Api\Gateway\MessagePublisher; use Ecotone\Api\Gateway\QueryBus; +use Ecotone\Api\Messaging\Flow\MessageFlowGraph; use Ecotone\Messaging\Config\Container\GatewayProxyMethodReference; use Ecotone\Messaging\Config\Container\GatewayProxyReference; use Ecotone\Messaging\Handler\Gateway\Gateway; @@ -32,6 +33,8 @@ public function getGatewayByName(string $gatewayReferenceName): object; */ public function getGatewayList(): array; + public function getMessageFlowGraph(): MessageFlowGraph; + public function getNonProxyGatewayByName(GatewayProxyMethodReference $gatewayProxyMethodReference): Gateway; public function runConsoleCommand(string $commandName, array $parameters): mixed; diff --git a/packages/Ecotone/Api/Messaging/Flow/FlowEdge.php b/packages/Ecotone/Api/Messaging/Flow/FlowEdge.php new file mode 100644 index 0000000000..ac6dd2f860 --- /dev/null +++ b/packages/Ecotone/Api/Messaging/Flow/FlowEdge.php @@ -0,0 +1,45 @@ +|null> $attributes + */ + public function __construct( + public readonly string $from, + public readonly string $to, + public readonly FlowEdgeKind $kind, + public readonly array $attributes = [], + ) { + } + + /** + * @param array{from: string, to: string, kind: string, attributes: array} $data + */ + public static function fromArray(array $data): self + { + return new self($data['from'], $data['to'], FlowEdgeKind::from($data['kind']), $data['attributes']); + } + + public function isInferred(): bool + { + return ($this->attributes['inferred'] ?? false) === true; + } + + /** + * @return array{from: string, to: string, kind: string, attributes: array} + */ + public function toArray(): array + { + return ['from' => $this->from, 'to' => $this->to, 'kind' => $this->kind->value, 'attributes' => $this->attributes]; + } +} diff --git a/packages/Ecotone/Api/Messaging/Flow/FlowEdgeKind.php b/packages/Ecotone/Api/Messaging/Flow/FlowEdgeKind.php new file mode 100644 index 0000000000..9ee0602b18 --- /dev/null +++ b/packages/Ecotone/Api/Messaging/Flow/FlowEdgeKind.php @@ -0,0 +1,25 @@ +|null> $attributes + */ + public function __construct( + public readonly string $id, + public readonly FlowNodeKind $kind, + public readonly string $label, + public readonly array $attributes = [], + ) { + } + + /** + * @param array{id: string, kind: string, label: string, attributes: array} $data + */ + public static function fromArray(array $data): self + { + return new self($data['id'], FlowNodeKind::from($data['kind']), $data['label'], $data['attributes']); + } + + /** + * @param array|null> $attributes + */ + public function withAttributes(array $attributes): self + { + return new self($this->id, $this->kind, $this->label, array_merge($this->attributes, $attributes)); + } + + /** + * @return array{id: string, kind: string, label: string, attributes: array} + */ + public function toArray(): array + { + return ['id' => $this->id, 'kind' => $this->kind->value, 'label' => $this->label, 'attributes' => $this->attributes]; + } +} diff --git a/packages/Ecotone/Api/Messaging/Flow/FlowNodeKind.php b/packages/Ecotone/Api/Messaging/Flow/FlowNodeKind.php new file mode 100644 index 0000000000..5971241dfa --- /dev/null +++ b/packages/Ecotone/Api/Messaging/Flow/FlowNodeKind.php @@ -0,0 +1,48 @@ + $nodes + * @param list $edges + */ + private function __construct(private array $nodes, private array $edges) + { + } + + /** + * @param list $nodes + * @param list $edges + */ + public static function create(array $nodes, array $edges): self + { + $graph = new self([], []); + foreach ($nodes as $node) { + $graph->nodes[$node->id] = $node; + } + $seenEdges = []; + foreach ($edges as $edge) { + $graph->nodes[$edge->from] ?? throw InvalidArgumentException::create("Flow edge {$edge->kind->value} starts at `{$edge->from}`, which is not a node of this graph. Add the node first with MessageFlowGraph::create() or withNode()."); + $graph->nodes[$edge->to] ?? throw InvalidArgumentException::create("Flow edge {$edge->kind->value} ends at `{$edge->to}`, which is not a node of this graph. Add the node first with MessageFlowGraph::create() or withNode()."); + $key = $edge->kind->value . '|' . strlen($edge->from) . '|' . $edge->from . $edge->to . '|' . ($edge->attributes['targetService'] ?? ''); + if (! isset($seenEdges[$key])) { + $graph->edges[] = $edge; + $seenEdges[$key] = true; + } + } + + return $graph; + } + + /** + * @param array{nodes: list}>, edges: list}>} $data + */ + public static function fromArray(array $data): self + { + return self::create( + array_map(fn (array $node) => FlowNode::fromArray($node), $data['nodes']), + array_map(fn (array $edge) => FlowEdge::fromArray($edge), $data['edges']), + ); + } + + public static function fromJson(string $json): self + { + return self::fromArray(json_decode($json, true, flags: JSON_THROW_ON_ERROR)); + } + + public function hasNode(string $id): bool + { + return isset($this->nodes[$id]); + } + + public function node(string $id): FlowNode + { + return $this->nodes[$id] ?? throw InvalidArgumentException::create("Message flow graph has no node `{$id}`. Known nodes: " . implode(', ', array_keys($this->nodes)) . '. Check it with hasNode() first, or list the nodes of a kind with nodesOfKind().'); + } + + /** + * @return list + */ + public function nodes(): array + { + return array_values($this->nodes); + } + + /** + * @return list + */ + public function nodesOfKind(FlowNodeKind ...$kinds): array + { + return array_values(array_filter($this->nodes, fn (FlowNode $node) => in_array($node->kind, $kinds, true))); + } + + /** + * @return list + */ + public function edges(): array + { + return $this->edges; + } + + /** + * @return list + */ + public function edgesFrom(string $id): array + { + return array_values(array_filter($this->edges, fn (FlowEdge $edge) => $edge->from === $id)); + } + + /** + * @return list + */ + public function edgesTo(string $id): array + { + return array_values(array_filter($this->edges, fn (FlowEdge $edge) => $edge->to === $id)); + } + + /** + * @return list + */ + public function edgesOfKind(FlowEdgeKind $kind, ?string $from = null, ?string $to = null): array + { + return array_values(array_filter( + $this->edges, + fn (FlowEdge $edge) => $edge->kind === $kind && ($from === null || $edge->from === $from) && ($to === null || $edge->to === $to) + )); + } + + public function withNode(FlowNode $node): self + { + $nodes = $this->nodes; + $nodes[$node->id] = $node; + + return new self($nodes, $this->edges); + } + + public function withEdge(FlowEdge $edge): self + { + return self::create(array_values($this->nodes), [...$this->edges, $edge]); + } + + /** + * @return array{nodes: list}>, edges: list}>} + */ + public function toArray(): array + { + return [ + 'nodes' => array_map(fn (FlowNode $node) => $node->toArray(), array_values($this->nodes)), + 'edges' => array_map(fn (FlowEdge $edge) => $edge->toArray(), $this->edges), + ]; + } + + public function toJson(): string + { + return json_encode($this->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } + + public function toMermaid(): string + { + $lines = ['flowchart LR']; + $mermaidIds = []; + $index = 0; + foreach ($this->nodes as $node) { + $mermaidIds[$node->id] = 'n' . $index++; + } + + $ownedNodeIds = []; + foreach ($this->nodesOfKind(FlowNodeKind::AGGREGATE, FlowNodeKind::SAGA, FlowNodeKind::PROJECTION) as $owner) { + $members = array_values(array_filter($this->nodes, fn (FlowNode $node) => ($node->attributes['owner'] ?? null) === $owner->id)); + if ($members === []) { + $lines[] = ' ' . $this->mermaidNode($mermaidIds[$owner->id], $owner); + continue; + } + $lines[] = sprintf(' subgraph %s ["%s"]', $mermaidIds[$owner->id], $this->mermaidText($this->kindTitle($owner->kind) . ': ' . $owner->label)); + foreach ($members as $member) { + $lines[] = ' ' . $this->mermaidNode($mermaidIds[$member->id], $member); + $ownedNodeIds[$member->id] = true; + } + $lines[] = ' end'; + $ownedNodeIds[$owner->id] = true; + } + foreach ($this->nodes as $node) { + if (isset($ownedNodeIds[$node->id])) { + continue; + } + $lines[] = ' ' . $this->mermaidNode($mermaidIds[$node->id], $node); + } + $edgeLines = []; + foreach ($this->edges as $edge) { + $asynchronousChannels = $edge->kind === FlowEdgeKind::ROUTED_TO ? $this->edgesOfKind(FlowEdgeKind::ASYNCHRONOUS, to: $edge->to) : []; + if ($asynchronousChannels === []) { + $edgeLines[] = sprintf(' %s %s %s', $mermaidIds[$edge->from], $this->mermaidArrow($edge), $mermaidIds[$edge->to]); + + continue; + } + foreach ($asynchronousChannels as $asynchronousChannel) { + $edgeLines[] = sprintf(' %s %s %s', $mermaidIds[$edge->from], $this->mermaidArrow($edge), $mermaidIds[$asynchronousChannel->from]); + } + } + + return implode("\n", [...$lines, ...array_values(array_unique($edgeLines))]) . "\n"; + } + + public function getDefinition(): Definition + { + return new Definition(self::class, [json_encode($this->toArray(), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)], 'fromJson'); + } + + private function mermaidNode(string $mermaidId, FlowNode $node): string + { + $label = $this->mermaidText($node->kind->isHandler() || $node->kind === FlowNodeKind::INTERCEPTOR ? $this->kindTitle($node->kind) . '
' . $node->label : $node->label); + + return $mermaidId . match ($node->kind) { + FlowNodeKind::COMMAND => sprintf('>"%s"]', $label), + FlowNodeKind::EVENT => sprintf('(["%s"])', $label), + FlowNodeKind::QUERY => sprintf('{{"%s"}}', $label), + FlowNodeKind::CHANNEL => sprintf('[("Channel
%s")]', $label), + FlowNodeKind::GATEWAY => sprintf('[["%s"]]', $label), + FlowNodeKind::ROUTER, FlowNodeKind::ORCHESTRATOR => sprintf('{"%s"}', $label), + FlowNodeKind::SCHEDULED => sprintf('(("%s"))', $label), + FlowNodeKind::DISTRIBUTED_BUS => sprintf('[\\"%s"\\]', $label), + FlowNodeKind::AGGREGATE, FlowNodeKind::SAGA, FlowNodeKind::PROJECTION => sprintf('[/"%s"/]', $this->mermaidText($this->kindTitle($node->kind) . ': ' . $node->label)), + default => sprintf('["%s"]', $label), + }; + } + + private function mermaidArrow(FlowEdge $edge): string + { + $label = match ($edge->kind) { + FlowEdgeKind::EMITS => 'emits (' . ($edge->attributes['via'] ?? 'new') . ')', + FlowEdgeKind::ASYNCHRONOUS => 'async', + FlowEdgeKind::OUTPUT => 'output', + FlowEdgeKind::APPLIES => 'applies', + FlowEdgeKind::CALLS => 'calls', + FlowEdgeKind::ROUTES_TO => 'routes', + FlowEdgeKind::DISTRIBUTES => 'distributed', + FlowEdgeKind::INTERCEPTED_BY => 'intercepted by', + FlowEdgeKind::SENDS_TO => $edge->isInferred() ? 'sends (' . ($edge->attributes['via'] ?? '') . ')' : (isset($edge->attributes['targetService']) ? 'to ' . $edge->attributes['targetService'] : ''), + default => '', + }; + $labelPart = $label === '' ? '' : '|"' . $this->mermaidText($label) . '"|'; + if ($edge->kind === FlowEdgeKind::ASYNCHRONOUS) { + return '==>' . $labelPart; + } + + return ($edge->isInferred() ? '-.->' : '-->') . $labelPart; + } + + private function kindTitle(FlowNodeKind $kind): string + { + return match ($kind) { + FlowNodeKind::COMMAND_HANDLER => 'Command Handler', + FlowNodeKind::EVENT_HANDLER => 'Event Handler', + FlowNodeKind::QUERY_HANDLER => 'Query Handler', + FlowNodeKind::INTERNAL_HANDLER => 'Internal Handler', + FlowNodeKind::ROUTER => 'Router', + FlowNodeKind::AGGREGATE => 'Aggregate', + FlowNodeKind::SAGA => 'Saga', + FlowNodeKind::PROJECTION => 'Projection', + default => ucfirst($kind->value), + }; + } + + private function mermaidText(string $text): string + { + return str_replace(['"', '|'], ['#quot;', '#124;'], $text); + } +} diff --git a/packages/Ecotone/Api/Tracing/SpanAttribute.php b/packages/Ecotone/Api/Tracing/SpanAttribute.php index d793e5d808..4ba355e5d0 100644 --- a/packages/Ecotone/Api/Tracing/SpanAttribute.php +++ b/packages/Ecotone/Api/Tracing/SpanAttribute.php @@ -14,12 +14,15 @@ final class SpanAttribute public const MESSAGING_DESTINATION_NAME = 'messaging.destination.name'; public const ENDPOINT_ID = 'ecotone.endpoint.id'; public const PROJECTION_NAME = 'ecotone.projection.name'; + public const PROJECTION_OPERATION = 'ecotone.projection.operation'; public const TRACE_ROOT = 'ecotone.trace.root'; public const TRACE_CARRIED = 'ecotone.trace.carried'; public const TRACE_UNTRACKED = 'ecotone.trace.untracked'; public const CODE_FUNCTION_NAME = 'code.function.name'; public const CHANNEL_NAME = 'ecotone.channel.name'; public const BATCH_DELIVERIES = 'ecotone.batch.deliveries'; + public const ROUTE_UNDELIVERED = 'ecotone.route.undelivered'; + public const ROUTE_COLLECTED = 'ecotone.route.collected'; public const ECOTONE = 'ecotone'; public const OPERATION_SEND = 'send'; diff --git a/packages/Ecotone/src/AnnotationFinder/FileSystem/FileSystemAnnotationFinder.php b/packages/Ecotone/src/AnnotationFinder/FileSystem/FileSystemAnnotationFinder.php index 5a8468f5a1..81427c2c6d 100644 --- a/packages/Ecotone/src/AnnotationFinder/FileSystem/FileSystemAnnotationFinder.php +++ b/packages/Ecotone/src/AnnotationFinder/FileSystem/FileSystemAnnotationFinder.php @@ -569,7 +569,7 @@ public function getCacheMessagingFileNameBasedOnConfig( foreach ($this->registeredClasses() as $class) { $filePath = (new ReflectionClass($class))->getFileName(); - $fileSha .= $class . hash_file(self::FILE_CONTENT_HASH_ALGORITHM, $filePath); + $fileSha .= $class . ($filePath !== false && is_file($filePath) && is_readable($filePath) ? hash_file(self::FILE_CONTENT_HASH_ALGORITHM, $filePath) : ''); } $composerLockPath = rtrim($pathToRootCatalog, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'composer.lock'; diff --git a/packages/Ecotone/src/Lite/Test/ConfiguredMessagingSystemWithTestSupport.php b/packages/Ecotone/src/Lite/Test/ConfiguredMessagingSystemWithTestSupport.php index 444c5ca9b4..789ab51543 100644 --- a/packages/Ecotone/src/Lite/Test/ConfiguredMessagingSystemWithTestSupport.php +++ b/packages/Ecotone/src/Lite/Test/ConfiguredMessagingSystemWithTestSupport.php @@ -15,6 +15,7 @@ use Ecotone\Api\Lite\Test\FlowTestSupport; use Ecotone\Api\Lite\Test\MessagingTestSupport; use Ecotone\Api\Messaging\ConfiguredMessagingSystem; +use Ecotone\Api\Messaging\Flow\MessageFlowGraph; use Ecotone\Api\Messaging\Message; use Ecotone\Api\Messaging\MessageChannel; use Ecotone\Api\Messaging\MessageHeaders; @@ -140,6 +141,11 @@ public function getGatewayList(): array return $this->configuredMessagingSystem->getGatewayList(); } + public function getMessageFlowGraph(): MessageFlowGraph + { + return $this->configuredMessagingSystem->getMessageFlowGraph(); + } + public function replaceWith(ConfiguredMessagingSystem $messagingSystem): void { $this->configuredMessagingSystem->replaceWith($messagingSystem); diff --git a/packages/Ecotone/src/Messaging/Channel/Collector/Config/CollectorChannelInterceptorBuilder.php b/packages/Ecotone/src/Messaging/Channel/Collector/Config/CollectorChannelInterceptorBuilder.php index ffc5b58317..e233d39c0c 100644 --- a/packages/Ecotone/src/Messaging/Channel/Collector/Config/CollectorChannelInterceptorBuilder.php +++ b/packages/Ecotone/src/Messaging/Channel/Collector/Config/CollectorChannelInterceptorBuilder.php @@ -5,6 +5,7 @@ namespace Ecotone\Messaging\Channel\Collector\Config; use Ecotone\Messaging\Channel\ChannelInterceptorBuilder; +use Ecotone\Messaging\Channel\Collector\MessageCollectionObserver; use Ecotone\Messaging\Channel\Collector\MessageCollectorChannelInterceptor; use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\Container\MessagingContainerBuilder; @@ -38,6 +39,7 @@ public function compile(MessagingContainerBuilder $builder): Definition [ $this->collectorStorageReference, new Reference(LoggingGateway::class), + new Reference(MessageCollectionObserver::class), ] ); } diff --git a/packages/Ecotone/src/Messaging/Channel/Collector/MessageCollectionObserver.php b/packages/Ecotone/src/Messaging/Channel/Collector/MessageCollectionObserver.php new file mode 100644 index 0000000000..869f80b4d2 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Channel/Collector/MessageCollectionObserver.php @@ -0,0 +1,15 @@ +collectorStorage->isEnabled()) { $this->collectorStorage->collect($message, $this->logger); + $this->observer->collected($message); $message = null; } diff --git a/packages/Ecotone/src/Messaging/Channel/Collector/UnobservedMessageCollection.php b/packages/Ecotone/src/Messaging/Channel/Collector/UnobservedMessageCollection.php new file mode 100644 index 0000000000..b25d0c2210 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Channel/Collector/UnobservedMessageCollection.php @@ -0,0 +1,17 @@ +recover($message, $messageChannel, $exception) !== SendRecovery::Failed; + } + public function recover(Message $message, MessageChannel $messageChannel, Throwable $exception): SendRecovery + { $messageToRedeliver = $this->messageContainingOnlyFailedDeliveries($message, $exception); if ($exception !== null) { @@ -58,7 +59,7 @@ public function afterSendCompletion(Message $message, MessageChannel $messageCha $messageChannel->send($messageToRedeliver); - return true; + return SendRecovery::Delivered; } catch (Exception $exception) { $messageToRedeliver = $this->messageContainingOnlyFailedDeliveries($messageToRedeliver, $exception); $attempt++; @@ -82,10 +83,10 @@ public function afterSendCompletion(Message $message, MessageChannel $messageCha ); } - return true; + return SendRecovery::SentToErrorChannel; } - return false; + return SendRecovery::Failed; } /** diff --git a/packages/Ecotone/src/Messaging/Channel/SendingInterceptorAdapter.php b/packages/Ecotone/src/Messaging/Channel/SendingInterceptorAdapter.php index 2a23a8a0a4..6e120bc25e 100644 --- a/packages/Ecotone/src/Messaging/Channel/SendingInterceptorAdapter.php +++ b/packages/Ecotone/src/Messaging/Channel/SendingInterceptorAdapter.php @@ -6,6 +6,8 @@ use Ecotone\Api\Messaging\Message; use Ecotone\Api\Messaging\MessageChannel; +use Ecotone\Messaging\Channel\PollableChannel\SendRetries\SendRecovery; +use Ecotone\Messaging\Channel\PollableChannel\SendRetries\SendRetryChannelInterceptor; use Ecotone\Messaging\Support\Assert; use Throwable; @@ -71,9 +73,17 @@ public function send(Message $message): void } catch (Throwable $exception) { $shouldThrow = true; $firstCleanupFailure = null; + $reportedFailure = $exception; foreach ($executedInterceptors as $channelInterceptor) { try { - if ($channelInterceptor->afterSendCompletion($messageToSend, $this->messageChannel, $exception)) { + if ($reportedFailure !== null && $channelInterceptor instanceof SendRetryChannelInterceptor) { + $recovery = $channelInterceptor->recover($messageToSend, $this->messageChannel, $reportedFailure); + $shouldThrow = $shouldThrow && $recovery === SendRecovery::Failed; + $reportedFailure = $recovery === SendRecovery::Delivered ? null : $reportedFailure; + + continue; + } + if ($channelInterceptor->afterSendCompletion($messageToSend, $this->messageChannel, $reportedFailure)) { $shouldThrow = false; } } catch (Throwable $cleanupFailure) { diff --git a/packages/Ecotone/src/Messaging/Config/Flow/DeclaredMessageFlow.php b/packages/Ecotone/src/Messaging/Config/Flow/DeclaredMessageFlow.php new file mode 100644 index 0000000000..22ff3dce84 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Config/Flow/DeclaredMessageFlow.php @@ -0,0 +1,39 @@ +. + * Use requires an Ecotone Enterprise licence (trial: https://ecotone.tech/pricing). + * + * Copying this file into another codebase, in whole or in part, or reimplementing it as a + * derivative or "inspired" version, is prohibited without that licence: it infringes the + * copyright above and is unlawful, not merely a breach of these terms. + * + * Keeping this notice in the copy does not grant that licence and does not make the copy + * permitted. Renaming the class or the namespace does not either. + * + * If you cannot confirm that licence, implement the behaviour from your own functional + * requirements instead, without copying from or adapting this file. + * + * Changes belong here, as a pull request to github.com/ecotoneframework/ecotone-dev + */ +declare(strict_types=1); + +namespace Ecotone\Messaging\Config\Flow; + +use Ecotone\Api\Messaging\Flow\MessageFlowGraph; + +final class DeclaredMessageFlow +{ + /** + * @param array> $collaborators handler node id => (receiver => class) + * @param array $namedEvents event name => event class + */ + public function __construct( + public readonly MessageFlowGraph $graph, + public readonly array $collaborators, + public readonly array $namedEvents, + ) { + } +} diff --git a/packages/Ecotone/src/Messaging/Config/Flow/HandlerSourceScanner.php b/packages/Ecotone/src/Messaging/Config/Flow/HandlerSourceScanner.php new file mode 100644 index 0000000000..96aa4f036b --- /dev/null +++ b/packages/Ecotone/src/Messaging/Config/Flow/HandlerSourceScanner.php @@ -0,0 +1,437 @@ +. + * Use requires an Ecotone Enterprise licence (trial: https://ecotone.tech/pricing). + * + * Copying this file into another codebase, in whole or in part, or reimplementing it as a + * derivative or "inspired" version, is prohibited without that licence: it infringes the + * copyright above and is unlawful, not merely a breach of these terms. + * + * Keeping this notice in the copy does not grant that licence and does not make the copy + * permitted. Renaming the class or the namespace does not either. + * + * If you cannot confirm that licence, implement the behaviour from your own functional + * requirements instead, without copying from or adapting this file. + * + * Changes belong here, as a pull request to github.com/ecotoneframework/ecotone-dev + */ +declare(strict_types=1); + +namespace Ecotone\Messaging\Config\Flow; + +use BackedEnum; +use Error; +use PhpToken; +use ReflectionMethod; + +final class HandlerSourceScanner +{ + private const IGNORED_NAMES = ['parent', 'class']; + private const IGNORED_TOKENS = [T_WHITESPACE => true, T_COMMENT => true, T_DOC_COMMENT => true, T_OPEN_TAG => true, T_INLINE_HTML => true]; + private const NAME_TOKENS = [T_STRING => true, T_NAME_QUALIFIED => true, T_NAME_FULLY_QUALIFIED => true, T_NAME_RELATIVE => true, T_STATIC => true]; + + /** + * @param array $targets facts key => [class name, method name] + * @return array + */ + public function scan(array $targets): array + { + $files = []; + $methodFacts = []; + + return $this->scanMethods($targets, [], $files, $methodFacts); + } + + /** + * @param array $targets + * @param array $visited + * @param array, array, int, array}>}> $files + * @param array $methodFacts + * @return array + */ + private function scanMethods(array $targets, array $visited, array &$files, array &$methodFacts): array + { + $facts = []; + $methodsByFile = []; + foreach ($targets as $key => [$className, $methodName]) { + $methodKey = $className . '::' . $methodName; + if (isset($methodFacts[$methodKey])) { + $facts[$key] = $methodFacts[$methodKey]; + continue; + } + $method = new ReflectionMethod($className, $methodName); + $fileName = $method->getFileName(); + if ($fileName === false || ! is_file($fileName) || ! is_readable($fileName) || $method->getStartLine() === false || $method->getEndLine() === false) { + $facts[$key] = $methodFacts[$methodKey] = MethodSourceFacts::none(); + continue; + } + $methodsByFile[$fileName][] = [$key, $className, $method->getStartLine(), $method->getEndLine(), $method->getDeclaringClass()->getName()]; + } + + foreach ($methodsByFile as $fileName => $methods) { + if (! isset($files[$fileName])) { + $tokens = []; + $firstIndexByLine = []; + foreach (PhpToken::tokenize((string) file_get_contents($fileName)) as $token) { + if (isset(self::IGNORED_TOKENS[$token->id])) { + continue; + } + $firstIndexByLine[$token->line] ??= count($tokens); + $tokens[] = $token; + } + $contexts = $this->importContextsOf($tokens); + $lastLine = $tokens === [] ? 0 : end($tokens)->line; + $files[$fileName] = [$tokens, $firstIndexByLine, $lastLine, $contexts]; + } + [$tokens, $firstIndexByLine, $lastLine, $contexts] = $files[$fileName]; + foreach ($methods as [$key, $className, $startLine, $endLine, $declaringClass]) { + $start = $this->firstIndexAtOrAfter($firstIndexByLine, $startLine, $lastLine, count($tokens)); + $end = $this->firstIndexAtOrAfter($firstIndexByLine, $endLine + 1, $lastLine, count($tokens)); + [$namespace, $imports] = $this->contextAt($contexts, $start); + $imports['self'] = $declaringClass; + $facts[$key] = $methodFacts[$className . '::' . $targets[$key][1]] = $this->factsOf(array_slice($tokens, $start, $end - $start), $className, $namespace, $imports); + } + } + + foreach ($targets as $key => [$className, $methodName]) { + $path = $visited + [$className . '::' . $methodName => true]; + $instantiations = $facts[$key]->instantiations; + $calls = $facts[$key]->calls; + $literals = $facts[$key]->stringLiterals; + foreach ($facts[$key]->calls as $call) { + $helperKey = $className . '::' . $call['method']; + if (! in_array($call['receiver'], ['this', 'self', 'static'], true) || isset($path[$helperKey]) || ! method_exists($className, $call['method'])) { + continue; + } + $helper = $this->scanMethods([$helperKey => [$className, $call['method']]], $path, $files, $methodFacts)[$helperKey]; + foreach ($helper->instantiations as $instantiation) { + if ($instantiation['enclosingCall'] === null) { + $instantiation['enclosingCall'] = $call['enclosingCall']; + $instantiation['receiver'] = $call['receiver']; + } + $instantiations[] = $instantiation; + } + $calls = [...$calls, ...$helper->calls]; + $literals = [...$literals, ...$helper->stringLiterals]; + } + $facts[$key] = new MethodSourceFacts($instantiations, $calls, array_values(array_unique($literals))); + } + + return $facts; + } + + /** + * @param array $firstIndexByLine + */ + private function firstIndexAtOrAfter(array $firstIndexByLine, int $line, int $lastLine, int $tokenCount): int + { + for ($current = $line; $current <= $lastLine; $current++) { + if (isset($firstIndexByLine[$current])) { + return $firstIndexByLine[$current]; + } + } + + return $tokenCount; + } + + /** + * @param list $tokens + * @return array}> + */ + private function importContextsOf(array $tokens): array + { + $namespace = ''; + $imports = []; + $braceDepth = 0; + $namespaceDepth = 0; + $contexts = [0 => ['', []]]; + foreach ($tokens as $index => $token) { + $id = $token->id; + if ($id === 123 || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) { + $braceDepth++; + } elseif ($id === 125) { + $braceDepth--; + } elseif ($id === T_NAMESPACE) { + $name = $tokens[$index + 1] ?? null; + $namespace = $name !== null && $this->isName($name) ? $name->text : ''; + $delimiter = $tokens[$index + ($namespace === '' ? 1 : 2)] ?? null; + $namespaceDepth = $delimiter?->text === '{' ? 1 : 0; + $imports = []; + $contexts[$index] = [$namespace, $imports]; + } elseif ($id === T_USE && $braceDepth === $namespaceDepth && ($tokens[$index + 1] ?? null)?->text !== '(') { + $imports = array_merge($imports, $this->importStatementAt($tokens, $index + 1)); + $contexts[$index] = [$namespace, $imports]; + } + } + + return $contexts; + } + + /** + * @param array}> $contexts + * @return array{string, array} + */ + private function contextAt(array $contexts, int $index): array + { + $result = ['', []]; + foreach ($contexts as $position => $context) { + if ($position > $index) { + break; + } + $result = $context; + } + + return $result; + } + + /** + * @param list $tokens + * @return array + */ + private function importStatementAt(array $tokens, int $index): array + { + $imports = []; + if (isset($tokens[$index]) && ($tokens[$index]->is(T_FUNCTION) || $tokens[$index]->is(T_CONST))) { + return $imports; + } + $groupPrefix = ''; + $current = ''; + for ($i = $index; isset($tokens[$i]) && $tokens[$i]->text !== ';'; $i++) { + $token = $tokens[$i]; + if ($this->isName($token)) { + $current = $groupPrefix . ltrim($token->text, '\\'); + $imports[strtolower($this->shortName($current))] = $current; + } elseif ($token->is(T_NS_SEPARATOR) || $token->text === '{') { + $groupPrefix = rtrim($current, '\\') . '\\'; + unset($imports[strtolower($this->shortName($current))]); + } elseif ($token->is(T_AS) && isset($tokens[$i + 1])) { + unset($imports[strtolower($this->shortName($current))]); + $imports[strtolower($tokens[$i + 1]->text)] = $current; + $i++; + } + } + + return $imports; + } + + /** + * @param list $tokens + * @param array $imports + */ + private function factsOf(array $tokens, string $className, string $namespace, array $imports): MethodSourceFacts + { + $instantiations = []; + $calls = []; + $literals = []; + $enclosingCalls = []; + $variables = []; + $returning = false; + foreach ($tokens as $index => $token) { + $id = $token->id; + if ($id === T_RETURN || $id === T_YIELD) { + $returning = true; + } elseif ($id === 59) { + $returning = false; + } + if ($id === T_VARIABLE && ($tokens[$index + 1] ?? null)?->id === 61) { + unset($variables[$token->text]); + if (($tokens[$index + 2] ?? null)?->is(T_NEW) && isset($tokens[$index + 3]) && $this->isName($tokens[$index + 3])) { + $variables[$token->text] = $this->resolve($tokens[$index + 3], $className, $namespace, $imports); + } + } + if ($id === T_NEW && isset($tokens[$index + 1]) && $this->isName($tokens[$index + 1])) { + $resolved = $this->resolve($tokens[$index + 1], $className, $namespace, $imports); + if ($resolved !== null) { + $instantiations[] = ['class' => $resolved, 'returned' => $returning, 'enclosingCall' => $this->innermostCall($enclosingCalls)['method'] ?? null, 'receiver' => $this->innermostCall($enclosingCalls)['receiver'] ?? null]; + } + } elseif ($id === T_DOUBLE_COLON && isset($tokens[$index - 1], $tokens[$index + 1], $tokens[$index + 2]) && $this->isName($tokens[$index - 1]) && $tokens[$index + 1]->is(T_STRING) && $tokens[$index + 2]->text === '(') { + $resolved = $this->resolve($tokens[$index - 1], $className, $namespace, $imports); + if ($resolved !== null && $resolved !== $className) { + $instantiations[] = ['class' => $resolved, 'returned' => $returning, 'enclosingCall' => $this->innermostCall($enclosingCalls)['method'] ?? null, 'receiver' => $this->innermostCall($enclosingCalls)['receiver'] ?? null]; + } + } elseif ($id === 40) { + $call = $this->callBefore($tokens, $index); + if ($call !== null) { + $argument = $tokens[$index + 1] ?? null; + if ($argument?->is(T_VARIABLE) && isset($variables[$argument->text]) && in_array($call['method'], ['publish', 'recordThat', 'send'], true)) { + array_unshift($instantiations, ['class' => $variables[$argument->text], 'returned' => false, 'enclosingCall' => $call['method'], 'receiver' => $call['receiver']]); + } + $calls[] = $call + ['enclosingCall' => $this->innermostCall($enclosingCalls)['method'] ?? null, 'receiver' => $this->innermostCall($enclosingCalls)['receiver'] ?? null] + $this->argumentsAt($tokens, $index + 1, $className, $namespace, $imports); + } + $enclosingCalls[] = $call; + } elseif ($id === 41) { + array_pop($enclosingCalls); + } elseif ($id === T_CONSTANT_ENCAPSED_STRING) { + $literals[] = $this->literal($token); + } + } + + return new MethodSourceFacts($instantiations, $calls, array_values(array_unique($literals))); + } + + /** + * @param list $tokens + * @return array{receiver: string|null, method: string}|null + */ + private function callBefore(array $tokens, int $parenthesisIndex): ?array + { + $name = $tokens[$parenthesisIndex - 1] ?? null; + $operator = $tokens[$parenthesisIndex - 2] ?? null; + if ($name === null || $operator === null || ! $name->is(T_STRING)) { + return null; + } + if (! ($operator->is(T_OBJECT_OPERATOR) || $operator->is(T_NULLSAFE_OBJECT_OPERATOR) || $operator->is(T_DOUBLE_COLON))) { + return null; + } + $receiver = null; + $subject = $tokens[$parenthesisIndex - 3] ?? null; + $subjectOperator = $tokens[$parenthesisIndex - 4] ?? null; + $subjectOwner = $tokens[$parenthesisIndex - 5] ?? null; + if ($operator->is(T_DOUBLE_COLON) && in_array(strtolower($subject?->text ?? ''), ['self', 'static'], true)) { + $receiver = strtolower($subject->text); + } elseif ($subject?->is(T_VARIABLE)) { + $receiver = ltrim($subject->text, '$'); + } elseif ($subject?->is(T_STRING) && $subjectOperator?->is(T_OBJECT_OPERATOR) && $subjectOwner?->text === '$this') { + $receiver = 'this.' . $subject->text; + } + + return ['receiver' => $receiver, 'method' => $name->text]; + } + + /** + * @param list $tokens + * @param array $imports + * @return array{literalArguments: list, firstArgumentClass: string|null} + */ + private function argumentsAt(array $tokens, int $index, string $className, string $namespace, array $imports): array + { + $first = $tokens[$index] ?? null; + if ($first?->is(T_NEW) && isset($tokens[$index + 1]) && $this->isName($tokens[$index + 1])) { + return ['literalArguments' => [], 'firstArgumentClass' => $this->resolve($tokens[$index + 1], $className, $namespace, $imports)]; + } + $literals = []; + while (isset($tokens[$index])) { + [$literal, $end] = $this->literalArgumentAt($tokens, $index, $className, $namespace, $imports); + if ($literal === null || ! in_array(($tokens[$end + 1] ?? null)?->text, [',', ')'], true)) { + break; + } + $literals[] = $literal; + if ($tokens[$end + 1]->text !== ',') { + break; + } + $index = $end + 2; + } + + return ['literalArguments' => $literals, 'firstArgumentClass' => null]; + } + + private function literalArgumentAt(array $tokens, int $index, string $className, string $namespace, array $imports): array + { + $token = $tokens[$index]; + if ($token->is(T_CONSTANT_ENCAPSED_STRING)) { + return [$this->literal($token), $index]; + } + if (! $this->isName($token) || ! ($tokens[$index + 1] ?? null)?->is(T_DOUBLE_COLON) || ! ($tokens[$index + 2] ?? null)?->is(T_STRING)) { + return [null, $index]; + } + $class = $this->resolve($token, $className, $namespace, $imports); + $constant = $class . '::' . $tokens[$index + 2]->text; + if ($class === null || ! defined($constant)) { + return [null, $index]; + } + try { + $value = constant($constant); + } catch (Error) { + return [null, $index]; + } + $end = $index + 2; + if ($value instanceof BackedEnum && ($tokens[$end + 1] ?? null)?->is(T_OBJECT_OPERATOR) && ($tokens[$end + 2] ?? null)?->text === 'value') { + $value = $value->value; + $end += 2; + } + + return [is_string($value) ? $value : null, $end]; + } + + /** + * @param list $enclosingCalls + * @return array{receiver: string|null, method: string}|null + */ + private function innermostCall(array $enclosingCalls): ?array + { + for ($i = count($enclosingCalls) - 1; $i >= 0; $i--) { + if ($enclosingCalls[$i] !== null) { + return $enclosingCalls[$i]; + } + } + + return null; + } + + private function isName(PhpToken $token): bool + { + return isset(self::NAME_TOKENS[$token->id]); + } + + /** + * @param array $imports + */ + private function resolve(PhpToken $token, string $className, string $namespace, array $imports): ?string + { + $name = $token->text; + if (strtolower($name) === 'self') { + return $imports['self'] ?? $className; + } + if (strtolower($name) === 'static') { + return $className; + } + if (in_array(strtolower($name), self::IGNORED_NAMES, true)) { + return null; + } + if ($token->is(T_NAME_FULLY_QUALIFIED)) { + return ltrim($name, '\\'); + } + if ($token->is(T_NAME_RELATIVE)) { + return $namespace . '\\' . substr($name, strlen('namespace\\')); + } + $firstSegment = strtolower(explode('\\', $name)[0]); + if (isset($imports[$firstSegment])) { + $rest = substr($name, strlen(explode('\\', $name)[0])); + + return $imports[$firstSegment] . $rest; + } + + return $namespace === '' ? $name : $namespace . '\\' . $name; + } + + private function literal(PhpToken $token): string + { + $text = substr($token->text, 1, -1); + if ($token->text[0] === "'") { + return str_replace(['\\\\', "\\'"], ['\\', "'"], $text); + } + + return preg_replace_callback('~\\\\(?:u\{([0-9a-fA-F]+)\}|[nrtvef\\\\$"]|[0-7]{1,3}|x[0-9a-fA-F]{1,2})~', function (array $match): string { + if (($match[1] ?? '') !== '') { + $codePoint = hexdec($match[1]); + $escaped = $codePoint <= 0xffff + ? sprintf('"\u%04x"', $codePoint) + : sprintf('"\u%04x\u%04x"', 0xd800 + (($codePoint - 0x10000) >> 10), 0xdc00 + (($codePoint - 0x10000) & 0x3ff)); + + return json_decode($escaped, flags: JSON_THROW_ON_ERROR); + } + + return $match[0] === '\e' ? "\x1b" : stripcslashes($match[0]); + }, $text); + } + + private function shortName(string $name): string + { + $segments = explode('\\', $name); + + return end($segments); + } +} diff --git a/packages/Ecotone/src/Messaging/Config/Flow/InferredFlow.php b/packages/Ecotone/src/Messaging/Config/Flow/InferredFlow.php new file mode 100644 index 0000000000..086683cd4e --- /dev/null +++ b/packages/Ecotone/src/Messaging/Config/Flow/InferredFlow.php @@ -0,0 +1,78 @@ +. + * Use requires an Ecotone Enterprise licence (trial: https://ecotone.tech/pricing). + * + * Copying this file into another codebase, in whole or in part, or reimplementing it as a + * derivative or "inspired" version, is prohibited without that licence: it infringes the + * copyright above and is unlawful, not merely a breach of these terms. + * + * Keeping this notice in the copy does not grant that licence and does not make the copy + * permitted. Renaming the class or the namespace does not either. + * + * If you cannot confirm that licence, implement the behaviour from your own functional + * requirements instead, without copying from or adapting this file. + * + * Changes belong here, as a pull request to github.com/ecotoneframework/ecotone-dev + */ +declare(strict_types=1); + +namespace Ecotone\Messaging\Config\Flow; + +use Ecotone\Api\Messaging\Flow\FlowEdge; +use Ecotone\Api\Messaging\Flow\FlowNode; +use Ecotone\Api\Messaging\Flow\FlowNodeKind; +use Ecotone\Api\Messaging\Flow\MessageFlowGraph; + +final class InferredFlow +{ + /** @var array */ + private array $nodes = []; + /** @var list */ + private array $edges; + + public function __construct(MessageFlowGraph $declared) + { + foreach ($declared->nodes() as $node) { + $this->nodes[$node->id] = $node; + } + $this->edges = $declared->edges(); + } + + public function hasNode(string $id): bool + { + return isset($this->nodes[$id]); + } + + public function node(string $id): FlowNode + { + return $this->nodes[$id]; + } + + public function addNode(FlowNode $node): void + { + $this->nodes[$node->id] = $node; + } + + public function addEdge(FlowEdge $edge): void + { + $this->edges[] = $edge; + } + + public function graph(): MessageFlowGraph + { + $connected = []; + foreach ($this->edges as $edge) { + $connected[$edge->from] = true; + $connected[$edge->to] = true; + } + + return MessageFlowGraph::create( + array_values(array_filter($this->nodes, fn (FlowNode $node) => $node->kind !== FlowNodeKind::CHANNEL || isset($connected[$node->id]))), + $this->edges, + ); + } +} diff --git a/packages/Ecotone/src/Messaging/Config/Flow/MessageFlowConsoleCommands.php b/packages/Ecotone/src/Messaging/Config/Flow/MessageFlowConsoleCommands.php new file mode 100644 index 0000000000..65510431dd --- /dev/null +++ b/packages/Ecotone/src/Messaging/Config/Flow/MessageFlowConsoleCommands.php @@ -0,0 +1,27 @@ +getMessageFlowGraph(); + + $writer->write(match ($format) { + 'mermaid' => $graph->toMermaid(), + 'json' => $graph->toJson() . "\n", + default => throw ConfigurationException::create("ecotone:flow:dump was asked for format `{$format}`, which it cannot produce. Pass --format=mermaid for a Mermaid flowchart to paste into a diagram viewer, or --format=json for the graph as nodes and edges."), + }); + } +} diff --git a/packages/Ecotone/src/Messaging/Config/Flow/MessageFlowDiscovery.php b/packages/Ecotone/src/Messaging/Config/Flow/MessageFlowDiscovery.php new file mode 100644 index 0000000000..5aace82c98 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Config/Flow/MessageFlowDiscovery.php @@ -0,0 +1,576 @@ +. + * Use requires an Ecotone Enterprise licence (trial: https://ecotone.tech/pricing). + * + * Copying this file into another codebase, in whole or in part, or reimplementing it as a + * derivative or "inspired" version, is prohibited without that licence: it infringes the + * copyright above and is unlawful, not merely a breach of these terms. + * + * Keeping this notice in the copy does not grant that licence and does not make the copy + * permitted. Renaming the class or the namespace does not either. + * + * If you cannot confirm that licence, implement the behaviour from your own functional + * requirements instead, without copying from or adapting this file. + * + * Changes belong here, as a pull request to github.com/ecotoneframework/ecotone-dev + */ +declare(strict_types=1); + +namespace Ecotone\Messaging\Config\Flow; + +use Ecotone\AnnotationFinder\AnnotatedFinding; +use Ecotone\AnnotationFinder\AnnotationFinder; +use Ecotone\Api\Attribute\After; +use Ecotone\Api\Attribute\Aggregate; +use Ecotone\Api\Attribute\Apply; +use Ecotone\Api\Attribute\Around; +use Ecotone\Api\Attribute\Asynchronous; +use Ecotone\Api\Attribute\Before; +use Ecotone\Api\Attribute\CommandHandler; +use Ecotone\Api\Attribute\Distributed; +use Ecotone\Api\Attribute\EventHandler; +use Ecotone\Api\Attribute\EventSourcingAggregate; +use Ecotone\Api\Attribute\InternalHandler; +use Ecotone\Api\Attribute\MessageGateway; +use Ecotone\Api\Attribute\NamedEvent; +use Ecotone\Api\Attribute\Orchestrator; +use Ecotone\Api\Attribute\Polling; +use Ecotone\Api\Attribute\Presend; +use Ecotone\Api\Attribute\QueryHandler; +use Ecotone\Api\Attribute\Router; +use Ecotone\Api\Attribute\Saga; +use Ecotone\Api\Attribute\Scheduled; +use Ecotone\Api\Attribute\Splitter; +use Ecotone\Api\Attribute\Transformer; +use Ecotone\Api\ExtensionObject\CombinedMessageChannel; +use Ecotone\Api\ExtensionObject\DistributedServiceMap; +use Ecotone\Api\ExtensionObject\MessageChannelBuilder; +use Ecotone\Api\ExtensionObject\ServiceConfiguration; +use Ecotone\Api\Gateway\DistributedBus; +use Ecotone\Api\Messaging\Flow\FlowEdge; +use Ecotone\Api\Messaging\Flow\FlowEdgeKind; +use Ecotone\Api\Messaging\Flow\FlowNode; +use Ecotone\Api\Messaging\Flow\FlowNodeKind; +use Ecotone\Api\Messaging\Flow\MessageFlowGraph; +use Ecotone\Api\Projecting\Projection; +use Ecotone\Api\Projecting\ProjectionBackfill; +use Ecotone\Api\Projecting\ProjectionRebuild; +use Ecotone\Api\Projecting\Streaming; +use Ecotone\Messaging\Attribute\EndpointAnnotation; +use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ExtensionObjectResolver; +use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ParameterConverterAnnotationFactory; +use Ecotone\Messaging\Handler\InterfaceToCallRegistry; +use Ecotone\Messaging\Handler\Processor\MethodInvoker\Pointcut; +use Ecotone\Messaging\Handler\Type; +use Ecotone\Messaging\NullableMessageChannel; +use Ecotone\Modelling\Config\MessageHandlerRoutingModule; +use Ecotone\Modelling\Config\Routing\BusRoutingMap; +use Ecotone\Modelling\Config\Routing\BusRoutingMapBuilder; +use Ecotone\Modelling\DecisionModel\DecisionModelHandlers; + +final class MessageFlowDiscovery +{ + private const FRAMEWORK_NAMESPACE = 'Ecotone\\'; + + /** @var array */ + private array $nodes = []; + /** @var list */ + private array $edges = []; + /** @var array> */ + private array $collaborators = []; + /** @var array */ + private array $pollableChannels = []; + /** @var array> */ + private array $combinedChannels = []; + /** @var array */ + private array $ownerByClass = []; + /** @var array event name => event class */ + private array $namedEvents = []; + /** @var array */ + private array $handlerRegistrations = []; + + private function __construct( + private readonly AnnotationFinder $annotationFinder, + private readonly InterfaceToCallRegistry $interfaceToCallRegistry, + ) { + } + + /** + * @param object[] $extensionObjects + */ + public static function discover(AnnotationFinder $annotationFinder, InterfaceToCallRegistry $interfaceToCallRegistry, array $extensionObjects): DeclaredMessageFlow + { + return (new self($annotationFinder, $interfaceToCallRegistry))->run($extensionObjects); + } + + /** + * @param object[] $extensionObjects + */ + private function run(array $extensionObjects): DeclaredMessageFlow + { + foreach (ExtensionObjectResolver::resolve(MessageChannelBuilder::class, $extensionObjects) as $channelBuilder) { + $this->pollableChannels[$channelBuilder->getMessageChannelName()] = $channelBuilder->isPollable(); + $this->channelNode($channelBuilder->getMessageChannelName()); + } + foreach (ExtensionObjectResolver::resolve(CombinedMessageChannel::class, $extensionObjects) as $combinedChannel) { + $this->combinedChannels[$combinedChannel->getReferenceName()] = $combinedChannel->combinedChannels; + } + + $this->discoverNamedEvents(); + $this->discoverServiceMaps($extensionObjects); + $this->discoverOwners(); + $this->discoverBusHandlers(CommandHandler::class, FlowNodeKind::COMMAND_HANDLER, FlowNodeKind::COMMAND); + $this->discoverBusHandlers(QueryHandler::class, FlowNodeKind::QUERY_HANDLER, FlowNodeKind::QUERY); + $this->discoverBusHandlers(EventHandler::class, FlowNodeKind::EVENT_HANDLER, FlowNodeKind::EVENT); + $this->connectSubscribedEventChannels($extensionObjects); + $this->discoverChannelHandlers(); + $this->discoverRouters(); + $this->discoverSplitters(); + $this->discoverTransformers(); + $this->discoverOrchestrators(); + $this->discoverInterceptors(); + $this->discoverAppliedEvents(); + $this->discoverGateways(); + $this->discoverScheduled(); + foreach (DecisionModelHandlers::scan($this->annotationFinder, $this->interfaceToCallRegistry)->appendingTheirResult() as $handler) { + $id = 'handler:' . $handler->className() . '::' . $handler->methodName(); + if (isset($this->nodes[$id])) { + $this->nodes[$id] = $this->nodes[$id]->withAttributes(['returnsEvents' => true]); + } + } + + return new DeclaredMessageFlow(MessageFlowGraph::create(array_values($this->nodes), $this->edges), $this->collaborators, $this->namedEvents); + } + + /** + * @param object[] $extensionObjects + */ + private function discoverServiceMaps(array $extensionObjects): void + { + $serviceName = ExtensionObjectResolver::resolveUnique(ServiceConfiguration::class, $extensionObjects, ServiceConfiguration::createWithDefaults())->serviceName; + foreach (ExtensionObjectResolver::resolve(DistributedServiceMap::class, $extensionObjects) as $serviceMap) { + $bus = $this->distributedBusNode($serviceMap->getReferenceName()); + $subscriptions = array_map(fn (array $subscription) => $subscription['keys'], $serviceMap->eventSubscriptions); + $this->nodes[$bus->id] = $bus->withAttributes(['commandMapping' => $serviceMap->commandMapping, 'eventSubscriptions' => $subscriptions]); + foreach ($serviceMap->commandMapping as $targetService => $channelName) { + $this->edges[] = new FlowEdge($bus->id, $this->channelNode($channelName)->id, FlowEdgeKind::SENDS_TO, ['targetService' => $targetService]); + if ($targetService === $serviceName) { + $this->edges[] = new FlowEdge($this->channelNode($channelName)->id, $bus->id, FlowEdgeKind::INPUT); + } + } + foreach ($serviceMap->eventSubscriptions as $channelName => $subscription) { + if (in_array($serviceName, $subscription['exclude'], true) || ($subscription['include'] !== [] && ! in_array($serviceName, $subscription['include'], true))) { + continue; + } + $this->edges[] = new FlowEdge($bus->id, $this->channelNode($channelName)->id, FlowEdgeKind::SENDS_TO, ['subscriptionKeys' => $subscription['keys']]); + } + } + } + + private function discoverNamedEvents(): void + { + foreach ($this->annotationFinder->findAnnotatedClasses(NamedEvent::class) as $eventClass) { + if (str_starts_with($eventClass, self::FRAMEWORK_NAMESPACE)) { + continue; + } + /** @var NamedEvent $namedEvent */ + $namedEvent = $this->annotationFinder->getAttributeForClass($eventClass, NamedEvent::class); + $this->namedEvents[$namedEvent->getName()] = $eventClass; + } + foreach ($this->applicationMethods(EventHandler::class) as $registration) { + foreach (MessageHandlerRoutingModule::getFirstParameterClassesIfAny($registration, $this->interfaceToCallRegistry) as $eventClass) { + $definition = $this->interfaceToCallRegistry->getClassDefinitionFor(Type::object($eventClass)); + if ($definition->hasClassAnnotation(Type::attribute(NamedEvent::class))) { + $this->namedEvents[$definition->getSingleClassAnnotation(Type::attribute(NamedEvent::class))->getName()] = $eventClass; + } + } + } + } + + private function discoverOwners(): void + { + foreach ($this->annotationFinder->findAnnotatedClasses(Aggregate::class) as $aggregateClass) { + $isSaga = $this->annotationFinder->findAttributeForClass($aggregateClass, Saga::class) !== null; + $kind = $isSaga ? FlowNodeKind::SAGA : FlowNodeKind::AGGREGATE; + $id = $kind->value . ':' . $aggregateClass; + $this->nodes[$id] = new FlowNode($id, $kind, $this->shortName($aggregateClass), [ + 'class' => $aggregateClass, + 'eventSourced' => $this->annotationFinder->findAttributeForClass($aggregateClass, EventSourcingAggregate::class) !== null, + ]); + $this->ownerByClass[$aggregateClass] = $id; + } + foreach ($this->annotationFinder->findAnnotatedClasses(Projection::class) as $projectionClass) { + /** @var Projection $projection */ + $projection = $this->annotationFinder->getAttributeForClass($projectionClass, Projection::class); + $id = 'projection:' . $projection->getName(); + $this->nodes[$id] = new FlowNode($id, FlowNodeKind::PROJECTION, $projection->getName(), ['class' => $projectionClass]); + $polling = $this->annotationFinder->findAttributeForClass($projectionClass, Polling::class); + if ($polling instanceof Polling) { + $this->nodes[$id] = $this->nodes[$id]->withAttributes(['endpointId' => $polling->endpointId]); + } + $streaming = $this->annotationFinder->findAttributeForClass($projectionClass, Streaming::class); + if ($streaming instanceof Streaming) { + $this->edges[] = new FlowEdge($this->channelNode($streaming->channelName)->id, $id, FlowEdgeKind::INPUT); + } + foreach ([ProjectionBackfill::class, ProjectionRebuild::class] as $replayAttribute) { + $replay = $this->annotationFinder->findAttributeForClass($projectionClass, $replayAttribute); + if (($replay instanceof ProjectionBackfill || $replay instanceof ProjectionRebuild) && $replay->asyncChannelName !== null) { + $channelId = $this->channelNode($replay->asyncChannelName)->id; + $this->edges[] = new FlowEdge($id, $channelId, FlowEdgeKind::ASYNCHRONOUS); + $this->edges[] = new FlowEdge($channelId, $id, FlowEdgeKind::INPUT); + } + } + $this->ownerByClass[$projectionClass] = $id; + } + } + + private function discoverBusHandlers(string $attribute, FlowNodeKind $handlerKind, FlowNodeKind $messageKind): void + { + $routing = new BusRoutingMapBuilder(); + foreach ($this->applicationMethods($attribute) as $registration) { + /** @var CommandHandler|QueryHandler|EventHandler $annotation */ + $annotation = $registration->getAnnotationForMethod(); + $routingKeys = $routing->getRoutesFromAnnotatedFinding($registration, $this->interfaceToCallRegistry); + $distributed = $registration->hasAnnotation(Distributed::class); + $handlerId = $this->registerHandler($registration, $handlerKind, ['routingKeys' => $routingKeys, 'distributed' => $distributed]); + + foreach ($routingKeys as $routingKey) { + $message = $this->messageNode($routingKey, $messageKind, handled: true); + $this->edges[] = new FlowEdge($message->id, $handlerId, FlowEdgeKind::ROUTED_TO); + if ($distributed) { + /** @var Distributed $distributedAttribute */ + $distributedAttribute = $registration->getAnnotationsByImportanceOrder(Distributed::class)[0]; + $this->nodes[$message->id] = $message->withAttributes(['distributed' => true]); + $this->edges[] = new FlowEdge($this->distributedBusNode($distributedAttribute->distributionReference)->id, $message->id, FlowEdgeKind::DISTRIBUTES); + } + } + $this->registerOutput($handlerId, $annotation->getOutputChannelName()); + if ($handlerKind !== FlowNodeKind::QUERY_HANDLER) { + $this->registerAsynchronous($handlerId, $registration); + } + } + } + + private function discoverChannelHandlers(): void + { + foreach ($this->applicationMethods(InternalHandler::class) as $registration) { + /** @var InternalHandler $annotation */ + $annotation = $registration->getAnnotationForMethod(); + $handlerId = $this->registerHandler($registration, FlowNodeKind::INTERNAL_HANDLER, ['inputChannel' => $annotation->getInputChannelName()]); + $this->edges[] = new FlowEdge($this->channelNode($annotation->getInputChannelName())->id, $handlerId, FlowEdgeKind::INPUT); + $this->registerOutput($handlerId, $annotation->getOutputChannelName()); + $this->registerAsynchronous($handlerId, $registration); + } + } + + private function discoverRouters(): void + { + foreach ($this->applicationMethods(Router::class) as $registration) { + /** @var Router $annotation */ + $annotation = $registration->getAnnotationForMethod(); + $handlerId = $this->registerHandler($registration, FlowNodeKind::ROUTER, ['inputChannel' => $annotation->getInputChannelName()]); + $this->edges[] = new FlowEdge($this->channelNode($annotation->getInputChannelName())->id, $handlerId, FlowEdgeKind::INPUT); + } + } + + private function discoverSplitters(): void + { + foreach ($this->applicationMethods(Splitter::class) as $registration) { + /** @var Splitter $annotation */ + $annotation = $registration->getAnnotationForMethod(); + $handlerId = $this->registerHandler($registration, FlowNodeKind::SPLITTER, ['inputChannel' => $annotation->getInputChannelName()]); + $this->edges[] = new FlowEdge($this->channelNode($annotation->getInputChannelName())->id, $handlerId, FlowEdgeKind::INPUT); + $this->registerOutput($handlerId, $annotation->getOutputChannelName()); + } + } + + private function discoverTransformers(): void + { + foreach ($this->applicationMethods(Transformer::class) as $registration) { + /** @var Transformer $annotation */ + $annotation = $registration->getAnnotationForMethod(); + $handlerId = $this->registerHandler($registration, FlowNodeKind::TRANSFORMER, ['inputChannel' => $annotation->getInputChannelName()]); + $this->edges[] = new FlowEdge($this->channelNode($annotation->getInputChannelName())->id, $handlerId, FlowEdgeKind::INPUT); + $this->registerOutput($handlerId, $annotation->getOutputChannelName()); + } + } + + /** + * @param object[] $extensionObjects + */ + private function connectSubscribedEventChannels(array $extensionObjects): void + { + foreach (ExtensionObjectResolver::resolve(DistributedServiceMap::class, $extensionObjects) as $serviceMap) { + $busId = $this->distributedBusNode($serviceMap->getReferenceName())->id; + foreach ($serviceMap->eventSubscriptions as $channelName => $subscription) { + if ($this->subscribesToAnyOf($subscription['keys'])) { + $this->edges[] = new FlowEdge($this->channelNode($channelName)->id, $busId, FlowEdgeKind::INPUT); + } + } + } + } + + /** + * @param string[] $subscriptionKeys + */ + private function subscribesToAnyOf(array $subscriptionKeys): bool + { + foreach ($this->nodes as $node) { + if ($node->kind !== FlowNodeKind::EVENT_HANDLER || ($node->attributes['distributed'] ?? false) !== true) { + continue; + } + foreach ($node->attributes['routingKeys'] ?? [] as $routingKey) { + foreach ($subscriptionKeys as $subscriptionKey) { + if (BusRoutingMap::globMatch($subscriptionKey, (string) $routingKey)) { + return true; + } + } + } + } + + return false; + } + + private function discoverOrchestrators(): void + { + foreach ($this->applicationMethods(Orchestrator::class) as $registration) { + /** @var Orchestrator $annotation */ + $annotation = $registration->getAnnotationForMethod(); + $handlerId = $this->registerHandler($registration, FlowNodeKind::ORCHESTRATOR, ['inputChannel' => $annotation->getInputChannelName()]); + $this->edges[] = new FlowEdge($this->channelNode($annotation->getInputChannelName())->id, $handlerId, FlowEdgeKind::INPUT); + } + } + + private function discoverInterceptors(): void + { + $parameterConverters = ParameterConverterAnnotationFactory::create(); + foreach (['presend' => Presend::class, 'before' => Before::class, 'around' => Around::class, 'after' => After::class] as $position => $attribute) { + foreach ($this->applicationMethods($attribute) as $registration) { + /** @var Presend|Before|Around|After $annotation */ + $annotation = $registration->getAnnotationForMethod(); + $interface = $this->interfaceToCallRegistry->getFor($registration->getClassName(), $registration->getMethodName()); + $pointcut = $annotation->pointcut !== '' + ? Pointcut::createWith($annotation->pointcut) + : Pointcut::initializeFrom($interface, $parameterConverters->createParameterConverters($interface)); + $id = 'interceptor:' . $registration->getClassName() . '::' . $registration->getMethodName(); + $this->nodes[$id] = new FlowNode($id, FlowNodeKind::INTERCEPTOR, $this->shortName($registration->getClassName()) . '::' . $registration->getMethodName(), [ + 'class' => $registration->getClassName(), + 'method' => $registration->getMethodName(), + 'position' => $position, + 'pointcut' => $annotation->pointcut, + ]); + $this->collaborators[$id] = $this->collaboratorsOf($registration); + foreach ($this->handlerRegistrations as $handlerId => $handler) { + if ($pointcut->doesItCut($this->interfaceToCallRegistry->getFor($handler->getClassName(), $handler->getMethodName()), [$handler->getAnnotationForMethod()])) { + $this->edges[] = new FlowEdge($handlerId, $id, FlowEdgeKind::INTERCEPTED_BY); + } + } + } + } + } + + private function discoverAppliedEvents(): void + { + foreach ($this->applicationMethods(Apply::class) as $registration) { + $ownerId = $this->ownerByClass[$registration->getClassName()] ?? null; + if ($ownerId === null) { + continue; + } + foreach (MessageHandlerRoutingModule::getFirstParameterClassesIfAny($registration, $this->interfaceToCallRegistry) as $eventClass) { + $this->edges[] = new FlowEdge($ownerId, $this->messageNode($eventClass, FlowNodeKind::EVENT, handled: false)->id, FlowEdgeKind::APPLIES, ['method' => $registration->getMethodName()]); + } + } + } + + private function discoverGateways(): void + { + foreach ($this->applicationMethods(MessageGateway::class) as $registration) { + /** @var MessageGateway $annotation */ + $annotation = $registration->getAnnotationForMethod(); + $id = 'gateway:' . $registration->getClassName() . '::' . $registration->getMethodName(); + $this->nodes[$id] = new FlowNode($id, FlowNodeKind::GATEWAY, $this->shortName($registration->getClassName()) . '::' . $registration->getMethodName(), [ + 'interface' => $registration->getClassName(), + 'method' => $registration->getMethodName(), + 'requestChannel' => $annotation->requestChannel, + ]); + $this->edges[] = new FlowEdge($id, $this->channelNode($annotation->requestChannel)->id, FlowEdgeKind::SENDS_TO); + } + } + + private function discoverScheduled(): void + { + foreach ($this->applicationMethods(Scheduled::class) as $registration) { + /** @var Scheduled $annotation */ + $annotation = $registration->getAnnotationForMethod(); + $id = 'scheduled:' . $registration->getClassName() . '::' . $registration->getMethodName(); + $this->collaborators[$id] = $this->collaboratorsOf($registration); + $this->nodes[$id] = new FlowNode($id, FlowNodeKind::SCHEDULED, $this->shortName($registration->getClassName()) . '::' . $registration->getMethodName(), [ + 'class' => $registration->getClassName(), + 'method' => $registration->getMethodName(), + 'endpointId' => $annotation->getEndpointId(), + ]); + if ($annotation->requestChannelName !== NullableMessageChannel::CHANNEL_NAME) { + $this->edges[] = new FlowEdge($id, $this->channelNode($annotation->requestChannelName)->id, FlowEdgeKind::SENDS_TO); + } + } + } + + /** + * @param array|null> $attributes + */ + private function registerHandler(AnnotatedFinding $registration, FlowNodeKind $kind, array $attributes): string + { + /** @var EndpointAnnotation $annotation */ + $annotation = $registration->getAnnotationForMethod(); + $id = 'handler:' . $registration->getClassName() . '::' . $registration->getMethodName(); + if (isset($this->nodes[$id]) && $this->nodes[$id]->kind !== $kind) { + $id .= ':' . $kind->value; + } + if (isset($this->nodes[$id]->attributes['routingKeys'], $attributes['routingKeys'])) { + $attributes['routingKeys'] = array_values(array_unique([...$this->nodes[$id]->attributes['routingKeys'], ...$attributes['routingKeys']])); + } + $this->nodes[$id] = new FlowNode($id, $kind, $this->shortName($registration->getClassName()) . '::' . $registration->getMethodName(), array_merge([ + 'class' => $registration->getClassName(), + 'method' => $registration->getMethodName(), + 'endpointId' => $annotation->isEndpointIdGenerated() ? null : $annotation->getEndpointId(), + 'owner' => $this->ownerByClass[$registration->getClassName()] ?? null, + ], $attributes)); + $this->collaborators[$id] = $this->collaboratorsOf($registration); + $this->handlerRegistrations[$id] = $registration; + + return $id; + } + + private function registerOutput(string $handlerId, string $outputChannelName): void + { + if ($outputChannelName === '') { + return; + } + $this->edges[] = new FlowEdge($handlerId, $this->channelNode($outputChannelName)->id, FlowEdgeKind::OUTPUT); + } + + private function registerAsynchronous(string $handlerId, AnnotatedFinding $registration): void + { + if (! $registration->hasAnnotation(Asynchronous::class)) { + return; + } + /** @var Asynchronous $attribute */ + $attribute = $registration->getAnnotationsByImportanceOrder(Asynchronous::class)[0]; + foreach ($attribute->getChannelName() as $channelName) { + foreach ($this->combinedChannels[$channelName] ?? [$channelName] as $resolvedChannelName) { + $this->pollableChannels[$resolvedChannelName] = true; + $this->edges[] = new FlowEdge($this->channelNode($resolvedChannelName)->id, $handlerId, FlowEdgeKind::ASYNCHRONOUS); + } + } + } + + private function messageNode(string $routingKey, FlowNodeKind $kind, bool $handled): FlowNode + { + $routingKey = $this->namedEvents[$routingKey] ?? $routingKey; + $id = 'message:' . $routingKey; + $isClass = class_exists($routingKey) || interface_exists($routingKey); + $existing = $this->nodes[$id] ?? null; + if ($existing !== null) { + return $handled && ! $existing->attributes['handled'] + ? $this->nodes[$id] = $existing->withAttributes(['handled' => true]) + : $existing; + } + + return $this->nodes[$id] = new FlowNode($id, $kind, $isClass ? $this->shortName($routingKey) : $routingKey, [ + 'routingKey' => $routingKey, + 'class' => $isClass ? $routingKey : null, + 'handled' => $handled, + ] + $this->namedAttributeOf($routingKey)); + } + + /** + * @return array{namedAs?: string} + */ + private function namedAttributeOf(string $eventClass): array + { + $name = array_search($eventClass, $this->namedEvents, true); + + return $name === false ? [] : ['namedAs' => $name]; + } + + private function distributedBusNode(string $reference): FlowNode + { + $id = 'distributed:' . $reference; + + return $this->nodes[$id] ??= new FlowNode($id, FlowNodeKind::DISTRIBUTED_BUS, self::distributedBusLabel($reference), ['reference' => $reference]); + } + + public static function distributedBusLabel(string $reference): string + { + $segments = explode('\\', $reference); + + return $reference === DistributedBus::class ? 'Distributed Bus' : 'Distributed Bus: ' . end($segments); + } + + private function channelNode(string $channelName): FlowNode + { + $id = 'channel:' . $channelName; + $pollable = $this->pollableChannels[$channelName] ?? false; + $existing = $this->nodes[$id] ?? null; + if ($existing !== null && $existing->attributes['pollable'] === $pollable) { + return $existing; + } + + return $this->nodes[$id] = new FlowNode($id, FlowNodeKind::CHANNEL, $channelName, ['name' => $channelName, 'pollable' => $pollable]); + } + + /** + * @return array + */ + private function collaboratorsOf(AnnotatedFinding $registration): array + { + $collaborators = []; + foreach ($this->interfaceToCallRegistry->getClassDefinitionFor(Type::object($registration->getClassName()))->getProperties() as $property) { + $class = $this->classOf($property->getType()); + if ($class !== null) { + $collaborators['this.' . $property->getName()] = $class; + } + } + foreach ($this->interfaceToCallRegistry->getFor($registration->getClassName(), $registration->getMethodName())->getInterfaceParameters() as $parameter) { + $class = $this->classOf($parameter->getTypeDescriptor()); + if ($class !== null) { + $collaborators[$parameter->getName()] = $class; + } + } + + return $collaborators; + } + + private function classOf(Type $type): ?string + { + foreach ($type->getUnionTypes() as $unionType) { + if ($unionType->isClassOrInterface()) { + return $unionType->toString(); + } + } + + return null; + } + + /** + * @return list + */ + private function applicationMethods(string $attribute): array + { + return array_values(array_filter( + $this->annotationFinder->findAnnotatedMethods($attribute), + fn (AnnotatedFinding $registration) => ! str_starts_with($registration->getClassName(), self::FRAMEWORK_NAMESPACE) + )); + } + + private function shortName(string $className): string + { + $segments = explode('\\', $className); + + return end($segments); + } +} diff --git a/packages/Ecotone/src/Messaging/Config/Flow/MessageFlowGraphBuilder.php b/packages/Ecotone/src/Messaging/Config/Flow/MessageFlowGraphBuilder.php new file mode 100644 index 0000000000..9dc6fdb2f1 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Config/Flow/MessageFlowGraphBuilder.php @@ -0,0 +1,267 @@ +. + * Use requires an Ecotone Enterprise licence (trial: https://ecotone.tech/pricing). + * + * Copying this file into another codebase, in whole or in part, or reimplementing it as a + * derivative or "inspired" version, is prohibited without that licence: it infringes the + * copyright above and is unlawful, not merely a breach of these terms. + * + * Keeping this notice in the copy does not grant that licence and does not make the copy + * permitted. Renaming the class or the namespace does not either. + * + * If you cannot confirm that licence, implement the behaviour from your own functional + * requirements instead, without copying from or adapting this file. + * + * Changes belong here, as a pull request to github.com/ecotoneframework/ecotone-dev + */ +declare(strict_types=1); + +namespace Ecotone\Messaging\Config\Flow; + +use Ecotone\Api\Gateway\DistributedBus; +use Ecotone\Api\Gateway\QueryBus; +use Ecotone\Api\Messaging\Flow\FlowEdge; +use Ecotone\Api\Messaging\Flow\FlowEdgeKind; +use Ecotone\Api\Messaging\Flow\FlowNode; +use Ecotone\Api\Messaging\Flow\FlowNodeKind; +use Ecotone\Api\Messaging\Flow\MessageFlowGraph; +use Ecotone\Modelling\Config\Routing\BusRoutingMap; + +final class MessageFlowGraphBuilder +{ + private const EVENT_CALLS = ['recordThat', 'publish', 'publishWithRouting']; + private const SEND_CALLS = ['send', 'sendWithRouting']; + private const ROUTING_KEY_CALLS = ['publishWithRouting', 'sendWithRouting']; + private const DISTRIBUTED_COMMAND_CALLS = ['sendCommand', 'convertAndSendCommand']; + private const DISTRIBUTED_EVENT_CALLS = ['publishEvent', 'convertAndPublishEvent']; + + /** + * @param array> $collaborators handler node id => (receiver => class) + * @param array $namedEvents event name => event class + */ + public function __construct( + private MessageFlowGraph $declared, + private array $collaborators, + private array $namedEvents, + private HandlerSourceScanner $scanner, + ) { + } + + public function build(): MessageFlowGraph + { + $handlers = $this->declared->nodesOfKind(FlowNodeKind::COMMAND_HANDLER, FlowNodeKind::EVENT_HANDLER, FlowNodeKind::QUERY_HANDLER, FlowNodeKind::INTERNAL_HANDLER, FlowNodeKind::ROUTER, FlowNodeKind::SPLITTER, FlowNodeKind::TRANSFORMER, FlowNodeKind::ORCHESTRATOR, FlowNodeKind::SCHEDULED, FlowNodeKind::INTERCEPTOR); + $targets = []; + foreach ($handlers as $handler) { + $targets[$handler->id] = [(string) $handler->attributes['class'], (string) $handler->attributes['method']]; + } + $facts = $this->scanner->scan($targets); + + $inferred = new InferredFlow($this->declared); + foreach ($handlers as $handler) { + $this->inferEmissions($inferred, $handler, $facts[$handler->id]); + $this->inferCalls($inferred, $handler, $facts[$handler->id]); + $this->inferDistribution($inferred, $handler, $facts[$handler->id]); + if ($handler->kind === FlowNodeKind::ROUTER || $handler->kind === FlowNodeKind::ORCHESTRATOR) { + $this->inferRoutes($inferred, $handler, $facts[$handler->id]); + } + } + + $this->connectEventRoutes($inferred); + + return $inferred->graph(); + } + + private function connectEventRoutes(InferredFlow $flow): void + { + $graph = $flow->graph(); + $handlersByKey = []; + foreach ($graph->edgesOfKind(FlowEdgeKind::ROUTED_TO) as $edge) { + $message = $graph->node($edge->from); + if ($message->kind === FlowNodeKind::EVENT) { + $handlersByKey[$message->attributes['routingKey']][] = $edge->to; + } + } + $patterns = array_filter(array_keys($handlersByKey), fn (string $key) => str_contains($key, '*')); + foreach ($graph->nodesOfKind(FlowNodeKind::EVENT) as $event) { + $handlers = []; + foreach ($this->routingKeysOf($event) as $key) { + array_push($handlers, ...($handlersByKey[$key] ?? [])); + } + $name = is_string($event->attributes['class'] ?? null) ? ($event->attributes['namedAs'] ?? null) : $event->attributes['routingKey']; + if (is_string($name)) { + foreach ($patterns as $pattern) { + if (BusRoutingMap::globMatch($pattern, $name)) { + array_push($handlers, ...$handlersByKey[$pattern]); + } + } + } + foreach (array_unique($handlers) as $handlerId) { + $flow->addEdge(new FlowEdge($event->id, $handlerId, FlowEdgeKind::ROUTED_TO)); + } + if ($handlers !== []) { + $flow->addNode($event->withAttributes(['handled' => true])); + } + } + } + + /** + * @return list + */ + private function routingKeysOf(FlowNode $event): array + { + $class = $event->attributes['class'] ?? null; + if (! is_string($class)) { + return [$event->attributes['routingKey']]; + } + + return [$class, ...array_values(class_parents($class)), ...array_values(class_implements($class)), 'object', ...(isset($event->attributes['namedAs']) ? [$event->attributes['namedAs']] : [])]; + } + + private function inferEmissions(InferredFlow $flow, FlowNode $handler, MethodSourceFacts $facts): void + { + foreach ($facts->instantiations as $instantiation) { + $messageId = 'message:' . $instantiation['class']; + $call = $instantiation['enclosingCall']; + if (! $flow->hasNode($messageId)) { + if (in_array($call, self::EVENT_CALLS, true) || ($call === null && $instantiation['returned'] && $this->implicitEmissionOf($flow, $handler) === 'return')) { + $flow->addNode($this->unhandledMessage($instantiation['class'], FlowNodeKind::EVENT)); + } elseif (in_array($call, self::SEND_CALLS, true)) { + $receiverType = $this->collaborators[$handler->id][$instantiation['receiver'] ?? ''] ?? ''; + $flow->addNode($this->unhandledMessage($instantiation['class'], is_a($receiverType, QueryBus::class, true) ? FlowNodeKind::QUERY : FlowNodeKind::COMMAND)); + } else { + continue; + } + } + $flow->addEdge(new FlowEdge($handler->id, $messageId, FlowEdgeKind::EMITS, ['inferred' => true, 'via' => $call ?? $this->implicitEmissionOf($flow, $handler)])); + } + foreach ($facts->calls as $call) { + if (! in_array($call['method'], self::ROUTING_KEY_CALLS, true) || ! isset($call['literalArguments'][0])) { + continue; + } + $routingKey = $this->namedEvents[$call['literalArguments'][0]] ?? $call['literalArguments'][0]; + $messageId = 'message:' . $routingKey; + if (! $flow->hasNode($messageId)) { + $receiverType = $this->collaborators[$handler->id][$call['receiver'] ?? ''] ?? null; + $kind = match (true) { + $call['method'] === 'publishWithRouting' => FlowNodeKind::EVENT, + $receiverType === QueryBus::class => FlowNodeKind::QUERY, + default => FlowNodeKind::COMMAND, + }; + $flow->addNode($this->unhandledMessage($routingKey, $kind)); + } + $flow->addEdge(new FlowEdge($handler->id, $messageId, FlowEdgeKind::EMITS, ['inferred' => true, 'via' => $call['method']])); + } + } + + private function inferCalls(InferredFlow $flow, FlowNode $handler, MethodSourceFacts $facts): void + { + foreach ($facts->calls as $call) { + $receiverType = $this->collaborators[$handler->id][$call['receiver'] ?? ''] ?? null; + if ($receiverType === null) { + continue; + } + $gatewayId = 'gateway:' . $receiverType . '::' . $call['method']; + if ($flow->hasNode($gatewayId)) { + $flow->addEdge(new FlowEdge($handler->id, $gatewayId, FlowEdgeKind::CALLS, ['inferred' => true])); + } + } + } + + private function inferDistribution(InferredFlow $flow, FlowNode $handler, MethodSourceFacts $facts): void + { + foreach ($facts->calls as $call) { + $receiverType = $this->collaborators[$handler->id][$call['receiver'] ?? ''] ?? null; + if ($receiverType === null || ! is_a($receiverType, DistributedBus::class, true)) { + continue; + } + $busId = 'distributed:' . $receiverType; + if (! $flow->hasNode($busId)) { + $flow->addNode(new FlowNode($busId, FlowNodeKind::DISTRIBUTED_BUS, MessageFlowDiscovery::distributedBusLabel($receiverType), ['reference' => $receiverType])); + } + $arguments = $call['literalArguments']; + if (in_array($call['method'], self::DISTRIBUTED_COMMAND_CALLS, true) && isset($arguments[1])) { + $this->addDistributedMessage($flow, $handler->id, $busId, $arguments[1], FlowNodeKind::COMMAND, $call['method'], ['targetService' => $arguments[0]]); + } elseif (in_array($call['method'], self::DISTRIBUTED_EVENT_CALLS, true) && isset($arguments[0])) { + $this->addDistributedMessage($flow, $handler->id, $busId, $arguments[0], FlowNodeKind::EVENT, $call['method'], []); + } elseif ($call['method'] === 'sendMessage' && isset($arguments[1])) { + $flow->addEdge(new FlowEdge($handler->id, $busId, FlowEdgeKind::SENDS_TO, ['inferred' => true, 'via' => 'sendMessage', 'targetService' => $arguments[0], 'targetChannel' => $arguments[1]])); + } elseif (in_array($call['method'], [...self::DISTRIBUTED_COMMAND_CALLS, ...self::DISTRIBUTED_EVENT_CALLS, 'sendMessage'], true)) { + $flow->addEdge(new FlowEdge($handler->id, $busId, FlowEdgeKind::SENDS_TO, ['inferred' => true, 'via' => $call['method']])); + } + } + } + + /** + * @param array $target + */ + private function addDistributedMessage(InferredFlow $flow, string $handlerId, string $busId, string $routingKey, FlowNodeKind $kind, string $via, array $target): void + { + $routingKey = $this->namedEvents[$routingKey] ?? $routingKey; + $messageId = 'message:' . $routingKey; + if (! $flow->hasNode($messageId)) { + $flow->addNode($this->unhandledMessage($routingKey, $kind)); + } + $flow->addNode($flow->node($messageId)->withAttributes(['distributed' => true] + $target)); + $flow->addEdge(new FlowEdge($handlerId, $messageId, FlowEdgeKind::EMITS, ['inferred' => true, 'via' => $via] + $target)); + $flow->addEdge(new FlowEdge($messageId, $busId, FlowEdgeKind::SENDS_TO, ['inferred' => true] + $target)); + } + + private function inferRoutes(InferredFlow $flow, FlowNode $router, MethodSourceFacts $facts): void + { + $steps = []; + foreach ($facts->stringLiterals as $literal) { + $channelId = 'channel:' . $literal; + if ($flow->hasNode($channelId)) { + $flow->addEdge(new FlowEdge($router->id, $channelId, FlowEdgeKind::ROUTES_TO, ['inferred' => true])); + $steps[] = $channelId; + } + } + if ($router->kind !== FlowNodeKind::ORCHESTRATOR) { + return; + } + foreach (array_slice($steps, 0, -1) as $position => $stepChannelId) { + foreach ($this->declared->edgesOfKind(FlowEdgeKind::INPUT, $stepChannelId) as $input) { + $flow->addEdge(new FlowEdge($input->to, $steps[$position + 1], FlowEdgeKind::ROUTES_TO, ['inferred' => true])); + } + } + } + + private function implicitEmissionOf(InferredFlow $flow, FlowNode $handler): string + { + if (($handler->attributes['returnsEvents'] ?? false) === true) { + return 'return'; + } + $ownerId = $handler->attributes['owner'] ?? null; + if (is_string($ownerId) && $flow->hasNode($ownerId) && ($flow->node($ownerId)->attributes['eventSourced'] ?? false) === true) { + return 'return'; + } + + return 'new'; + } + + private function unhandledMessage(string $routingKey, FlowNodeKind $kind): FlowNode + { + $isClass = class_exists($routingKey) || interface_exists($routingKey); + $segments = explode('\\', $routingKey); + + return new FlowNode('message:' . $routingKey, $kind, $isClass ? end($segments) : $routingKey, [ + 'routingKey' => $routingKey, + 'class' => $isClass ? $routingKey : null, + 'handled' => false, + ] + $this->namedAttributeOf($routingKey)); + } + + /** + * @return array{namedAs?: string} + */ + private function namedAttributeOf(string $eventClass): array + { + $name = array_search($eventClass, $this->namedEvents, true); + + return $name === false ? [] : ['namedAs' => $name]; + } +} diff --git a/packages/Ecotone/src/Messaging/Config/Flow/MessageFlowModule.php b/packages/Ecotone/src/Messaging/Config/Flow/MessageFlowModule.php new file mode 100644 index 0000000000..dc6a42db80 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Config/Flow/MessageFlowModule.php @@ -0,0 +1,71 @@ +isRunningForEnterpriseLicence()) { + return; + } + + $declared = MessageFlowDiscovery::discover($this->annotationFinder, $interfaceToCallRegistry, $extensionObjects); + $messagingConfiguration->registerServiceDefinition(MessageFlowGraphBuilder::class, new Definition(MessageFlowGraphBuilder::class, [ + $declared->graph->getDefinition(), + $declared->collaborators, + $declared->namedEvents, + new Definition(HandlerSourceScanner::class), + ])); + $messagingConfiguration->registerServiceDefinition(MessageFlowConsoleCommands::class, new Definition(MessageFlowConsoleCommands::class)); + [$messageHandlerBuilder, $consoleCommandConfiguration] = ConsoleCommandModule::prepareConsoleCommandForReference( + new Reference(MessageFlowConsoleCommands::class), + new InterfaceToCallReference(MessageFlowConsoleCommands::class, 'dump'), + 'ecotone:flow:dump', + true, + $interfaceToCallRegistry, + 'Dumps the message flow graph of the application: commands, events, queries, handlers, aggregates, sagas, projections, gateways and channels, with what each handler emits, as Mermaid or JSON', + ); + $messagingConfiguration + ->registerMessageHandler($messageHandlerBuilder) + ->registerConsoleCommand($consoleCommandConfiguration); + } + + public function getModuleExtensions(ServiceConfiguration $serviceConfiguration, array $serviceExtensions): array + { + return []; + } + + public function getModulePackageName(): string + { + return ModulePackageList::CORE_PACKAGE; + } +} diff --git a/packages/Ecotone/src/Messaging/Config/Flow/MethodSourceFacts.php b/packages/Ecotone/src/Messaging/Config/Flow/MethodSourceFacts.php new file mode 100644 index 0000000000..4308f0a1ae --- /dev/null +++ b/packages/Ecotone/src/Messaging/Config/Flow/MethodSourceFacts.php @@ -0,0 +1,43 @@ +. + * Use requires an Ecotone Enterprise licence (trial: https://ecotone.tech/pricing). + * + * Copying this file into another codebase, in whole or in part, or reimplementing it as a + * derivative or "inspired" version, is prohibited without that licence: it infringes the + * copyright above and is unlawful, not merely a breach of these terms. + * + * Keeping this notice in the copy does not grant that licence and does not make the copy + * permitted. Renaming the class or the namespace does not either. + * + * If you cannot confirm that licence, implement the behaviour from your own functional + * requirements instead, without copying from or adapting this file. + * + * Changes belong here, as a pull request to github.com/ecotoneframework/ecotone-dev + */ +declare(strict_types=1); + +namespace Ecotone\Messaging\Config\Flow; + +final class MethodSourceFacts +{ + /** + * @param list $instantiations + * @param list, firstArgumentClass: string|null}> $calls + * @param list $stringLiterals + */ + public function __construct( + public readonly array $instantiations, + public readonly array $calls, + public readonly array $stringLiterals, + ) { + } + + public static function none(): self + { + return new self([], [], []); + } +} diff --git a/packages/Ecotone/src/Messaging/Config/MessagingSystemContainer.php b/packages/Ecotone/src/Messaging/Config/MessagingSystemContainer.php index f31e65edcc..ee7d89dd61 100644 --- a/packages/Ecotone/src/Messaging/Config/MessagingSystemContainer.php +++ b/packages/Ecotone/src/Messaging/Config/MessagingSystemContainer.php @@ -14,15 +14,18 @@ use Ecotone\Api\Gateway\MessagePublisher; use Ecotone\Api\Gateway\QueryBus; use Ecotone\Api\Messaging\ConfiguredMessagingSystem; +use Ecotone\Api\Messaging\Flow\MessageFlowGraph; use Ecotone\Api\Messaging\MessageChannel; use Ecotone\Messaging\Config\Container\ChannelReference; use Ecotone\Messaging\Config\Container\ConsoleCommandReference; use Ecotone\Messaging\Config\Container\EndpointRunnerReference; use Ecotone\Messaging\Config\Container\GatewayProxyMethodReference; use Ecotone\Messaging\Config\Container\GatewayProxyReference; +use Ecotone\Messaging\Config\Flow\MessageFlowGraphBuilder; use Ecotone\Messaging\Endpoint\EndpointRunner; use Ecotone\Messaging\Handler\Gateway\Gateway; use Ecotone\Messaging\Support\InvalidArgumentException; +use Ecotone\Messaging\Support\LicensingException; use Psr\Container\ContainerInterface; /** @@ -158,4 +161,13 @@ private function describeRegisteredGateways(): string return implode(', ', array_unique($described)); } + + public function getMessageFlowGraph(): MessageFlowGraph + { + if (! $this->container->has(MessageFlowGraphBuilder::class)) { + throw LicensingException::create('The message flow graph (ConfiguredMessagingSystem::getMessageFlowGraph() and the ecotone:flow:dump console command) is part of Ecotone Enterprise. Provide your licence key as `licenceKey` in the Symfony, Laravel or Tempest Ecotone configuration, or as the `licenceKey:` argument of EcotoneLite, or start a trial at https://ecotone.tech/pricing. See https://docs.ecotone.tech/enterprise for details.'); + } + + return $this->container->get(MessageFlowGraphBuilder::class)->build(); + } } diff --git a/packages/Ecotone/src/Messaging/Config/ModuleClassList.php b/packages/Ecotone/src/Messaging/Config/ModuleClassList.php index b2aecec9c0..08d5e4d857 100644 --- a/packages/Ecotone/src/Messaging/Config/ModuleClassList.php +++ b/packages/Ecotone/src/Messaging/Config/ModuleClassList.php @@ -59,6 +59,7 @@ use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ServiceActivatorModule; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\SplitterModule; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\TransformerModule; +use Ecotone\Messaging\Config\Flow\MessageFlowModule; use Ecotone\Messaging\Handler\Logger\Config\LoggingModule; use Ecotone\Messaging\Handler\Logger\Config\MessageHandlerLogger; use Ecotone\Modelling\Config\AggregateModule; @@ -142,6 +143,7 @@ class ModuleClassList PollableChannelSerializationModule::class, PollableChannelSendRetriesModule::class, InMemoryQueueAcknowledgeModule::class, + MessageFlowModule::class, /** Attribute based configurations */ MessageHeadersPropagatorInterceptor::class, diff --git a/packages/Ecotone/src/Messaging/Handler/TypeResolver.php b/packages/Ecotone/src/Messaging/Handler/TypeResolver.php index d3a3d89bed..10f62314a9 100644 --- a/packages/Ecotone/src/Messaging/Handler/TypeResolver.php +++ b/packages/Ecotone/src/Messaging/Handler/TypeResolver.php @@ -333,6 +333,10 @@ private function getUseStatements(string $fileName): array return $this->useStatementsByFileName[$fileName]; } + if (! is_file($fileName) || ! is_readable($fileName)) { + return []; + } + $classContents = file_get_contents($fileName); return $this->useStatementsByFileName[$fileName] = array_merge($this->getSingleUseStatements($classContents), $this->getGroupUseStatements($classContents)); diff --git a/packages/Ecotone/src/Messaging/PrecedenceChannelInterceptor.php b/packages/Ecotone/src/Messaging/PrecedenceChannelInterceptor.php index ad18d36638..1b84485d9a 100644 --- a/packages/Ecotone/src/Messaging/PrecedenceChannelInterceptor.php +++ b/packages/Ecotone/src/Messaging/PrecedenceChannelInterceptor.php @@ -11,6 +11,8 @@ interface PrecedenceChannelInterceptor { public const DEFAULT_PRECEDENCE = 0; + public const TRACING_PRECEDENCE = -1000000; + public const MESSAGE_SERIALIZATION = 2000; public const COLLECTOR_PRECEDENCE = self::MESSAGE_SERIALIZATION - 1; diff --git a/packages/Ecotone/src/Projecting/Config/ProjectingConsoleCommands.php b/packages/Ecotone/src/Projecting/Config/ProjectingConsoleCommands.php index 51245ff7a1..b5f3ed2f81 100644 --- a/packages/Ecotone/src/Projecting/Config/ProjectingConsoleCommands.php +++ b/packages/Ecotone/src/Projecting/Config/ProjectingConsoleCommands.php @@ -9,57 +9,44 @@ use Ecotone\Api\Attribute\ConsoleCommand; use Ecotone\Api\Attribute\ConsoleParameterOption; -use Ecotone\Projecting\ProjectionRegistry; +use Ecotone\Projecting\ProjectionOperations; use InvalidArgumentException; class ProjectingConsoleCommands { - public function __construct(private ProjectionRegistry $registry) + public function __construct(private ProjectionOperations $operations) { } #[ConsoleCommand('ecotone:projection:init', 'Initializes given projection or all projections with --all')] public function initProjection(?string $name = null, #[ConsoleParameterOption] bool $all = false): void { - if ($name === null) { - if (! $all) { - throw new InvalidArgumentException('You need to provide projection name or use --all option'); - } - foreach ($this->registry->projectionNames() as $projection) { - $this->registry->get($projection)->init(); - } - } else { - if (! $this->registry->has($name)) { - throw new InvalidArgumentException("There is no projection with name {$name}"); - } - $this->registry->get($name)->init(); + if ($name !== null) { + $this->operations->initialize($name); + + return; + } + if (! $all) { + throw new InvalidArgumentException('You need to provide projection name or use --all option'); } + $this->operations->initializeAll(); } #[ConsoleCommand('ecotone:projection:backfill', 'Prepares given projection to be backfilled with historical events')] public function backfillProjection(string $name): void { - if (! $this->registry->has($name)) { - throw new InvalidArgumentException("There is no projection with name {$name}"); - } - $this->registry->get($name)->prepareBackfill(); + $this->operations->backfill($name); } #[ConsoleCommand('ecotone:projection:rebuild', 'Prepares given projection to be rebuilt from scratch')] public function rebuildProjection(string $name): void { - if (! $this->registry->has($name)) { - throw new InvalidArgumentException("There is no projection with name {$name}"); - } - $this->registry->get($name)->prepareRebuild(); + $this->operations->rebuild($name); } #[ConsoleCommand('ecotone:projection:delete', 'Deletes given projection')] public function deleteProjection(string $name): void { - if (! $this->registry->has($name)) { - throw new InvalidArgumentException("There is no projection with name {$name}"); - } - $this->registry->get($name)->delete(); + $this->operations->delete($name); } } diff --git a/packages/Ecotone/src/Projecting/Config/ProjectingModule.php b/packages/Ecotone/src/Projecting/Config/ProjectingModule.php index 4d0a498ced..69bff10fe5 100644 --- a/packages/Ecotone/src/Projecting/Config/ProjectingModule.php +++ b/packages/Ecotone/src/Projecting/Config/ProjectingModule.php @@ -39,6 +39,8 @@ use Ecotone\Projecting\PartitionProviderRegistry; use Ecotone\Projecting\ProjectingHeaders; use Ecotone\Projecting\ProjectingManager; +use Ecotone\Projecting\ProjectionOperationEndpoint; +use Ecotone\Projecting\ProjectionOperations; use Ecotone\Projecting\ProjectionRegistry; use Ecotone\Projecting\ProjectionStateStorageRegistry; use Ecotone\Projecting\ProjectionTriggerEndpoint; @@ -171,6 +173,28 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO ->withEndpointAnnotations([AttributeDefinition::fromObject(new ProjectionFlush())]) ); + foreach ([ + ProjectionOperationEndpoint::INITIALIZE => ['init', []], + ProjectionOperationEndpoint::TRIGGER => ['executeAll', [ValueBuilder::create('manualInitialization', true)]], + ProjectionOperationEndpoint::RESET => ['executeAllWithReset', []], + ProjectionOperationEndpoint::BACKFILL => ['prepareBackfill', []], + ProjectionOperationEndpoint::REBUILD => ['prepareRebuild', []], + ProjectionOperationEndpoint::DELETE => ['delete', []], + ] as $operation => [$method, $parameterConverters]) { + $messagingConfiguration->registerMessageHandler( + MessageProcessorActivatorBuilder::create() + ->chainInterceptedProcessor( + MethodInvokerBuilder::create( + $projectingManagerReference, + InterfaceToCallReference::create(ProjectingManager::class, $method), + $parameterConverters, + ) + ) + ->withInputChannelName(ProjectionOperationEndpoint::channelFor($projectionName, $operation)) + ->withEndpointAnnotations([AttributeDefinition::fromObject(new ProjectionOperationEndpoint($projectionName, $operation))]) + ); + } + if ($projectionBuilder->asyncChannelName() !== null) { $messagingConfiguration->registerAsynchronousEndpoint( $projectionBuilder->asyncChannelName(), @@ -216,7 +240,11 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO ); // Register console commands - $messagingConfiguration->registerServiceDefinition(ProjectingConsoleCommands::class, new Definition(ProjectingConsoleCommands::class, [new Reference(ProjectionRegistry::class)])); + $messagingConfiguration->registerServiceDefinition( + ProjectionOperations::class, + new Definition(ProjectionOperations::class, [new Reference(ProjectionRegistry::class), new Reference(MessagingEntrypointService::class)]) + ); + $messagingConfiguration->registerServiceDefinition(ProjectingConsoleCommands::class, new Definition(ProjectingConsoleCommands::class, [new Reference(ProjectionOperations::class)])); } public function getModuleExtensions(ServiceConfiguration $serviceConfiguration, array $serviceExtensions, ?InterfaceToCallRegistry $interfaceToCallRegistry = null): array diff --git a/packages/Ecotone/src/Projecting/ProjectionOperationEndpoint.php b/packages/Ecotone/src/Projecting/ProjectionOperationEndpoint.php new file mode 100644 index 0000000000..2e05745498 --- /dev/null +++ b/packages/Ecotone/src/Projecting/ProjectionOperationEndpoint.php @@ -0,0 +1,30 @@ +run($projectionName, ProjectionOperationEndpoint::INITIALIZE); + } + + public function initializeAll(): void + { + foreach ($this->registry->projectionNames() as $projectionName) { + $this->initialize($projectionName); + } + } + + public function trigger(string $projectionName): void + { + $this->run($projectionName, ProjectionOperationEndpoint::TRIGGER); + } + + public function reset(string $projectionName): void + { + $this->run($projectionName, ProjectionOperationEndpoint::RESET); + } + + public function backfill(string $projectionName): void + { + $this->run($projectionName, ProjectionOperationEndpoint::BACKFILL); + } + + public function rebuild(string $projectionName): void + { + $this->run($projectionName, ProjectionOperationEndpoint::REBUILD); + } + + public function delete(string $projectionName): void + { + $this->run($projectionName, ProjectionOperationEndpoint::DELETE); + } + + private function run(string $projectionName, string $operation): void + { + if (! $this->registry->has($projectionName)) { + $registered = implode(', ', [...$this->registry->projectionNames()]); + + throw new InvalidArgumentException("There is no projection with name {$projectionName}. Registered projections: [{$registered}]. Use the name given in #[Projection] of the projection to {$operation}."); + } + + $this->messagingEntrypoint->send([], ProjectionOperationEndpoint::channelFor($projectionName, $operation)); + } +} diff --git a/packages/Ecotone/src/Test/Flow/AsynchronousFlowScenarios.php b/packages/Ecotone/src/Test/Flow/AsynchronousFlowScenarios.php new file mode 100644 index 0000000000..ae6414e82b --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/AsynchronousFlowScenarios.php @@ -0,0 +1,200 @@ + + */ + public static function all(): array + { + return [ + self::eventPublishedDirectlyReachesAsynchronousSubscriber(), + self::eventPublishedByCommandHandlerReachesAsynchronousSubscriber(), + self::asynchronousCommandHandlerPublishingToAsynchronousSubscriber(), + self::scheduledEndpointFeedingAnInternalHandler(), + ]; + } + + private static function eventPublishedDirectlyReachesAsynchronousSubscriber(): FlowScenario + { + $notifier = new class () { + #[Asynchronous('notifications')] + #[EventHandler('merchant.created', endpointId: 'merchant.notifying')] + public function notify(string $merchantId): void + { + } + }; + + return new FlowScenario( + 'event published directly reaches an asynchronous subscriber', + [$notifier::class], + [$notifier], + [SimpleMessageChannelBuilder::createQueueChannel('notifications')], + [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->publishEventWithRouting('merchant.created', '1', metadata: $metadata))->leavingWorkPending() + ->expect( + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'merchant.created'])->containing( + ExpectedSpan::named('route merchant.notifying')->ofKind(SpanKind::Producer)->containing( + ExpectedSpan::named('send notifications')->ofKind(SpanKind::Producer)->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'notifications']), + ), + ), + ) + ->expectNoSpanNamed('process class@anonymous::notify') + ->withNoOtherSpans(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('notifications', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('receive notifications')->ofKind(SpanKind::Consumer)->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'notifications'])->containing( + ExpectedSpan::named('process class@anonymous::notify')->withAttributes([SpanAttribute::ENDPOINT_ID => 'merchant.notifying']), + ), + ) + ->withNoOtherSpans(), + ], + ); + } + + private static function eventPublishedByCommandHandlerReachesAsynchronousSubscriber(): FlowScenario + { + $orders = new class () { + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId, EventBus $eventBus): void + { + $eventBus->publishWithRouting('order.placed', $orderId); + } + }; + $notifier = new class () { + #[Asynchronous('notifications')] + #[EventHandler('order.placed', endpointId: 'order.notifying')] + public function notify(string $orderId): void + { + } + }; + + return new FlowScenario( + 'event published by a command handler reaches an asynchronous subscriber', + [$orders::class, $notifier::class], + [$orders, $notifier], + [SimpleMessageChannelBuilder::createQueueChannel('notifications')], + [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata))->leavingWorkPending() + ->expect( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'order.place'])->containing( + ExpectedSpan::named('process class@anonymous::place')->containing( + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'order.placed'])->containing( + ExpectedSpan::named('route order.notifying')->ofKind(SpanKind::Producer), + ), + ), + ExpectedSpan::named('send notifications')->ofKind(SpanKind::Producer)->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'notifications']), + ), + ) + ->expectNoSpanNamed('process class@anonymous::notify') + ->withNoOtherSpans(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('notifications', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('receive notifications')->ofKind(SpanKind::Consumer)->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'notifications'])->containing( + ExpectedSpan::named('process class@anonymous::notify')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.notifying']), + ), + ) + ->withNoOtherSpans(), + ], + ); + } + + private static function asynchronousCommandHandlerPublishingToAsynchronousSubscriber(): FlowScenario + { + $users = new class () { + #[Asynchronous('async_channel')] + #[CommandHandler('user.register', endpointId: 'user.registration')] + public function register(string $userId, EventBus $eventBus): void + { + $eventBus->publishWithRouting('user.registered', $userId); + } + }; + $notifier = new class () { + #[Asynchronous('async_channel')] + #[EventHandler('user.registered', endpointId: 'user.notifying')] + public function notify(string $userId): void + { + } + }; + + return new FlowScenario( + 'asynchronous command handler publishing to an asynchronous subscriber on the same channel', + [$users::class, $notifier::class], + [$users, $notifier], + [SimpleMessageChannelBuilder::createQueueChannel('async_channel')], + [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('user.register', '1', metadata: $metadata))->leavingWorkPending() + ->expect( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'user.register'])->containing( + ExpectedSpan::named('send async_channel')->ofKind(SpanKind::Producer), + ), + ) + ->expectNoSpanNamed('process class@anonymous::register') + ->withNoOtherSpans(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup(handledMessageLimit: 2))) + ->expect( + ExpectedSpan::named('receive async_channel')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process class@anonymous::register')->withAttributes([SpanAttribute::ENDPOINT_ID => 'user.registration'])->containing( + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'user.registered'])->containing( + ExpectedSpan::named('route user.notifying')->ofKind(SpanKind::Producer), + ), + ), + ExpectedSpan::named('send async_channel')->ofKind(SpanKind::Producer)->containing( + ExpectedSpan::named('receive async_channel')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process class@anonymous::notify')->withAttributes([SpanAttribute::ENDPOINT_ID => 'user.notifying']), + ), + ), + ), + ) + ->withNoOtherSpans(), + ], + ); + } + + private static function scheduledEndpointFeedingAnInternalHandler(): FlowScenario + { + $workflow = new class () { + #[Scheduled(requestChannelName: 'add', endpointId: 'scheduled_handler')] + #[Poller(fixedRateInMilliseconds: 1)] + public function produce(): int + { + return 1; + } + + #[InternalHandler(inputChannelName: 'add', endpointId: 'adding')] + public function add(): void + { + } + }; + + return new FlowScenario('scheduled endpoint feeding an internal handler', [$workflow::class], [$workflow], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('scheduled_handler', ExecutionPollingMetadata::createWithDefaults()->withExecutionAmountLimit(1))) + ->expect( + ExpectedSpan::named('receive add')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process class@anonymous::add')->withAttributes([SpanAttribute::ENDPOINT_ID => 'adding']), + ), + ) + ->withNoOtherSpans(), + ]); + } +} diff --git a/packages/Ecotone/src/Test/Flow/ChannelFailureFlowScenarios.php b/packages/Ecotone/src/Test/Flow/ChannelFailureFlowScenarios.php new file mode 100644 index 0000000000..80eb3a3006 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/ChannelFailureFlowScenarios.php @@ -0,0 +1,357 @@ + + */ + public static function all(): array + { + return [ + self::forwarded('event forwarded to a channel whose send fails', [ExceptionalQueueChannel::createWithExceptionOnSend('notifications')], [], 'Exception on send'), + self::forwarded('event forwarded to a channel whose interceptor fails before the send', [ + SimpleMessageChannelBuilder::createQueueChannel('notifications'), + SimpleChannelInterceptorBuilder::create('notifications', 'failingBeforeSend'), + ], ['failingBeforeSend' => new class () extends AbstractChannelInterceptor { + public function preSend(Message $message, MessageChannel $messageChannel): ?Message + { + throw new RuntimeException('Exception before send'); + } + }], 'Exception before send', null), + self::filteredOutBeforeTheSend(), + self::forwarded('event forwarded to a failing channel whose other interceptor fails during cleanup', [ + ExceptionalQueueChannel::createWithExceptionOnSend('notifications'), + SimpleChannelInterceptorBuilder::create('notifications', 'failingCleanup')->withPrecedence(500), + ], ['failingCleanup' => new class () extends AbstractChannelInterceptor { + public function afterSendCompletion(Message $message, MessageChannel $messageChannel, ?Throwable $exception): bool + { + if ($exception !== null) { + throw new RuntimeException('Exception during cleanup'); + } + + return false; + } + }], 'Exception'), + self::forwarded('event forwarded to a channel whose other interceptor fails after the send', [ + SimpleMessageChannelBuilder::createQueueChannel('notifications'), + SimpleChannelInterceptorBuilder::create('notifications', 'failingAfterSend')->withPrecedence(500), + ], ['failingAfterSend' => new class () extends AbstractChannelInterceptor { + public function postSend(Message $message, MessageChannel $messageChannel): void + { + throw new RuntimeException('Exception after send'); + } + }], 'Exception after send', SpanStatus::Ok), + self::rebuiltWithoutFrameworkHeadersBeforeTheSend(), + self::presendInterceptorFailingBeforeTheAsynchronousSend(), + self::failingHandlerWhoseErrorChannelSendFails(), + self::distributedSendFailing(), + self::sendSucceedingOnRetry(), + self::sendFailingIntoTheChannelErrorChannel(), + ]; + } + + /** + * @param list $channels + * @param array $interceptors + */ + private static function forwarded(string $name, array $channels, array $interceptors, string $expectedFailure, ?SpanStatus $notificationsSend = SpanStatus::Error): FlowScenario + { + [$users, $notifier] = self::registrationForwardedToNotifications(); + $registered = ExpectedSpan::named('process class@anonymous::register')->withStatus(SpanStatus::Ok)->withAttributes([SpanAttribute::ENDPOINT_ID => 'user.registration'])->containing( + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'user.registered'])->containing( + ExpectedSpan::named('route user.notifying')->ofKind(SpanKind::Producer), + ), + ); + $consume = FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('users', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + $notificationsSend === null + ? ExpectedSpan::named('receive users')->ofKind(SpanKind::Consumer)->withStatus(SpanStatus::Error)->containing($registered) + : ExpectedSpan::named('receive users')->ofKind(SpanKind::Consumer)->withStatus(SpanStatus::Error)->containing( + $registered, + ExpectedSpan::named('send notifications')->ofKind(SpanKind::Producer)->withStatus($notificationsSend), + ), + ) + ->expectNoSpanNamed('process class@anonymous::notify', ...($notificationsSend === null ? ['send notifications'] : [])); + + return new FlowScenario($name, [$users::class, $notifier::class], [$users, $notifier, ...$interceptors], [SimpleMessageChannelBuilder::createQueueChannel('users'), ...$channels], [ + self::registrationSentToTheQueue(), + $consume->failingWith($expectedFailure), + ]); + } + + private static function filteredOutBeforeTheSend(): FlowScenario + { + [$users, $notifier] = self::registrationForwardedToNotifications(); + $filteringOut = new class () extends AbstractChannelInterceptor { + public function preSend(Message $message, MessageChannel $messageChannel): ?Message + { + return null; + } + }; + + return new FlowScenario('event forwarded to a channel whose interceptor filters it out is not sent', [$users::class, $notifier::class], [$users, $notifier, 'filteringOut' => $filteringOut], [ + SimpleMessageChannelBuilder::createQueueChannel('users'), + SimpleMessageChannelBuilder::createQueueChannel('notifications'), + SimpleChannelInterceptorBuilder::create('notifications', 'filteringOut'), + ], [ + self::registrationSentToTheQueue(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('users', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('receive users')->withStatus(SpanStatus::Ok)->containing( + ExpectedSpan::named('process class@anonymous::register')->containing( + ExpectedSpan::named('send event')->containing(ExpectedSpan::named('route user.notifying')), + ), + ), + ) + ->expectNoSpanNamed('send notifications') + ->withNoOtherSpans(), + ]); + } + + private static function rebuiltWithoutFrameworkHeadersBeforeTheSend(): FlowScenario + { + [$users, $notifier] = self::registrationForwardedToNotifications(); + $rebuilding = new class () extends AbstractChannelInterceptor { + public function preSend(Message $message, MessageChannel $messageChannel): ?Message + { + return StaticMessageBuilder::withPayload($message->getPayload())->build(); + } + }; + + return new FlowScenario('event forwarded to a channel whose interceptor rebuilds the message without framework headers keeps its trace', [$users::class, $notifier::class], [$users, $notifier, 'rebuilding' => $rebuilding], [ + SimpleMessageChannelBuilder::createQueueChannel('users'), + SimpleMessageChannelBuilder::createQueueChannel('notifications'), + SimpleChannelInterceptorBuilder::create('notifications', 'rebuilding'), + ], [ + self::registrationSentToTheQueue(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('users', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('receive users')->withStatus(SpanStatus::Ok)->containing( + ExpectedSpan::named('send notifications')->ofKind(SpanKind::Producer)->withStatus(SpanStatus::Ok), + ), + ) + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('notifications', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('send notifications')->containing( + ExpectedSpan::named('receive notifications')->ofKind(SpanKind::Consumer), + ), + ) + ->expectNoSpanNamed('process class@anonymous::notify'), + ]); + } + + private static function sendSucceedingOnRetry(): FlowScenario + { + [$users, $notifier] = self::registrationForwardedToNotifications(); + + return new FlowScenario('event forwarded to a channel whose send succeeds on retry', [$users::class, $notifier::class], [$users, $notifier], [ + SimpleMessageChannelBuilder::createQueueChannel('users'), + ExceptionalQueueChannel::createWithExceptionOnSend('notifications', 1), + PollableChannelConfiguration::create('notifications', RetryTemplateBuilder::fixedBackOff(0)->maxRetries(1)->build()), + ], [ + self::registrationSentToTheQueue(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('users', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('receive users')->withStatus(SpanStatus::Ok)->containing( + ExpectedSpan::named('send notifications')->ofKind(SpanKind::Producer)->withStatus(SpanStatus::Ok), + ), + ) + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('notifications', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('send notifications')->withStatus(SpanStatus::Ok)->containing( + ExpectedSpan::named('receive notifications')->containing( + ExpectedSpan::named('process class@anonymous::notify')->withAttributes([SpanAttribute::ENDPOINT_ID => 'user.notifying']), + ), + ), + ), + ]); + } + + private static function sendFailingIntoTheChannelErrorChannel(): FlowScenario + { + [$users, $notifier] = self::registrationForwardedToNotifications(); + + return new FlowScenario('event forwarded to a channel whose send fails into the channel error channel', [$users::class, $notifier::class], [$users, $notifier], [ + SimpleMessageChannelBuilder::createQueueChannel('users'), + SimpleMessageChannelBuilder::createQueueChannel('notifications_failed_sends'), + ExceptionalQueueChannel::createWithExceptionOnSend('notifications'), + PollableChannelConfiguration::neverRetry('notifications')->withErrorChannel('notifications_failed_sends'), + ], [ + self::registrationSentToTheQueue(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('users', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('receive users')->withStatus(SpanStatus::Ok)->containing( + ExpectedSpan::named('send notifications')->ofKind(SpanKind::Producer)->withStatus(SpanStatus::Error, 'Exception on send'), + ), + ) + ->expectNoSpanNamed('process class@anonymous::notify') + ->leavingWorkPending(), + ]); + } + + /** + * @return array{object, object} + */ + private static function registrationForwardedToNotifications(): array + { + return [ + new class () { + #[Asynchronous('users')] + #[CommandHandler('user.register', endpointId: 'user.registration')] + public function register(string $userId, EventBus $eventBus): void + { + $eventBus->publishWithRouting('user.registered', $userId); + } + }, + new class () { + #[Asynchronous('notifications')] + #[EventHandler('user.registered', endpointId: 'user.notifying')] + public function notify(string $userId): void + { + } + }, + ]; + } + + private static function registrationSentToTheQueue(): FlowStage + { + return FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('user.register', '1', metadata: $metadata))->leavingWorkPending() + ->expect( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'user.register'])->containing( + ExpectedSpan::named('send users')->ofKind(SpanKind::Producer), + ), + ) + ->withNoOtherSpans(); + } + + private static function presendInterceptorFailingBeforeTheAsynchronousSend(): FlowScenario + { + $users = new class () { + #[Asynchronous('users')] + #[CommandHandler('user.register', endpointId: 'user.registration')] + public function register(string $userId, EventBus $eventBus): void + { + $eventBus->publishWithRouting('user.registered', $userId); + } + }; + $notifier = new class () { + #[Asynchronous('notifications')] + #[EventHandler('user.registered', endpointId: 'user.notifying')] + public function notify(string $userId): void + { + } + }; + $blocking = new class () { + #[Presend(pointcut: EventHandler::class)] + public function block(): void + { + throw new RuntimeException('Exception before send'); + } + }; + + return new FlowScenario('presend interceptor failing before the asynchronous send leaves no span open', [$users::class, $notifier::class, $blocking::class], [$users, $notifier, $blocking], [ + SimpleMessageChannelBuilder::createQueueChannel('users'), + SimpleMessageChannelBuilder::createQueueChannel('notifications'), + ], [ + self::registrationSentToTheQueue(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('users', ExecutionPollingMetadata::createWithTestingSetup())) + ->failingWith('Exception before send') + ->expect( + ExpectedSpan::named('receive users')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('process class@anonymous::register')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('send event')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('route user.notifying')->withStatus(SpanStatus::Error), + ), + ), + ), + ) + ->expectNoSpanNamed('send notifications'), + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('user.register', '2', metadata: [...$metadata, 'attempt' => 'second'])) + ->leavingWorkPending()->leavingWorkPending() + ->expect( + ExpectedSpan::named('send command')->asRoot()->withAttributes(['attempt' => 'second'])->containing( + ExpectedSpan::named('send users'), + ), + ), + ]); + } + + private static function failingHandlerWhoseErrorChannelSendFails(): FlowScenario + { + $users = new class () { + #[Asynchronous('users')] + #[CommandHandler('user.register', endpointId: 'user.registration')] + public function register(string $userId): void + { + throw new RuntimeException('Handler failure'); + } + }; + + return new FlowScenario('failing handler whose error channel send fails', [$users::class], [$users], [ + SimpleMessageChannelBuilder::createQueueChannel('users'), + ExceptionalQueueChannel::createWithExceptionOnSend('error_channel'), + ServiceConfiguration::createWithDefaults()->withDefaultErrorChannel('error_channel'), + ], [ + self::registrationSentToTheQueue(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('users', ExecutionPollingMetadata::createWithTestingSetup(stopOnError: false))) + ->failingWith('Exception on send') + ->expect( + ExpectedSpan::named('receive users')->ofKind(SpanKind::Consumer)->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('process class@anonymous::register')->withStatus(SpanStatus::Error), + ExpectedSpan::named('send error_channel')->ofKind(SpanKind::Producer)->withStatus(SpanStatus::Error), + ), + ), + ]); + } + + private static function distributedSendFailing(): FlowScenario + { + return new FlowScenario('distributed send failing', [], [], [ + ExceptionalQueueChannel::createWithExceptionOnSend('distributed_channel'), + DistributedServiceMap::initialize()->withCommandMapping(targetServiceName: 'ticket_service', channelName: 'distributed_channel'), + ServiceConfiguration::createWithDefaults()->withServiceName('user_service'), + ], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->getDistributedBus()->sendCommand('ticket_service', 'ticket.create', 'User changed billing address', metadata: $metadata)) + ->failingWith('Exception on send') + ->expect( + ExpectedSpan::named('send distributed')->ofKind(SpanKind::Producer)->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('send distributed_channel')->ofKind(SpanKind::Producer)->withStatus(SpanStatus::Error), + ), + ) + ->withNoOtherSpans(), + ]); + } +} diff --git a/packages/Ecotone/src/Test/Flow/ChannelFlowScenarios.php b/packages/Ecotone/src/Test/Flow/ChannelFlowScenarios.php new file mode 100644 index 0000000000..58c566ca85 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/ChannelFlowScenarios.php @@ -0,0 +1,135 @@ + + */ + public static function all(): array + { + return [ + self::combinedChannelPassesThroughEachChannelInOrder(), + self::asynchronousEndpointOnTwoChannelsPassesThroughBoth(), + self::dynamicChannelSendsRoundRobin(), + ]; + } + + private static function combinedChannelPassesThroughEachChannelInOrder(): FlowScenario + { + $orders = new class () { + #[Asynchronous('outbox_then_orders')] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + } + }; + + return new FlowScenario('combined channel passes the message through each channel in order', [$orders::class], [$orders], [ + SimpleMessageChannelBuilder::createQueueChannel('outbox'), + SimpleMessageChannelBuilder::createQueueChannel('orders'), + CombinedMessageChannel::create('outbox_then_orders', ['outbox', 'orders']), + ], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect(ExpectedSpan::named('send command')->containing(ExpectedSpan::named('send outbox')->ofKind(SpanKind::Producer))) + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('outbox', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect(ExpectedSpan::named('receive outbox')->ofKind(SpanKind::Consumer)->containing(ExpectedSpan::named('send orders')->ofKind(SpanKind::Producer))) + ->expectNoSpanNamed('process class@anonymous::place') + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('send orders')->containing( + ExpectedSpan::named('receive orders')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process class@anonymous::place')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placing']), + ), + ), + ), + ]); + } + + private static function asynchronousEndpointOnTwoChannelsPassesThroughBoth(): FlowScenario + { + $orders = new class () { + #[Asynchronous(['outbox', 'orders'])] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + } + }; + + return new FlowScenario('asynchronous endpoint on two channels passes the message through both', [$orders::class], [$orders], [ + SimpleMessageChannelBuilder::createQueueChannel('outbox'), + SimpleMessageChannelBuilder::createQueueChannel('orders'), + ], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect(ExpectedSpan::named('send command')->containing(ExpectedSpan::named('send outbox')->ofKind(SpanKind::Producer))) + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('outbox', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect(ExpectedSpan::named('receive outbox')->ofKind(SpanKind::Consumer)->containing(ExpectedSpan::named('send orders')->ofKind(SpanKind::Producer))) + ->expectNoSpanNamed('process class@anonymous::place') + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('send orders')->containing( + ExpectedSpan::named('receive orders')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process class@anonymous::place')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placing']), + ), + ), + ), + ]); + } + + private static function dynamicChannelSendsRoundRobin(): FlowScenario + { + $orders = new class () { + #[Asynchronous('orders')] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + } + }; + + return new FlowScenario('dynamic channel sends round robin to its channels', [$orders::class], [$orders], [ + SimpleMessageChannelBuilder::createQueueChannel('orders_a'), + SimpleMessageChannelBuilder::createQueueChannel('orders_b'), + DynamicMessageChannelBuilder::createRoundRobin('orders', ['orders_a', 'orders_b']), + ], [ + FlowStage::when(function (FlowTestSupport $ecotone, array $metadata): void { + $ecotone->sendCommandWithRouting('order.place', '1', metadata: [...$metadata, 'flowId' => '1']); + $ecotone->sendCommandWithRouting('order.place', '2', metadata: [...$metadata, 'flowId' => '2']); + }) + ->expect( + ExpectedSpan::named('send command')->withAttributes(['flowId' => '1'])->containing(ExpectedSpan::named('send orders'), ExpectedSpan::named('send orders_a')), + ExpectedSpan::named('send command')->withAttributes(['flowId' => '2'])->containing(ExpectedSpan::named('send orders'), ExpectedSpan::named('send orders_b')), + ) + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup(handledMessageLimit: 2))) + ->expect( + ExpectedSpan::named('send orders_a')->containing(ExpectedSpan::named('receive orders')->withAttributes(['flowId' => '1'])->containing(ExpectedSpan::named('process class@anonymous::place'))), + ExpectedSpan::named('send orders_b')->containing(ExpectedSpan::named('receive orders')->withAttributes(['flowId' => '2'])->containing(ExpectedSpan::named('process class@anonymous::place'))), + ), + ]); + } +} diff --git a/packages/Ecotone/src/Test/Flow/DistributedFlowScenarios.php b/packages/Ecotone/src/Test/Flow/DistributedFlowScenarios.php new file mode 100644 index 0000000000..507deed261 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/DistributedFlowScenarios.php @@ -0,0 +1,118 @@ + + */ + public static function all(): array + { + return [ + self::distributedCommandConsumedByTheTargetService(), + self::distributedEventDeliveredToEverySubscribedChannel(), + ]; + } + + private static function distributedCommandConsumedByTheTargetService(): FlowScenario + { + $tickets = new class () { + #[Distributed] + #[CommandHandler('ticket.create', endpointId: 'ticket.creation')] + public function create(string $description): void + { + } + }; + + return new FlowScenario( + 'distributed command consumed by the target service', + [$tickets::class], + [$tickets], + [ + SimpleMessageChannelBuilder::createQueueChannel('distributed_channel'), + DistributedServiceMap::initialize()->withCommandMapping(targetServiceName: 'ticket_service', channelName: 'distributed_channel'), + ServiceConfiguration::createWithDefaults()->withServiceName('ticket_service'), + ], + [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->getDistributedBus()->sendCommand('ticket_service', 'ticket.create', 'User changed billing address', metadata: $metadata))->leavingWorkPending() + ->expect( + ExpectedSpan::named('send distributed')->ofKind(SpanKind::Producer)->containing( + ExpectedSpan::named('send distributed_channel')->ofKind(SpanKind::Producer), + ), + ) + ->expectNoSpanNamed('process class@anonymous::create') + ->withNoOtherSpans(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('distributed_channel', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('receive distributed_channel')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'ticket.create'])->containing( + ExpectedSpan::named('process class@anonymous::create')->withAttributes([SpanAttribute::ENDPOINT_ID => 'ticket.creation']), + ), + ), + ) + ->withNoOtherSpans(), + ], + ); + } + + private static function distributedEventDeliveredToEverySubscribedChannel(): FlowScenario + { + $billing = new class () { + #[Distributed] + #[EventHandler('user.billing_changed', endpointId: 'billing.updating')] + public function update(string $details): void + { + } + }; + + return new FlowScenario( + 'distributed event delivered to every subscribed channel', + [$billing::class], + [$billing], + [ + SimpleMessageChannelBuilder::createQueueChannel('billing_events'), + SimpleMessageChannelBuilder::createQueueChannel('audit_events'), + DistributedServiceMap::initialize() + ->withEventMapping(channelName: 'billing_events', subscriptionKeys: ['user.*']) + ->withEventMapping(channelName: 'audit_events', subscriptionKeys: ['order.*']), + ServiceConfiguration::createWithDefaults()->withServiceName('billing_service'), + ], + [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->getDistributedBus()->publishEvent('user.billing_changed', 'User changed billing address', metadata: $metadata)) + ->expect(ExpectedSpan::named('send distributed')->ofKind(SpanKind::Producer)->containing(ExpectedSpan::named('send billing_events')->ofKind(SpanKind::Producer))) + ->expectNoSpanNamed('send audit_events') + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('billing_events', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('send billing_events')->containing( + ExpectedSpan::named('receive billing_events')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'user.billing_changed'])->containing( + ExpectedSpan::named('route billing.updating')->containing( + ExpectedSpan::named('process class@anonymous::update')->withAttributes([SpanAttribute::ENDPOINT_ID => 'billing.updating']), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/packages/Ecotone/src/Test/Flow/DynamicConsistencyBoundaryFlowScenarios.php b/packages/Ecotone/src/Test/Flow/DynamicConsistencyBoundaryFlowScenarios.php new file mode 100644 index 0000000000..f25680bec1 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/DynamicConsistencyBoundaryFlowScenarios.php @@ -0,0 +1,188 @@ + + */ + public static function all(): array + { + return [ + self::decisionLoadedBeforeTheHandlerAndAppendedConditionally(), + self::eventSourcedAggregateSavedAsAConditionalAppend(), + self::conflictingAppendFailsWithTheConflictRecorded(), + self::handlerOutsideTheBoundaryRecordsNoBoundarySpans(), + ]; + } + + private static function decisionLoadedBeforeTheHandlerAndAppendedConditionally(): FlowScenario + { + $enrolments = self::enrolments(); + + return new FlowScenario('decision models loaded before the handler, its events appended conditionally', self::classes($enrolments), [$enrolments], [DynamicConsistencyBoundaryConfiguration::createWithDefaults()], [ + FlowStage::when(function (FlowTestSupport $ecotone, array $metadata): void { + $ecotone->withEvents([new FlowCourseDefined('course-1', 5)]); + $ecotone->sendCommand(new FlowEnrolStudent('course-1', 'student-1'), $metadata); + }) + ->expect( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => FlowEnrolStudent::class])->containing( + ExpectedSpan::named('Decision Models: class@anonymous::enrol')->ofKind(SpanKind::Internal)->withAttributes([ + DynamicConsistencyBoundarySpanAttributes::MODELS => FlowCourseCapacity::class, + DynamicConsistencyBoundarySpanAttributes::TAGS => 'course:course-1', + DynamicConsistencyBoundarySpanAttributes::CAPTURED_VERSIONS => 'course:course-1=1', + DynamicConsistencyBoundarySpanAttributes::AGGREGATES => '', + DynamicConsistencyBoundarySpanAttributes::EVENTS_FOLDED => 1, + ]), + ExpectedSpan::named('process class@anonymous::enrol'), + self::conditionalAppend(SpanStatus::Ok), + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => FlowStudentEnrolled::class]), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function eventSourcedAggregateSavedAsAConditionalAppend(): FlowScenario + { + $course = new #[EventSourcingAggregate] #[AggregateType('FlowCourse')] class () { + use WithAggregateVersioning; + + #[Identifier] + private string $courseId; + + #[CommandHandler] + public static function define(FlowDefineCourse $command, FlowCourseCapacity $capacity): array + { + return [new FlowCourseDefined($command->courseId, $command->capacity)]; + } + + #[Apply] + public function applyDefined(FlowCourseDefined $event): void + { + $this->courseId = $event->courseId; + } + }; + + return new FlowScenario('event-sourced aggregate saved as a conditional append', [$course::class, FlowDefineCourse::class, FlowCourseCapacity::class, FlowCourseDefined::class, FlowStudentEnrolled::class], [], [DynamicConsistencyBoundaryConfiguration::createWithDefaults()], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommand(new FlowDefineCourse('course-1', 5), $metadata)) + ->expect( + ExpectedSpan::named('send command')->containing( + ExpectedSpan::named('Decision Models: class@anonymous::define')->ofKind(SpanKind::Internal), + ExpectedSpan::named('process class@anonymous::define'), + self::conditionalAppend(SpanStatus::Ok), + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => FlowCourseDefined::class]), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function conflictingAppendFailsWithTheConflictRecorded(): FlowScenario + { + $enrolments = self::enrolments(); + + return new FlowScenario('conflicting append fails with the conflict recorded', self::classes($enrolments), [$enrolments], [DynamicConsistencyBoundaryConfiguration::createWithDefaults()], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommand(new FlowEnrolStudentTwice('course-1', 'student-1'), $metadata)) + ->failingWith(DecisionModelConcurrencyException::class) + ->expect( + ExpectedSpan::named('send command')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('Decision Models: class@anonymous::enrolTwice'), + ExpectedSpan::named('process class@anonymous::enrolTwice')->containing( + ExpectedSpan::named('send command')->containing( + ExpectedSpan::named('Decision Models: class@anonymous::enrol'), + ExpectedSpan::named('process class@anonymous::enrol'), + self::conditionalAppend(SpanStatus::Ok), + ExpectedSpan::named('send event'), + ), + ), + self::conditionalAppend(SpanStatus::Error)->withEventStartingWith(DynamicConsistencyBoundarySpanAttributes::CONFLICT_EVENT_NAME, [ + DecisionModelConcurrencyException::CONFLICT_TAG_FIELD => 'course:course-1', + DecisionModelConcurrencyException::CONFLICT_EXPECTED_VERSION_FIELD => 0, + DecisionModelConcurrencyException::CONFLICT_CURRENT_VERSION_FIELD => 1, + DecisionModelConcurrencyException::CONFLICT_MODEL_FIELD => FlowCourseCapacity::class, + ]), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function handlerOutsideTheBoundaryRecordsNoBoundarySpans(): FlowScenario + { + $enrolments = new class () { + #[CommandHandler('enrolment.without_boundary', endpointId: 'enrolment.plain')] + public function enrol(string $courseId): void + { + } + }; + + return new FlowScenario('handler outside the boundary records no boundary spans', [$enrolments::class], [$enrolments], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('enrolment.without_boundary', 'course-1', metadata: $metadata)) + ->expect(ExpectedSpan::named('send command')->containing(ExpectedSpan::named('process class@anonymous::enrol'))) + ->withNoOtherSpans(), + ]); + } + + private static function conditionalAppend(SpanStatus $status): ExpectedSpan + { + return ExpectedSpan::named('Conditional Append: ecotone_event_stream')->ofKind(SpanKind::Internal)->withStatus($status)->withAttributes([ + DynamicConsistencyBoundarySpanAttributes::TAGS => 'course:course-1', + DynamicConsistencyBoundarySpanAttributes::EVENTS_APPENDED => 1, + ]); + } + + private static function enrolments(): object + { + return new class () { + #[CommandHandler] + public function enrol(FlowEnrolStudent $command, FlowCourseCapacity $course): array + { + return [new FlowStudentEnrolled($command->courseId, $command->studentId)]; + } + + #[CommandHandler] + public function enrolTwice(FlowEnrolStudentTwice $command, FlowCourseCapacity $course, CommandBus $commandBus): array + { + $commandBus->send(new FlowEnrolStudent($command->courseId, $command->studentId)); + + return [new FlowStudentEnrolled($command->courseId, $command->studentId)]; + } + }; + } + + /** + * @return list + */ + private static function classes(object $enrolments): array + { + return [$enrolments::class, FlowCourseCapacity::class, FlowCourseDefined::class, FlowStudentEnrolled::class, FlowEnrolStudent::class, FlowEnrolStudentTwice::class]; + } +} diff --git a/packages/Ecotone/src/Test/Flow/EndpointFlowScenarios.php b/packages/Ecotone/src/Test/Flow/EndpointFlowScenarios.php new file mode 100644 index 0000000000..37d73068cd --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/EndpointFlowScenarios.php @@ -0,0 +1,319 @@ + + */ + public static function all(): array + { + return [ + self::transformerReshapesTheMessageForTheNextHandler(), + self::transformerReturningNothingEndsTheFlow(), + self::aroundInterceptorWrapsTheHandler(), + self::afterInterceptorSendsACommandOnceTheHandlerReturned(), + self::failedHandlerDropsTheMessagesItCollected(), + self::handlerWithoutCollectorSendsImmediatelyEvenWhenItFails(), + self::handlerWithCollectorSendsWhatItPublishedOnceItSucceeds(), + ]; + } + + private static function transformerReshapesTheMessageForTheNextHandler(): FlowScenario + { + $orders = new class () { + #[CommandHandler('order.place', outputChannelName: 'order.enrich', endpointId: 'order.placing')] + public function place(string $orderId): string + { + return $orderId; + } + + #[Transformer('order.enrich', outputChannelName: 'order.store', endpointId: 'order.enriching')] + public function enrich(string $orderId): string + { + return 'order-' . $orderId; + } + + #[InternalHandler('order.store', endpointId: 'order.storing')] + public function store(string $order): void + { + } + }; + + return new FlowScenario('transformer reshapes the message for the next handler', [$orders::class], [$orders], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->containing( + ExpectedSpan::named('process class@anonymous::place')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placing']), + ExpectedSpan::named('process class@anonymous::store')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.storing']), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function transformerReturningNothingEndsTheFlow(): FlowScenario + { + $orders = new class () { + #[CommandHandler('order.place', outputChannelName: 'order.filter', endpointId: 'order.placing')] + public function place(string $orderId): string + { + return $orderId; + } + + #[Transformer('order.filter', outputChannelName: 'order.store', endpointId: 'order.filtering')] + public function filter(string $orderId): ?string + { + return null; + } + + #[InternalHandler('order.store', endpointId: 'order.storing')] + public function store(string $orderId): void + { + } + }; + + return new FlowScenario('transformer returning nothing ends the flow', [$orders::class], [$orders], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect(ExpectedSpan::named('send command')->containing(ExpectedSpan::named('process class@anonymous::place'))) + ->expectNoSpanNamed('process class@anonymous::store') + ->withNoOtherSpans(), + ]); + } + + private static function aroundInterceptorWrapsTheHandler(): FlowScenario + { + $orders = new class () { + #[FlowAudited] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + } + }; + $timing = new class () { + #[Around(pointcut: FlowAudited::class)] + public function time(MethodInvocation $invocation): mixed + { + return $invocation->proceed(); + } + }; + + return new FlowScenario('around interceptor wraps the handler', [$orders::class, $timing::class, FlowAudited::class], [$orders, $timing], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect(ExpectedSpan::named('send command')->containing(ExpectedSpan::named('process class@anonymous::place')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placing']))) + ->withNoOtherSpans(), + ]); + } + + private static function afterInterceptorSendsACommandOnceTheHandlerReturned(): FlowScenario + { + $orders = new class () { + #[FlowAudited] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): string + { + return $orderId; + } + + #[CommandHandler('audit.record', endpointId: 'audit.recording')] + public function record(string $orderId): void + { + } + }; + $auditing = new class () { + #[After(pointcut: FlowAudited::class)] + public function audit(string $orderId, #[Reference] CommandBus $commandBus): void + { + $commandBus->sendWithRouting('audit.record', $orderId); + } + }; + + return new FlowScenario('after interceptor sends a command once the handler returned', [$orders::class, $auditing::class, FlowAudited::class], [$orders, $auditing], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'order.place'])->containing( + ExpectedSpan::named('process class@anonymous::place'), + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'audit.record'])->containing( + ExpectedSpan::named('process class@anonymous::record')->withAttributes([SpanAttribute::ENDPOINT_ID => 'audit.recording']), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function failedHandlerDropsTheMessagesItCollected(): FlowScenario + { + [$orders, $notifier] = self::publishingThenFailing(withCollector: true); + + return new FlowScenario('failed handler drops the messages it collected', [$orders::class, $notifier::class], [$orders, $notifier], [ + SimpleMessageChannelBuilder::createQueueChannel('orders'), + SimpleMessageChannelBuilder::createQueueChannel('notifications'), + InstantRetryConfiguration::createWithDefaults()->withAsynchronousEndpointsRetry(false), + ], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect(ExpectedSpan::named('send command')->containing(ExpectedSpan::named('send orders')->ofKind(SpanKind::Producer))) + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup())) + ->failingWith('Order cannot be stored') + ->expect( + ExpectedSpan::named('receive orders')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('process class@anonymous::place')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('send event')->containing(ExpectedSpan::named('route order.notifying')), + ), + ), + ) + ->expectNoSpanNamed('send notifications') + ->withNoOtherSpans(), + ]); + } + + private static function handlerWithoutCollectorSendsImmediatelyEvenWhenItFails(): FlowScenario + { + [$orders, $notifier] = self::publishingThenFailing(withCollector: false); + + return new FlowScenario('handler without the message collector sends immediately even when it fails', [$orders::class, $notifier::class], [$orders, $notifier], [ + SimpleMessageChannelBuilder::createQueueChannel('orders'), + SimpleMessageChannelBuilder::createQueueChannel('notifications'), + InstantRetryConfiguration::createWithDefaults()->withAsynchronousEndpointsRetry(false), + ], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect(ExpectedSpan::named('send command')->containing(ExpectedSpan::named('send orders')->ofKind(SpanKind::Producer))) + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup())) + ->failingWith('Order cannot be stored') + ->expect( + ExpectedSpan::named('receive orders')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('process class@anonymous::place')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('send event')->containing( + ExpectedSpan::named('route order.notifying')->containing( + ExpectedSpan::named('send notifications')->ofKind(SpanKind::Producer)->withStatus(SpanStatus::Ok), + ), + ), + ), + ), + ), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('notifications', ExecutionPollingMetadata::createWithTestingSetup())) + ->leavingWorkPending() + ->expect( + ExpectedSpan::named('receive notifications')->withStatus(SpanStatus::Ok)->containing( + ExpectedSpan::named('process class@anonymous::notify')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.notifying']), + ), + ), + ]); + } + + private static function handlerWithCollectorSendsWhatItPublishedOnceItSucceeds(): FlowScenario + { + $notifier = new class () { + #[Asynchronous('notifications')] + #[EventHandler('order.placed', endpointId: 'order.notifying')] + public function notify(string $orderId): void + { + } + }; + $orders = new class () { + #[Asynchronous('orders')] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId, EventBus $eventBus): void + { + $eventBus->publishWithRouting('order.placed', $orderId); + } + }; + + return new FlowScenario('handler with the message collector sends what it published once it succeeds', [$orders::class, $notifier::class], [$orders, $notifier], [ + SimpleMessageChannelBuilder::createQueueChannel('orders'), + SimpleMessageChannelBuilder::createQueueChannel('notifications'), + ], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect(ExpectedSpan::named('send command')->containing(ExpectedSpan::named('send orders')->ofKind(SpanKind::Producer))) + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('receive orders')->withStatus(SpanStatus::Ok)->containing( + ExpectedSpan::named('process class@anonymous::place')->containing( + ExpectedSpan::named('send event')->containing( + ExpectedSpan::named('route order.notifying')->withAttributes([SpanAttribute::ROUTE_COLLECTED => true])->withoutAttributes(SpanAttribute::ROUTE_UNDELIVERED), + ), + ), + ExpectedSpan::named('send notifications')->ofKind(SpanKind::Producer)->withStatus(SpanStatus::Ok), + ), + ) + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('notifications', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('send notifications')->containing( + ExpectedSpan::named('receive notifications')->containing( + ExpectedSpan::named('process class@anonymous::notify')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.notifying']), + ), + ), + ), + ]); + } + + /** + * @return array{object, object} + */ + private static function publishingThenFailing(bool $withCollector): array + { + $notifier = new class () { + #[Asynchronous('notifications')] + #[EventHandler('order.placed', endpointId: 'order.notifying')] + public function notify(string $orderId): void + { + } + }; + $orders = $withCollector + ? new class () { + #[Asynchronous('orders')] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId, EventBus $eventBus): void + { + $eventBus->publishWithRouting('order.placed', $orderId); + throw new RuntimeException('Order cannot be stored'); + } + } + : new class () { + #[Asynchronous('orders', asynchronousExecution: [new WithoutMessageCollector()])] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId, EventBus $eventBus): void + { + $eventBus->publishWithRouting('order.placed', $orderId); + throw new RuntimeException('Order cannot be stored'); + } + }; + + return [$orders, $notifier]; + } +} diff --git a/packages/Ecotone/src/Test/Flow/EventFlowScenarios.php b/packages/Ecotone/src/Test/Flow/EventFlowScenarios.php new file mode 100644 index 0000000000..b7b45e7632 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/EventFlowScenarios.php @@ -0,0 +1,116 @@ + + */ + public static function all(): array + { + return [ + self::eventReachingTwoSubscribersThatEachSendACommand(), + self::commandPublishingAnEventWhoseSubscriberSendsACommand(), + ]; + } + + private static function eventReachingTwoSubscribersThatEachSendACommand(): FlowScenario + { + $users = self::users(); + $first = new class () { + #[EventHandler('merchant.created', endpointId: 'merchant.to.user')] + public function merchantToUser(string $merchantId, CommandBus $commandBus): void + { + $commandBus->sendWithRouting('user.register', $merchantId); + } + }; + $second = new class () { + #[EventHandler('merchant.created', endpointId: 'merchant.to.second.user')] + public function merchantToUser(string $merchantId, CommandBus $commandBus): void + { + $commandBus->sendWithRouting('user.register', $merchantId . '-second'); + } + }; + $registration = fn (string $endpointId) => ExpectedSpan::named('route ' . $endpointId)->ofKind(SpanKind::Producer)->withAttributes([SpanAttribute::ENDPOINT_ID => $endpointId, SpanAttribute::MESSAGING_DESTINATION_NAME => $endpointId . '.target'])->containing( + ExpectedSpan::named('process class@anonymous::merchantToUser')->withAttributes([SpanAttribute::ENDPOINT_ID => $endpointId])->containing( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'user.register'])->containing( + ExpectedSpan::named('process class@anonymous::register')->withAttributes([SpanAttribute::ENDPOINT_ID => 'user.registration']), + ), + ), + ); + + return new FlowScenario('event reaching two subscribers that each send a command', [$users::class, $first::class, $second::class], [$users, $first, $second], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->publishEventWithRouting('merchant.created', '1', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send event')->ofKind(SpanKind::Server)->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'merchant.created'])->containing( + $registration('merchant.to.user'), + $registration('merchant.to.second.user'), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function commandPublishingAnEventWhoseSubscriberSendsACommand(): FlowScenario + { + $merchants = new class () { + #[CommandHandler('merchant.create', endpointId: 'merchant.creation')] + public function create(string $merchantId, EventBus $eventBus): void + { + $eventBus->publishWithRouting('merchant.created', $merchantId); + } + }; + $users = self::users(); + $subscriber = new class () { + #[EventHandler('merchant.created', endpointId: 'merchant.to.user')] + public function merchantToUser(string $merchantId, CommandBus $commandBus): void + { + $commandBus->sendWithRouting('user.register', $merchantId); + } + }; + + return new FlowScenario('command publishing an event whose subscriber sends a command', [$merchants::class, $users::class, $subscriber::class], [$merchants, $users, $subscriber], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('merchant.create', '1', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'merchant.create'])->containing( + ExpectedSpan::named('process class@anonymous::create')->withAttributes([SpanAttribute::ENDPOINT_ID => 'merchant.creation'])->containing( + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'merchant.created'])->containing( + ExpectedSpan::named('route merchant.to.user')->ofKind(SpanKind::Producer)->containing( + ExpectedSpan::named('process class@anonymous::merchantToUser')->containing( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'user.register'])->containing( + ExpectedSpan::named('process class@anonymous::register'), + ), + ), + ), + ), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function users(): object + { + return new class () { + #[CommandHandler('user.register', endpointId: 'user.registration')] + public function register(string $userId): void + { + } + }; + } +} diff --git a/packages/Ecotone/src/Test/Flow/ExpectedSpan.php b/packages/Ecotone/src/Test/Flow/ExpectedSpan.php new file mode 100644 index 0000000000..dd1bc158d2 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/ExpectedSpan.php @@ -0,0 +1,297 @@ + $attributes + * @param list $children + * @param list $absentAttributes + * @param array $attributesOtherThan + * @param array $attributesOfParent + * @param list}> $events + */ + private function __construct( + public readonly string $name, + public readonly ?SpanKind $kind = null, + public readonly ?SpanStatus $status = null, + public readonly array $attributes = [], + public readonly array $children = [], + public readonly bool $root = false, + public readonly array $absentAttributes = [], + public readonly array $attributesOtherThan = [], + public readonly array $attributesOfParent = [], + public readonly array $events = [], + public readonly ?string $traceId = null, + public readonly ?string $parentSpanId = null, + public readonly ?string $statusDescription = null, + ) { + } + + public function withoutAttributes(string ...$keys): self + { + return new self(...[...get_object_vars($this), 'absentAttributes' => [...$this->absentAttributes, ...$keys]]); + } + + public function withAttributeOtherThan(string $key, int|float|string|bool $value): self + { + return new self(...[...get_object_vars($this), 'attributesOtherThan' => [...$this->attributesOtherThan, $key => $value]]); + } + + public function withAttributeEqualToParents(string $key, ?string $parentKey = null): self + { + return new self(...[...get_object_vars($this), 'attributesOfParent' => [...$this->attributesOfParent, $key => $parentKey ?? $key]]); + } + + /** + * @param array $attributes + */ + public function withEventStartingWith(string $namePrefix, array $attributes = []): self + { + return new self(...[...get_object_vars($this), 'events' => [...$this->events, [$namePrefix, $attributes]]]); + } + + public function inTrace(string $traceId): self + { + return new self(...[...get_object_vars($this), 'traceId' => $traceId]); + } + + public function withParentSpanId(string $parentSpanId): self + { + return new self(...[...get_object_vars($this), 'parentSpanId' => $parentSpanId]); + } + + public static function named(string $name): self + { + return new self($name); + } + + public function asRoot(): self + { + return new self(...[...get_object_vars($this), 'root' => true]); + } + + public function ofKind(SpanKind $kind): self + { + return new self(...[...get_object_vars($this), 'kind' => $kind]); + } + + public function withStatus(SpanStatus $status, ?string $description = null): self + { + return new self(...[...get_object_vars($this), 'status' => $status, 'statusDescription' => $description]); + } + + /** + * @param array $attributes + */ + public function withAttributes(array $attributes): self + { + return new self(...[...get_object_vars($this), 'attributes' => [...$this->attributes, ...$attributes]]); + } + + public function containing(ExpectedSpan ...$children): self + { + return new self(...[...get_object_vars($this), 'children' => [...$this->children, ...$children]]); + } + + public function failsTheFlow(): bool + { + return $this->status === SpanStatus::Error || $this->hasFailedConsumer(); + } + + private function hasFailedConsumer(): bool + { + foreach ($this->children as $child) { + if ((str_starts_with($child->name, 'receive ') && $child->status === SpanStatus::Error) || $child->hasFailedConsumer()) { + return true; + } + } + + return false; + } + + public function asStoredByFlowTracking(): ?self + { + if ($this->status === SpanStatus::Error) { + return null; + } + + return new self(...[...get_object_vars($this), 'children' => array_values(array_filter(array_map(fn (ExpectedSpan $child) => $child->asStoredByFlowTracking(), $this->children)))]); + } + + public function count(): int + { + return 1 + array_sum(array_map(fn (ExpectedSpan $child) => $child->count(), $this->children)); + } + + /** + * @param list $expected + * @param Span[] $spans + */ + public static function mismatchIn(array $expected, array $spans): ?string + { + $tree = SpanTree::of($spans); + foreach ($expected as $expectedSpan) { + if (! $expectedSpan->matchesAnyOf($tree->all(), $tree, null)) { + return "No recorded span tree matches:\n" . $expectedSpan->render(1) . "\nRecorded:\n" . $tree->render(); + } + } + + return null; + } + + /** + * @param Span[] $spans + */ + public static function namedIn(string $name, array $spans): bool + { + foreach ($spans as $span) { + if ($span->name === $name) { + return true; + } + } + + return false; + } + + /** + * @param Span[] $candidates + */ + private function matchesAnyOf(array $candidates, SpanTree $tree, ?Span $parent): bool + { + foreach ($candidates as $candidate) { + if ($this->matches($candidate, $tree, $parent ?? $tree->parentOf($candidate))) { + return true; + } + } + + return false; + } + + private function matches(Span $span, SpanTree $tree, ?Span $parent): bool + { + if ($span->name !== $this->name + || ($this->root && $span->parentSpanId !== null) + || ($this->kind !== null && $span->kind !== $this->kind) + || ($this->status !== null && $span->status !== $this->status)) { + return false; + } + foreach ($this->attributes as $key => $value) { + if (($span->attributes[$key] ?? null) !== $value) { + return false; + } + } + foreach ($this->absentAttributes as $key) { + if (array_key_exists($key, $span->attributes)) { + return false; + } + } + foreach ($this->attributesOtherThan as $key => $value) { + if (! array_key_exists($key, $span->attributes) || $span->attributes[$key] === $value) { + return false; + } + } + foreach ($this->attributesOfParent as $key => $parentKey) { + if ($parent === null || ! array_key_exists($key, $span->attributes) || $span->attributes[$key] !== ($parent->attributes[$parentKey] ?? null)) { + return false; + } + } + foreach ($this->events as [$namePrefix, $eventAttributes]) { + if (! self::hasEvent($span, $namePrefix, $eventAttributes)) { + return false; + } + } + if (($this->traceId !== null && $span->traceId !== $this->traceId) + || ($this->parentSpanId !== null && $span->parentSpanId !== $this->parentSpanId) + || ($this->statusDescription !== null && $span->statusDescription !== $this->statusDescription)) { + return false; + } + + return self::childrenMatch($this->children, $tree->childrenOf($span), $tree, $span); + } + + /** + * @param array $attributes + */ + private static function hasEvent(Span $span, string $namePrefix, array $attributes): bool + { + foreach ($span->events as $event) { + if (! str_starts_with($event->name, $namePrefix)) { + continue; + } + foreach ($attributes as $key => $value) { + if (($event->attributes[$key] ?? null) !== $value) { + continue 2; + } + } + + return true; + } + + return false; + } + + /** + * @param list $expectedChildren + * @param Span[] $available + */ + private static function childrenMatch(array $expectedChildren, array $available, SpanTree $tree, Span $parent): bool + { + if ($expectedChildren === []) { + return true; + } + $expected = array_shift($expectedChildren); + foreach ($available as $index => $candidate) { + if ($expected->matches($candidate, $tree, $parent)) { + $remaining = $available; + unset($remaining[$index]); + if (self::childrenMatch($expectedChildren, $remaining, $tree, $parent)) { + return true; + } + } + } + + return false; + } + + private function render(int $depth): string + { + $line = str_repeat(' ', $depth) . $this->name + . ($this->root ? ' ' : '') + . ($this->absentAttributes === [] ? '' : ' without ' . implode(', ', $this->absentAttributes)) + . ($this->attributesOtherThan === [] ? '' : ' ' . implode(', ', array_map(fn (string $key, mixed $value) => $key . '!=' . var_export($value, true), array_keys($this->attributesOtherThan), $this->attributesOtherThan))) + . ($this->attributesOfParent === [] ? '' : ' ' . implode(', ', array_map(fn (string $key, string $parentKey) => $key . '=parent.' . $parentKey, array_keys($this->attributesOfParent), $this->attributesOfParent))) + . ($this->events === [] ? '' : ' events: ' . implode(', ', array_map(fn (array $event) => $event[0] . '…', $this->events))) + . ($this->traceId === null ? '' : ' trace=' . $this->traceId) + . ($this->parentSpanId === null ? '' : ' parent=' . $this->parentSpanId) + . ($this->kind === null ? '' : ' [' . $this->kind->name . ']') + . ($this->status === null ? '' : ' (' . $this->status->name . ')') + . ($this->attributes === [] ? '' : ' ' . implode(', ', array_map(fn (string $key, mixed $value) => $key . '=' . var_export($value, true), array_keys($this->attributes), $this->attributes))); + + return implode("\n", [$line, ...array_map(fn (ExpectedSpan $child) => $child->render($depth + 1), $this->children)]); + } + + public static function isFrameworkNoise(Span $span): bool + { + return str_starts_with($span->name, self::FRAMEWORK_NOISE_PREFIX); + } +} diff --git a/packages/Ecotone/src/Test/Flow/FailureFlowScenarios.php b/packages/Ecotone/src/Test/Flow/FailureFlowScenarios.php new file mode 100644 index 0000000000..a96e0c66ef --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/FailureFlowScenarios.php @@ -0,0 +1,117 @@ + + */ + public static function all(): array + { + return [ + self::synchronousHandlerFailing(), + self::synchronousSubscriberFailingFailsItsRoute(), + self::asynchronousHandlerFailingIntoTheErrorChannel(), + ]; + } + + private static function synchronousHandlerFailing(): FlowScenario + { + $orders = new class () { + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + throw new RuntimeException('Order cannot be placed'); + } + }; + + return new FlowScenario('synchronous handler failing', [$orders::class], [$orders], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->failingWith('Order cannot be placed') + ->expect( + ExpectedSpan::named('send command')->withStatus(SpanStatus::Error, 'Order cannot be placed')->withEventStartingWith('exception', self::exceptionRecorded())->containing( + ExpectedSpan::named('process class@anonymous::place')->withStatus(SpanStatus::Error, 'Order cannot be placed')->withEventStartingWith('exception', self::exceptionRecorded())->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placing']), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function synchronousSubscriberFailingFailsItsRoute(): FlowScenario + { + $notifier = new class () { + #[EventHandler('merchant.created', endpointId: 'merchant.notifying')] + public function notify(string $merchantId): void + { + throw new RuntimeException('Notification failed'); + } + }; + + return new FlowScenario('synchronous subscriber failing fails its route', [$notifier::class], [$notifier], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->publishEventWithRouting('merchant.created', '1', metadata: $metadata)) + ->failingWith('Notification failed') + ->expect( + ExpectedSpan::named('send event')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('route merchant.notifying')->ofKind(SpanKind::Producer)->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('process class@anonymous::notify')->withStatus(SpanStatus::Error, 'Notification failed'), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } + + /** + * @return array + */ + private static function exceptionRecorded(): array + { + return ['exception.type' => RuntimeException::class, 'exception.message' => 'Order cannot be placed']; + } + + private static function asynchronousHandlerFailingIntoTheErrorChannel(): FlowScenario + { + $orders = new class () { + #[Asynchronous('orders')] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + throw new RuntimeException('Order cannot be placed'); + } + }; + + return new FlowScenario('asynchronous handler failing into the error channel', [$orders::class], [$orders], [SimpleMessageChannelBuilder::createQueueChannel('orders'), ServiceConfiguration::createWithDefaults()->withDefaultErrorChannel('errorChannel')], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata))->leavingWorkPending() + ->expect( + ExpectedSpan::named('send command')->withStatus(SpanStatus::Ok)->containing( + ExpectedSpan::named('send orders'), + ), + ) + ->withNoOtherSpans(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup(stopOnError: false))) + ->expect( + ExpectedSpan::named('receive orders')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('process class@anonymous::place')->withStatus(SpanStatus::Error), + ), + ), + ]); + } +} diff --git a/packages/Ecotone/src/Test/Flow/Fixture/FlowApprovalWorkflow.php b/packages/Ecotone/src/Test/Flow/Fixture/FlowApprovalWorkflow.php new file mode 100644 index 0000000000..af734c5104 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/Fixture/FlowApprovalWorkflow.php @@ -0,0 +1,16 @@ +capacity = $event->capacity; } #[Apply] - public function enrolled(StudentEnrolled $event): void + public function enrolled(FlowStudentEnrolled $event): void { $this->seatsTaken++; } diff --git a/packages/OpenTelemetry/tests/Fixture/DecisionModelFlow/CourseDefined.php b/packages/Ecotone/src/Test/Flow/Fixture/FlowCourseDefined.php similarity index 70% rename from packages/OpenTelemetry/tests/Fixture/DecisionModelFlow/CourseDefined.php rename to packages/Ecotone/src/Test/Flow/Fixture/FlowCourseDefined.php index a60ea7963f..ffe012199b 100644 --- a/packages/OpenTelemetry/tests/Fixture/DecisionModelFlow/CourseDefined.php +++ b/packages/Ecotone/src/Test/Flow/Fixture/FlowCourseDefined.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Test\Ecotone\OpenTelemetry\Fixture\DecisionModelFlow; +namespace Ecotone\Test\Flow\Fixture; use Ecotone\Api\Attribute\EventTag; /** * licence Apache-2.0 */ -final readonly class CourseDefined +final readonly class FlowCourseDefined { public function __construct( #[EventTag('course')] public string $courseId, diff --git a/packages/OpenTelemetry/tests/Fixture/DecisionModelFlow/DefineCourse.php b/packages/Ecotone/src/Test/Flow/Fixture/FlowDefineCourse.php similarity index 64% rename from packages/OpenTelemetry/tests/Fixture/DecisionModelFlow/DefineCourse.php rename to packages/Ecotone/src/Test/Flow/Fixture/FlowDefineCourse.php index 64e669a3b5..85729d2843 100644 --- a/packages/OpenTelemetry/tests/Fixture/DecisionModelFlow/DefineCourse.php +++ b/packages/Ecotone/src/Test/Flow/Fixture/FlowDefineCourse.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Test\Ecotone\OpenTelemetry\Fixture\DecisionModelFlow; +namespace Ecotone\Test\Flow\Fixture; /** * licence Apache-2.0 */ -final readonly class DefineCourse +final readonly class FlowDefineCourse { public function __construct( public string $courseId, diff --git a/packages/OpenTelemetry/tests/Fixture/DecisionModelFlow/EnrolStudent.php b/packages/Ecotone/src/Test/Flow/Fixture/FlowEnrolStudent.php similarity index 65% rename from packages/OpenTelemetry/tests/Fixture/DecisionModelFlow/EnrolStudent.php rename to packages/Ecotone/src/Test/Flow/Fixture/FlowEnrolStudent.php index bb3b18b201..89c1161672 100644 --- a/packages/OpenTelemetry/tests/Fixture/DecisionModelFlow/EnrolStudent.php +++ b/packages/Ecotone/src/Test/Flow/Fixture/FlowEnrolStudent.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Test\Ecotone\OpenTelemetry\Fixture\DecisionModelFlow; +namespace Ecotone\Test\Flow\Fixture; /** * licence Apache-2.0 */ -final readonly class EnrolStudent +final readonly class FlowEnrolStudent { public function __construct( public string $courseId, diff --git a/packages/OpenTelemetry/tests/Fixture/DecisionModelFlow/EnrolStudentTwice.php b/packages/Ecotone/src/Test/Flow/Fixture/FlowEnrolStudentTwice.php similarity index 63% rename from packages/OpenTelemetry/tests/Fixture/DecisionModelFlow/EnrolStudentTwice.php rename to packages/Ecotone/src/Test/Flow/Fixture/FlowEnrolStudentTwice.php index 879cf53a04..abc4c26703 100644 --- a/packages/OpenTelemetry/tests/Fixture/DecisionModelFlow/EnrolStudentTwice.php +++ b/packages/Ecotone/src/Test/Flow/Fixture/FlowEnrolStudentTwice.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Test\Ecotone\OpenTelemetry\Fixture\DecisionModelFlow; +namespace Ecotone\Test\Flow\Fixture; /** * licence Apache-2.0 */ -final readonly class EnrolStudentTwice +final readonly class FlowEnrolStudentTwice { public function __construct( public string $courseId, diff --git a/packages/Ecotone/src/Test/Flow/Fixture/FlowNotificationGateway.php b/packages/Ecotone/src/Test/Flow/Fixture/FlowNotificationGateway.php new file mode 100644 index 0000000000..258b0496a9 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/Fixture/FlowNotificationGateway.php @@ -0,0 +1,16 @@ +orderId = $event->orderId; + } +} diff --git a/packages/Ecotone/src/Test/Flow/Fixture/FlowOrderCancelled.php b/packages/Ecotone/src/Test/Flow/Fixture/FlowOrderCancelled.php new file mode 100644 index 0000000000..1740bb6a9e --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/Fixture/FlowOrderCancelled.php @@ -0,0 +1,15 @@ + $classesToResolve + * @param array $services + * @param list $extensionObjects + * @param list $stages + */ + public function __construct( + public readonly string $name, + public readonly array $classesToResolve, + public readonly array $services, + public readonly array $extensionObjects, + public readonly array $stages, + ) { + } + + /** + * @param array $metadata + */ + public function runStage(int $stageIndex, FlowTestSupport $ecotone, array $metadata = []): ?string + { + $stage = $this->stages[$stageIndex]; + try { + ($stage->action)($ecotone, $metadata); + } catch (Throwable $failure) { + if ($stage->expectedFailure !== null && ($failure instanceof $stage->expectedFailure || str_contains($failure->getMessage(), $stage->expectedFailure))) { + return null; + } + + return "Stage {$stageIndex} failed unexpectedly: " . $failure::class . ': ' . $failure->getMessage(); + } + + return $stage->expectedFailure === null ? null : "Stage {$stageIndex} was expected to fail with \"{$stage->expectedFailure}\", but it completed"; + } + + public function isFinishedAfterStage(int $stageIndex): bool + { + $stage = $this->stages[$stageIndex]; + if ($stage->leavesWorkPending || $stage->expectedFailure !== null) { + return false; + } + foreach ($stage->expectedSpans as $expectedSpan) { + if ($expectedSpan->failsTheFlow()) { + return false; + } + } + + return true; + } + + public function asStoredByFlowTracking(): self + { + return new self($this->name, $this->classesToResolve, $this->services, $this->extensionObjects, array_map(fn (FlowStage $stage) => $stage->asStoredByFlowTracking(), $this->stages)); + } + + /** + * @param Span[] $spans + */ + public function mismatchAfterStage(int $stageIndex, array $spans): ?string + { + $stage = $this->stages[$stageIndex]; + $mismatch = ExpectedSpan::mismatchIn($stage->expectedSpans, $spans); + if ($mismatch !== null) { + return $mismatch; + } + $tree = SpanTree::of($spans); + foreach ($tree->all() as $span) { + if (($span->attributes[SpanAttribute::MESSAGING_SYSTEM] ?? null) !== SpanAttribute::ECOTONE) { + return "Span {$span->name} does not name " . SpanAttribute::ECOTONE . ' as its ' . SpanAttribute::MESSAGING_SYSTEM . ". Recorded:\n" . $tree->render(); + } + } + foreach ($stage->absentSpanNames as $absentSpanName) { + if (ExpectedSpan::namedIn($absentSpanName, $tree->all())) { + return "Span {$absentSpanName} was recorded, though the stage expects it not to be. Recorded:\n" . $tree->render(); + } + } + $expectedCount = $this->expectedSpanCountUpTo($stageIndex); + if ($stage->noOtherSpans && count($tree->all()) !== $expectedCount) { + return 'Expected exactly ' . $expectedCount . ' spans, recorded ' . count($tree->all()) . ":\n" . $tree->render(); + } + + return null; + } + + private function expectedSpanCountUpTo(int $stageIndex): int + { + $count = 0; + foreach (array_slice($this->stages, 0, $stageIndex + 1) as $stage) { + foreach ($stage->expectedSpans as $expectedSpan) { + $count += $expectedSpan->count(); + } + } + + return $count; + } +} diff --git a/packages/Ecotone/src/Test/Flow/FlowScenarioCatalogue.php b/packages/Ecotone/src/Test/Flow/FlowScenarioCatalogue.php new file mode 100644 index 0000000000..e3e9ceb344 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/FlowScenarioCatalogue.php @@ -0,0 +1,46 @@ + + */ + public static function dataProvider(): iterable + { + foreach (self::all() as $scenario) { + yield $scenario->name => [$scenario]; + } + } + + /** + * @return list + */ + public static function all(): array + { + return [ + ...SynchronousFlowScenarios::all(), + ...EventFlowScenarios::all(), + ...AsynchronousFlowScenarios::all(), + ...GatewayFlowScenarios::all(), + ...ProjectionFlowScenarios::all(), + ...DistributedFlowScenarios::all(), + ...ModellingFlowScenarios::all(), + ...InterceptorFlowScenarios::all(), + ...FailureFlowScenarios::all(), + ...ChannelFailureFlowScenarios::all(), + ...TraceFlowScenarios::all(), + ...RecoverabilityFlowScenarios::all(), + ...ChannelFlowScenarios::all(), + ...EndpointFlowScenarios::all(), + ...RoutingFlowScenarios::all(), + ...DynamicConsistencyBoundaryFlowScenarios::all(), + ]; + } +} diff --git a/packages/Ecotone/src/Test/Flow/FlowStage.php b/packages/Ecotone/src/Test/Flow/FlowStage.php new file mode 100644 index 0000000000..8559ab49e9 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/FlowStage.php @@ -0,0 +1,66 @@ +): void $action + * @param list $expectedSpans + * @param list $absentSpanNames + */ + private function __construct( + public readonly Closure $action, + public readonly array $expectedSpans = [], + public readonly array $absentSpanNames = [], + public readonly bool $noOtherSpans = false, + public readonly ?string $expectedFailure = null, + public readonly bool $leavesWorkPending = false, + ) { + } + + /** + * @param Closure(\Ecotone\Api\Lite\Test\FlowTestSupport, array): void $action + */ + public static function when(Closure $action): self + { + return new self($action); + } + + public function expect(ExpectedSpan ...$spans): self + { + return new self(...[...get_object_vars($this), 'expectedSpans' => [...$this->expectedSpans, ...$spans]]); + } + + public function leavingWorkPending(): self + { + return new self(...[...get_object_vars($this), 'leavesWorkPending' => true]); + } + + public function failingWith(string $messageFragment): self + { + return new self(...[...get_object_vars($this), 'expectedFailure' => $messageFragment]); + } + + public function asStoredByFlowTracking(): self + { + return new self(...[...get_object_vars($this), 'expectedSpans' => array_values(array_filter(array_map(fn (ExpectedSpan $span) => $span->asStoredByFlowTracking(), $this->expectedSpans)))]); + } + + public function withNoOtherSpans(): self + { + return new self(...[...get_object_vars($this), 'noOtherSpans' => true]); + } + + public function expectNoSpanNamed(string ...$names): self + { + return new self(...[...get_object_vars($this), 'absentSpanNames' => [...$this->absentSpanNames, ...$names]]); + } +} diff --git a/packages/Ecotone/src/Test/Flow/GatewayFlowScenarios.php b/packages/Ecotone/src/Test/Flow/GatewayFlowScenarios.php new file mode 100644 index 0000000000..f3a57265bd --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/GatewayFlowScenarios.php @@ -0,0 +1,206 @@ + + */ + public static function all(): array + { + return [ + self::messageGatewayStartingTheFlow(), + self::orchestratorGatewayStartingTheFlow(), + self::orchestratorReachedFromCommandHandler(), + self::asynchronousGatewayQueuesTheCall(), + self::orchestratorWithAnAsynchronousStep(), + ]; + } + + private static function messageGatewayStartingTheFlow(): FlowScenario + { + $notifications = new class () { + #[InternalHandler('notification.send', endpointId: 'notification.sending')] + public function send(string $text): void + { + } + }; + + return new FlowScenario('message gateway starting the flow', [$notifications::class, FlowNotificationGateway::class], [$notifications], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->getGateway(FlowNotificationGateway::class)->send('hello')) + ->expect( + ExpectedSpan::named('send notification.send')->ofKind(SpanKind::Server)->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'notification.send'])->containing( + ExpectedSpan::named('process class@anonymous::send')->withAttributes([SpanAttribute::ENDPOINT_ID => 'notification.sending']), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function orchestratorGatewayStartingTheFlow(): FlowScenario + { + $steps = self::approvalSteps(); + + return new FlowScenario('orchestrator gateway starting the flow', [$steps::class, FlowApprovalWorkflow::class], [$steps], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->getGateway(FlowApprovalWorkflow::class)->start(['approval.validate', 'approval.notify'], 'request-1')) + ->expect( + ExpectedSpan::named('send orchestrator')->ofKind(SpanKind::Server)->containing( + ExpectedSpan::named('process class@anonymous::validate')->withAttributes([SpanAttribute::ENDPOINT_ID => 'approval.validating']), + ExpectedSpan::named('process class@anonymous::notify')->withAttributes([SpanAttribute::ENDPOINT_ID => 'approval.notifying']), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function orchestratorReachedFromCommandHandler(): FlowScenario + { + $approvals = new class () { + #[CommandHandler('approval.request', outputChannelName: 'approval.workflow', endpointId: 'approval.requesting')] + public function request(string $request): string + { + return $request; + } + + #[Orchestrator('approval.workflow')] + public function workflow(): array + { + return ['approval.validate', 'approval.notify']; + } + }; + $steps = self::approvalSteps(); + + return new FlowScenario('orchestrator reached from a command handler', [$approvals::class, $steps::class], [$approvals, $steps], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('approval.request', 'request-1', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'approval.request'])->containing( + ExpectedSpan::named('process class@anonymous::request')->withAttributes([SpanAttribute::ENDPOINT_ID => 'approval.requesting']), + ExpectedSpan::named('process class@anonymous::validate')->withAttributes([SpanAttribute::ENDPOINT_ID => 'approval.validating']), + ExpectedSpan::named('process class@anonymous::notify')->withAttributes([SpanAttribute::ENDPOINT_ID => 'approval.notifying']), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function asynchronousGatewayQueuesTheCall(): FlowScenario + { + $tickets = new class () { + #[InternalHandler('ticket.create', endpointId: 'ticket.creating')] + public function create(string $ticket): void + { + } + }; + + return new FlowScenario('asynchronous gateway queues the call until its consumer runs', [$tickets::class, FlowTicketGateway::class], [$tickets], [SimpleMessageChannelBuilder::createQueueChannel('tickets')], [ + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->getGateway(FlowTicketGateway::class)->create('broken printer')) + ->expect(ExpectedSpan::named('send ticket.create')->ofKind(SpanKind::Server)->containing(ExpectedSpan::named('send tickets')->ofKind(SpanKind::Producer))) + ->expectNoSpanNamed('process class@anonymous::create') + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('tickets', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('send tickets')->containing( + ExpectedSpan::named('receive tickets')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process class@anonymous::create')->withAttributes([SpanAttribute::ENDPOINT_ID => 'ticket.creating']), + ), + ), + ), + ]); + } + + private static function orchestratorWithAnAsynchronousStep(): FlowScenario + { + $approvals = new class () { + #[CommandHandler('approval.request', outputChannelName: 'approval.workflow', endpointId: 'approval.requesting')] + public function request(string $request): string + { + return $request; + } + + #[Orchestrator('approval.workflow')] + public function workflow(): array + { + return ['approval.validate', 'approval.review', 'approval.notify']; + } + + #[InternalHandler('approval.validate', endpointId: 'approval.validating')] + public function validate(string $request): string + { + return $request; + } + + #[Asynchronous('reviews')] + #[InternalHandler('approval.review', endpointId: 'approval.reviewing')] + public function review(string $request): string + { + return $request; + } + + #[InternalHandler('approval.notify', endpointId: 'approval.notifying')] + public function notify(string $request): string + { + return $request; + } + }; + + return new FlowScenario('orchestrator with an asynchronous step', [$approvals::class], [$approvals], [SimpleMessageChannelBuilder::createQueueChannel('reviews')], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('approval.request', 'request-1', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->containing( + ExpectedSpan::named('process class@anonymous::request'), + ExpectedSpan::named('process class@anonymous::validate')->withAttributes([SpanAttribute::ENDPOINT_ID => 'approval.validating']), + ExpectedSpan::named('send reviews')->ofKind(SpanKind::Producer), + ), + ) + ->expectNoSpanNamed('process class@anonymous::review', 'process class@anonymous::notify') + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('reviews', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('send reviews')->containing( + ExpectedSpan::named('receive reviews')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process class@anonymous::review')->withAttributes([SpanAttribute::ENDPOINT_ID => 'approval.reviewing']), + ExpectedSpan::named('process class@anonymous::notify')->withAttributes([SpanAttribute::ENDPOINT_ID => 'approval.notifying']), + ), + ), + ), + ]); + } + + private static function approvalSteps(): object + { + return new class () { + #[InternalHandler('approval.validate', endpointId: 'approval.validating')] + public function validate(string $request): string + { + return $request; + } + + #[InternalHandler('approval.notify', endpointId: 'approval.notifying')] + public function notify(string $request): string + { + return $request; + } + }; + } +} diff --git a/packages/Ecotone/src/Test/Flow/InterceptorFlowScenarios.php b/packages/Ecotone/src/Test/Flow/InterceptorFlowScenarios.php new file mode 100644 index 0000000000..51c7105ea7 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/InterceptorFlowScenarios.php @@ -0,0 +1,65 @@ + + */ + public static function all(): array + { + return [ + self::interceptorSendingACommandBeforeTheHandler(), + ]; + } + + private static function interceptorSendingACommandBeforeTheHandler(): FlowScenario + { + $orders = new class () { + #[FlowAudited] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + } + + #[CommandHandler('audit.record', endpointId: 'audit.recording')] + public function record(string $orderId): void + { + } + }; + $auditing = new class () { + #[Before(pointcut: FlowAudited::class)] + public function audit(string $orderId, #[Reference] CommandBus $commandBus): void + { + $commandBus->sendWithRouting('audit.record', $orderId); + } + }; + + return new FlowScenario('interceptor sending a command before the handler', [$orders::class, $auditing::class, FlowAudited::class], [$orders, $auditing], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'order.place'])->containing( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'audit.record'])->containing( + ExpectedSpan::named('process class@anonymous::record')->withAttributes([SpanAttribute::ENDPOINT_ID => 'audit.recording']), + ), + ExpectedSpan::named('process class@anonymous::place')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placing']), + ), + ) + ->withNoOtherSpans(), + ]); + } +} diff --git a/packages/Ecotone/src/Test/Flow/MessageFlowGraphVerifier.php b/packages/Ecotone/src/Test/Flow/MessageFlowGraphVerifier.php new file mode 100644 index 0000000000..c117c15b17 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/MessageFlowGraphVerifier.php @@ -0,0 +1,165 @@ +roots() as $root) { + $steps = [...$steps, ...self::stepsFrom($root, null, $tree, $graph)]; + } + + $handlerRan = array_filter($tree->all(), fn (Span $span) => isset($span->attributes[SpanAttribute::CODE_FUNCTION_NAME])); + if ($steps === [] && $handlerRan !== []) { + return "Handlers ran, yet no recorded step could be placed in the message flow graph. Recorded:\n" . $tree->render(); + } + foreach ($steps as [$from, $to]) { + if (! self::hasPath($graph, $from, $to)) { + return "The recorded step {$from} -> {$to} has no path in the message flow graph:\n" . $graph->toMermaid(); + } + } + + return null; + } + + /** + * @return list + */ + private static function stepsFrom(Span $span, ?string $nearestNode, SpanTree $tree, MessageFlowGraph $graph): array + { + $node = self::nodeOf($span, $graph); + $isChannel = $node !== null && str_starts_with($node, 'channel:'); + $steps = []; + if ($node !== null && ! $isChannel && $nearestNode !== null && $nearestNode !== $node) { + $steps[] = [$nearestNode, $node]; + } + foreach ($tree->childrenOf($span) as $child) { + $steps = [...$steps, ...self::stepsFrom($child, $node ?? $nearestNode, $tree, $graph)]; + } + if ($isChannel && $nearestNode !== null && ! str_starts_with($nearestNode, 'channel:')) { + $reachedBelow = self::nearestNodesBelow($span, $tree, $graph); + foreach ($reachedBelow === [] ? [$node] : $reachedBelow as $reached) { + $steps[] = [$nearestNode, $reached]; + } + } + + return $steps; + } + + /** + * @return list + */ + private static function nearestNodesBelow(Span $span, SpanTree $tree, MessageFlowGraph $graph): array + { + $nodes = []; + foreach ($tree->childrenOf($span) as $child) { + $node = self::nodeOf($child, $graph); + $nodes = $node === null || str_starts_with($node, 'channel:') + ? [...$nodes, ...self::nearestNodesBelow($child, $tree, $graph)] + : [...$nodes, $node]; + } + + return $nodes; + } + + private static function nodeOf(Span $span, MessageFlowGraph $graph): ?string + { + $destination = $span->attributes[SpanAttribute::MESSAGING_DESTINATION_NAME] ?? null; + $node = match (true) { + in_array($span->name, ['send command', 'send event', 'send query'], true) => self::messageNodeOf((string) $destination, $graph), + $span->name === 'send distributed' => 'distributed:' . DistributedBus::class, + isset($span->attributes[SpanAttribute::PROJECTION_NAME]) && (str_starts_with($span->name, 'process ') || isset($span->attributes[SpanAttribute::PROJECTION_OPERATION])) => 'projection:' . $span->attributes[SpanAttribute::PROJECTION_NAME], + isset($span->attributes[SpanAttribute::CODE_FUNCTION_NAME]) => 'handler:' . $span->attributes[SpanAttribute::CODE_FUNCTION_NAME], + str_starts_with($span->name, 'send ') => 'channel:' . substr($span->name, 5), + str_starts_with($span->name, 'receive ') => 'channel:' . substr($span->name, 8), + default => null, + }; + + return $node !== null && $graph->hasNode($node) ? $node : null; + } + + private static function messageNodeOf(string $destination, MessageFlowGraph $graph): ?string + { + if ($graph->hasNode('message:' . $destination)) { + return 'message:' . $destination; + } + foreach ($graph->nodes() as $node) { + if (! $node->kind->isMessage()) { + continue; + } + $subscription = (string) ($node->attributes['routingKey'] ?? ''); + if (($node->attributes['namedAs'] ?? null) === $destination + || ($subscription !== '' && BusRoutingMap::globMatch($subscription, $destination)) + || ($subscription !== '' && class_exists($destination) && is_a($destination, $subscription, true))) { + return $node->id; + } + } + + return null; + } + + private static function hasPath(MessageFlowGraph $graph, string $from, string $to): bool + { + if ($graph->hasNode($to) && ($graph->node($to)->attributes['owner'] ?? null) === $from) { + return true; + } + $targets = [$to => true]; + $owner = $graph->hasNode($to) ? ($graph->node($to)->attributes['owner'] ?? null) : null; + if (is_string($owner) && str_starts_with($owner, 'projection:')) { + $targets[$owner] = true; + } + if (str_starts_with($to, 'channel:') && $graph->hasNode($to)) { + foreach ($graph->edgesFrom($to) as $edge) { + $targets[$edge->to] = true; + } + } + foreach ($graph->nodes() as $node) { + if (($node->attributes['owner'] ?? null) === $to) { + $targets[$node->id] = true; + } + } + $visited = [$from => true]; + $queue = [$from]; + while ($queue !== []) { + $current = array_shift($queue); + if (isset($targets[$current])) { + return true; + } + foreach ($graph->edgesFrom($current) as $edge) { + if (! isset($visited[$edge->to])) { + $visited[$edge->to] = true; + $queue[] = $edge->to; + } + } + } + + return false; + } +} diff --git a/packages/Ecotone/src/Test/Flow/ModellingFlowScenarios.php b/packages/Ecotone/src/Test/Flow/ModellingFlowScenarios.php new file mode 100644 index 0000000000..ac1b1c7e27 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/ModellingFlowScenarios.php @@ -0,0 +1,251 @@ + + */ + public static function all(): array + { + return [ + self::stateStoredAggregateRecordingAnEvent(), + self::eventSourcedAggregateReturningAnEvent(), + self::sagaStartedByAnEventSendingACommand(), + self::sagaContinuedByASecondEventMappedToIt(), + self::eventForAnUnknownSagaIsDropped(), + self::eventSourcedAggregateFeedingAProjection(), + ]; + } + + private static function stateStoredAggregateRecordingAnEvent(): FlowScenario + { + $order = new #[Aggregate] class () { + use WithEvents; + + #[Identifier] + public string $orderId = ''; + + #[CommandHandler('order.create', endpointId: 'order.creating')] + public static function create(array $command): self + { + $order = new self(); + $order->orderId = $command['orderId']; + $order->recordThat(new FlowOrderPlaced($command['orderId'])); + + return $order; + } + }; + $subscriber = self::orderPlacedSubscriber(); + + return new FlowScenario('state-stored aggregate recording an event', [$order::class, $subscriber::class, FlowOrderPlaced::class], [$subscriber], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.create', ['orderId' => '1'], metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'order.create'])->containing( + ExpectedSpan::named('process class@anonymous::create')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.creating']), + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => FlowOrderPlaced::class])->containing( + ExpectedSpan::named('route order.placed.handling')->ofKind(SpanKind::Producer)->containing( + ExpectedSpan::named('process class@anonymous::on')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placed.handling']), + ), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function eventSourcedAggregateReturningAnEvent(): FlowScenario + { + $order = new #[EventSourcingAggregate] class () { + use WithAggregateVersioning; + + #[Identifier] + public string $orderId = ''; + + #[CommandHandler('order.create', endpointId: 'order.creating')] + public static function create(array $command): array + { + return [new FlowOrderPlaced($command['orderId'])]; + } + + #[Apply] + public function applyPlaced(FlowOrderPlaced $event): void + { + $this->orderId = $event->orderId; + } + }; + $subscriber = self::orderPlacedSubscriber(); + + return new FlowScenario('event-sourced aggregate returning an event', [$order::class, $subscriber::class, FlowOrderPlaced::class], [$subscriber], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.create', ['orderId' => '1'], metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'order.create'])->containing( + ExpectedSpan::named('process class@anonymous::create')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.creating']), + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => FlowOrderPlaced::class])->containing( + ExpectedSpan::named('route order.placed.handling')->ofKind(SpanKind::Producer)->containing( + ExpectedSpan::named('process class@anonymous::on')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placed.handling']), + ), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function sagaStartedByAnEventSendingACommand(): FlowScenario + { + $saga = new #[Saga] class () { + #[Identifier] + public string $orderId = ''; + + #[EventHandler(endpointId: 'shipping.starting')] + public static function start(FlowOrderPlaced $event, CommandBus $commandBus): self + { + $saga = new self(); + $saga->orderId = $event->orderId; + $commandBus->sendWithRouting('shipping.prepare', $event->orderId); + + return $saga; + } + }; + $shipping = new class () { + #[CommandHandler('shipping.prepare', endpointId: 'shipping.preparing')] + public function prepare(string $orderId): void + { + } + }; + + return new FlowScenario('saga started by an event sending a command', [$saga::class, $shipping::class, FlowOrderPlaced::class], [$shipping], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->publishEvent(new FlowOrderPlaced('1'), metadata: $metadata)) + ->expect( + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => FlowOrderPlaced::class])->containing( + ExpectedSpan::named('route shipping.starting')->ofKind(SpanKind::Producer)->containing( + ExpectedSpan::named('process class@anonymous::start')->withAttributes([SpanAttribute::ENDPOINT_ID => 'shipping.starting'])->containing( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'shipping.prepare'])->containing( + ExpectedSpan::named('process class@anonymous::prepare')->withAttributes([SpanAttribute::ENDPOINT_ID => 'shipping.preparing']), + ), + ), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function sagaContinuedByASecondEventMappedToIt(): FlowScenario + { + $saga = self::shippingSaga(); + + return new FlowScenario('saga continued by a second event mapped to it', [$saga::class, FlowOrderPlaced::class, FlowPaymentReceived::class], [], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->publishEvent(new FlowOrderPlaced('1'), metadata: $metadata)) + ->expect(ExpectedSpan::named('send event')->containing(ExpectedSpan::named('route shipping.starting')->containing(ExpectedSpan::named('process class@anonymous::start')))) + ->withNoOtherSpans(), + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->publishEvent(new FlowPaymentReceived('1'), metadata: $metadata)) + ->expect( + ExpectedSpan::named('send event')->asRoot()->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => FlowPaymentReceived::class])->containing( + ExpectedSpan::named('route shipping.paying')->containing( + ExpectedSpan::named('process class@anonymous::whenPaid')->withAttributes([SpanAttribute::ENDPOINT_ID => 'shipping.paying']), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function eventForAnUnknownSagaIsDropped(): FlowScenario + { + $saga = self::shippingSaga(); + + return new FlowScenario('event for an unknown saga is dropped', [$saga::class, FlowOrderPlaced::class, FlowPaymentReceived::class], [], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->publishEvent(new FlowPaymentReceived('unknown'), metadata: $metadata)) + ->expect(ExpectedSpan::named('send event')->containing(ExpectedSpan::named('route shipping.paying')->withStatus(SpanStatus::Ok))) + ->expectNoSpanNamed('process class@anonymous::whenPaid') + ->withNoOtherSpans(), + ]); + } + + private static function eventSourcedAggregateFeedingAProjection(): FlowScenario + { + $projection = new #[Projection('order_list'), FromAggregateStream(FlowOrder::class)] class () { + #[EventHandler(endpointId: 'order_list.placed')] + public function whenPlaced(FlowOrderPlaced $event): void + { + } + }; + + return new FlowScenario('event-sourced aggregate feeding a projection', [FlowOrder::class, FlowOrderPlaced::class, $projection::class], [$projection], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('flow_order.place', ['orderId' => '1'], metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->containing( + ExpectedSpan::named('process FlowOrder::place')->withAttributes([SpanAttribute::ENDPOINT_ID => 'flow_order.placing']), + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => FlowOrderPlaced::class])->containing( + ExpectedSpan::named('route order_list')->ofKind(SpanKind::Producer)->containing( + ExpectedSpan::named('process order_list')->ofKind(SpanKind::Consumer)->withAttributes([SpanAttribute::PROJECTION_NAME => 'order_list'])->containing( + ExpectedSpan::named('process class@anonymous::whenPlaced'), + ), + ), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function shippingSaga(): object + { + return new #[Saga] class () { + #[Identifier] + public string $orderId = ''; + + #[EventHandler(endpointId: 'shipping.starting')] + public static function start(FlowOrderPlaced $event): self + { + $saga = new self(); + $saga->orderId = $event->orderId; + + return $saga; + } + + #[EventHandler(endpointId: 'shipping.paying', dropMessageOnNotFound: true)] + public function whenPaid(FlowPaymentReceived $event): void + { + } + }; + } + + private static function orderPlacedSubscriber(): object + { + return new class () { + #[EventHandler(endpointId: 'order.placed.handling')] + public function on(FlowOrderPlaced $event): void + { + } + }; + } +} diff --git a/packages/Ecotone/src/Test/Flow/ProjectionFlowScenarios.php b/packages/Ecotone/src/Test/Flow/ProjectionFlowScenarios.php new file mode 100644 index 0000000000..99ddb258c2 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/ProjectionFlowScenarios.php @@ -0,0 +1,247 @@ + + */ + public static function all(): array + { + return [ + self::eventDrivenProjection(), + self::asynchronousProjection(), + self::pollingProjectionReadingItsStream(), + self::partitionedProjectionTriggeredForOnePartition(), + self::projectionOperation('projection triggered', ProjectionOperationEndpoint::TRIGGER, fn (FlowTestSupport $ecotone) => $ecotone->triggerProjection('orders')), + self::projectionOperation('projection reset replays its stream', ProjectionOperationEndpoint::RESET, fn (FlowTestSupport $ecotone) => $ecotone->resetProjection('orders')), + self::projectionOperation('projection backfilled from the console', ProjectionOperationEndpoint::BACKFILL, fn (FlowTestSupport $ecotone) => $ecotone->runConsoleCommand('ecotone:projection:backfill', ['name' => 'orders'])), + self::projectionOperation('projection rebuilt from the console', ProjectionOperationEndpoint::REBUILD, fn (FlowTestSupport $ecotone) => $ecotone->runConsoleCommand('ecotone:projection:rebuild', ['name' => 'orders'])), + self::projectionBackfilledAsynchronously(), + self::projectionRebuiltAsynchronously(), + self::projectionInitializedAndDeleted(), + ]; + } + + private static function projectionOperation(string $name, string $operation, callable $action): FlowScenario + { + $projection = new #[Projection('orders'), FromStream('orders_stream')] class () { + #[EventHandler('order.placed')] + public function whenPlaced(array $event): void + { + } + }; + + return new FlowScenario($name, [$projection::class], [$projection], [], [ + FlowStage::when(function (FlowTestSupport $ecotone) use ($action): void { + $ecotone->withEventStream('orders_stream', [Event::createWithType('order.placed', ['orderId' => '1'])]); + $action($ecotone); + }) + ->expect( + ExpectedSpan::named($operation . ' orders')->asRoot()->ofKind(SpanKind::Server)->withAttributes([SpanAttribute::PROJECTION_NAME => 'orders', SpanAttribute::PROJECTION_OPERATION => $operation])->containing( + ExpectedSpan::named('process class@anonymous::whenPlaced'), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function projectionBackfilledAsynchronously(): FlowScenario + { + $projection = new #[Projection('orders'), FromStream('orders_stream'), ProjectionBackfill(asyncChannelName: 'backfill')] class () { + #[EventHandler('order.placed')] + public function whenPlaced(array $event): void + { + } + }; + + return self::projectionReplayedAsynchronously('projection backfilled asynchronously', ProjectionOperationEndpoint::BACKFILL, $projection); + } + + private static function projectionRebuiltAsynchronously(): FlowScenario + { + $projection = new #[Projection('orders'), FromStream('orders_stream'), ProjectionRebuild(asyncChannelName: 'rebuild')] class () { + #[EventHandler('order.placed')] + public function whenPlaced(array $event): void + { + } + }; + + return self::projectionReplayedAsynchronously('projection rebuilt asynchronously', ProjectionOperationEndpoint::REBUILD, $projection); + } + + private static function projectionReplayedAsynchronously(string $name, string $operation, object $projection): FlowScenario + { + return new FlowScenario($name, [$projection::class], [$projection], [SimpleMessageChannelBuilder::createQueueChannel($operation)], [ + FlowStage::when(function (FlowTestSupport $ecotone) use ($operation): void { + $ecotone->withEventStream('orders_stream', [Event::createWithType('order.placed', ['orderId' => '1'])]); + $ecotone->runConsoleCommand('ecotone:projection:' . $operation, ['name' => 'orders']); + })->leavingWorkPending() + ->expect( + ExpectedSpan::named($operation . ' orders')->asRoot()->ofKind(SpanKind::Server)->containing( + ExpectedSpan::named('send ' . $operation)->ofKind(SpanKind::Producer), + ), + ) + ->expectNoSpanNamed('process class@anonymous::whenPlaced') + ->withNoOtherSpans(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run($operation, ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('receive ' . $operation)->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process class@anonymous::whenPlaced'), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function projectionInitializedAndDeleted(): FlowScenario + { + $projection = new #[Projection('orders'), FromStream('orders_stream')] class () { + #[EventHandler('order.placed')] + public function whenPlaced(array $event): void + { + } + }; + + return new FlowScenario('projection initialized, then deleted', [$projection::class], [$projection], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->initializeProjection('orders')) + ->expect(ExpectedSpan::named('initialize orders')->asRoot()->ofKind(SpanKind::Server)->withAttributes([SpanAttribute::PROJECTION_OPERATION => ProjectionOperationEndpoint::INITIALIZE])) + ->withNoOtherSpans(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->deleteProjection('orders')) + ->expect(ExpectedSpan::named('delete orders')->asRoot()->ofKind(SpanKind::Server)->withAttributes([SpanAttribute::PROJECTION_OPERATION => ProjectionOperationEndpoint::DELETE])) + ->withNoOtherSpans(), + ]); + } + + private static function eventDrivenProjection(): FlowScenario + { + $projection = new #[Projection('orders'), FromStream('orders_stream')] class () { + #[EventHandler('order.placed')] + public function whenPlaced(array $event): void + { + } + }; + + return new FlowScenario('event-driven projection', [$projection::class], [$projection], [], [ + FlowStage::when(function (FlowTestSupport $ecotone, array $metadata): void { + $ecotone->withEvents([Event::createWithType('order.placed', ['orderId' => '1'])]); + $ecotone->publishEventWithRouting('order.placed', [], metadata: $metadata); + }) + ->expect( + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'order.placed'])->containing( + ExpectedSpan::named('route orders')->ofKind(SpanKind::Producer)->withAttributes([SpanAttribute::PROJECTION_NAME => 'orders'])->containing( + ExpectedSpan::named('process orders')->ofKind(SpanKind::Consumer)->withAttributes([SpanAttribute::PROJECTION_NAME => 'orders']), + ), + ), + ), + ]); + } + + private static function asynchronousProjection(): FlowScenario + { + $projection = new #[Projection('orders'), FromStream('orders_stream'), Asynchronous('projections')] class () { + #[EventHandler('order.placed')] + public function whenPlaced(array $event): void + { + } + }; + + return new FlowScenario('asynchronous projection', [$projection::class], [$projection], [SimpleMessageChannelBuilder::createQueueChannel('projections')], [ + FlowStage::when(function (FlowTestSupport $ecotone, array $metadata): void { + $ecotone->withEvents([Event::createWithType('order.placed', ['orderId' => '1'])]); + $ecotone->publishEventWithRouting('order.placed', [], metadata: $metadata); + })->leavingWorkPending() + ->expect( + ExpectedSpan::named('send event')->containing( + ExpectedSpan::named('route orders')->ofKind(SpanKind::Producer)->containing( + ExpectedSpan::named('send projections')->ofKind(SpanKind::Producer), + ), + ), + ) + ->expectNoSpanNamed('process orders'), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('projections', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('receive projections')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process orders')->ofKind(SpanKind::Consumer)->withAttributes([SpanAttribute::PROJECTION_NAME => 'orders']), + ), + ), + ]); + } + + private static function pollingProjectionReadingItsStream(): FlowScenario + { + $projection = new #[Projection('order_board'), Polling('order_board_poller'), FromStream('board_stream')] class () { + #[EventHandler('order.placed', endpointId: 'order_board.placed')] + public function whenPlaced(array $event): void + { + } + }; + + return new FlowScenario('polling projection reading its stream', [$projection::class], [$projection], [], [ + FlowStage::when(function (FlowTestSupport $ecotone): void { + $ecotone->withEventStream('board_stream', [Event::createWithType('order.placed', ['orderId' => '1'])]); + $ecotone->run('order_board_poller', ExecutionPollingMetadata::createWithTestingSetup()); + }) + ->expect( + ExpectedSpan::named('poll order_board_poller')->asRoot()->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process order_board')->ofKind(SpanKind::Consumer)->withAttributes([SpanAttribute::PROJECTION_NAME => 'order_board'])->containing( + ExpectedSpan::named('process class@anonymous::whenPlaced')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order_board.placed']), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function partitionedProjectionTriggeredForOnePartition(): FlowScenario + { + $projection = new #[Projection('tenant_orders'), FromStream('tenant_stream'), Partitioned('tenantId')] class () { + #[EventHandler('order.placed', endpointId: 'tenant_orders.placed')] + public function whenPlaced(array $event): void + { + } + }; + + return new FlowScenario('partitioned projection triggered for one partition', [$projection::class], [$projection], [], [ + FlowStage::when(function (FlowTestSupport $ecotone, array $metadata): void { + $ecotone->withEvents([ + Event::createWithType('order.placed', [], [MessageHeaders::EVENT_AGGREGATE_ID => 'order-1', 'tenantId' => 'tenant-1']), + ]); + $ecotone->publishEventWithRouting('order.placed', [], metadata: [...$metadata, MessageHeaders::EVENT_AGGREGATE_ID => 'order-1', 'tenantId' => 'tenant-1']); + }) + ->expect( + ExpectedSpan::named('send event')->containing( + ExpectedSpan::named('route tenant_orders')->ofKind(SpanKind::Producer)->containing( + ExpectedSpan::named('process tenant_orders')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process class@anonymous::whenPlaced')->withAttributes([SpanAttribute::ENDPOINT_ID => 'tenant_orders.placed']), + ), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } +} diff --git a/packages/Ecotone/src/Test/Flow/RecoverabilityFlowScenarios.php b/packages/Ecotone/src/Test/Flow/RecoverabilityFlowScenarios.php new file mode 100644 index 0000000000..93596cfcee --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/RecoverabilityFlowScenarios.php @@ -0,0 +1,178 @@ + + */ + public static function all(): array + { + return [ + self::delayedMessageConsumedOnceItsDelayPassed(), + self::failureRetriedWithDelayThenSentToDeadLetter(), + self::commandBusRetriesInstantlyUntilTheHandlerSucceeds(), + self::failedMessageIgnoredByItsChannel(), + ]; + } + + private static function delayedMessageConsumedOnceItsDelayPassed(): FlowScenario + { + $orders = self::asynchronousOrders(); + + return new FlowScenario('delayed message is consumed once its delay passed', [$orders::class], [$orders], [SimpleMessageChannelBuilder::createQueueChannel('orders')], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: [...$metadata, MessageHeaders::DELIVERY_DELAY => 60000])) + ->expect(self::queued()) + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup())) + ->expectNoSpanNamed('receive orders') + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(function (FlowTestSupport $ecotone): void { + $ecotone->advanceTimeBy(TimeSpan::withMinutes(2)); + $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup()); + }) + ->expect( + ExpectedSpan::named('send orders')->containing( + ExpectedSpan::named('receive orders')->withStatus(SpanStatus::Ok)->containing( + ExpectedSpan::named('process class@anonymous::place')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placing']), + ), + ), + ), + ]); + } + + private static function failureRetriedWithDelayThenSentToDeadLetter(): FlowScenario + { + $orders = self::failingOrders(); + + return new FlowScenario('failure is retried with a delay, then sent to the dead letter', [$orders::class], [$orders], [ + SimpleMessageChannelBuilder::createQueueChannel('orders'), + SimpleMessageChannelBuilder::createQueueChannel('dead_letter'), + ErrorHandlerConfiguration::createWithDeadLetterChannel('errorChannel', RetryTemplateBuilder::fixedBackOff(0)->maxRetries(1), 'dead_letter'), + InstantRetryConfiguration::createWithDefaults()->withAsynchronousEndpointsRetry(false), + ServiceConfiguration::createWithDefaults()->withDefaultErrorChannel('errorChannel'), + ], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect(self::queued()) + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup(stopOnError: false))) + ->expect( + ExpectedSpan::named('receive orders')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('process class@anonymous::place')->withStatus(SpanStatus::Error, 'Order cannot be placed'), + ExpectedSpan::named('send orders')->ofKind(SpanKind::Producer)->containing( + ExpectedSpan::named('receive orders')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('process class@anonymous::place')->withStatus(SpanStatus::Error), + ExpectedSpan::named('send dead_letter')->ofKind(SpanKind::Producer)->withStatus(SpanStatus::Ok), + ), + ), + ), + ), + ]); + } + + private static function commandBusRetriesInstantlyUntilTheHandlerSucceeds(): FlowScenario + { + $orders = new class () { + private int $attempts = 0; + + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + if (++$this->attempts < 2) { + throw new RuntimeException('Order storage busy'); + } + } + }; + + return new FlowScenario('command bus retries instantly until the handler succeeds', [$orders::class], [$orders], [ + InstantRetryConfiguration::createWithDefaults()->withCommandBusRetry(true, 2), + ], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->withStatus(SpanStatus::Ok)->containing( + ExpectedSpan::named('process class@anonymous::place')->withStatus(SpanStatus::Error, 'Order storage busy'), + ExpectedSpan::named('process class@anonymous::place')->withStatus(SpanStatus::Ok), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function failedMessageIgnoredByItsChannel(): FlowScenario + { + $orders = self::failingOrders(); + + return new FlowScenario('failed message is ignored by its channel', [$orders::class], [$orders], [ + SimpleMessageChannelBuilder::createQueueChannel('orders', finalFailureStrategy: FinalFailureStrategy::IGNORE), + InstantRetryConfiguration::createWithDefaults()->withAsynchronousEndpointsRetry(false), + ], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect(self::queued()) + ->withNoOtherSpans() + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup(stopOnError: false))) + ->expect( + ExpectedSpan::named('receive orders')->withStatus(SpanStatus::Error)->containing( + ExpectedSpan::named('process class@anonymous::place')->withStatus(SpanStatus::Error, 'Order cannot be placed'), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function queued(): ExpectedSpan + { + return ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'order.place'])->containing( + ExpectedSpan::named('send orders')->ofKind(SpanKind::Producer), + ); + } + + private static function asynchronousOrders(): object + { + return new class () { + #[Asynchronous('orders')] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + } + }; + } + + private static function failingOrders(): object + { + return new class () { + #[Asynchronous('orders')] + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + throw new RuntimeException('Order cannot be placed'); + } + }; + } +} diff --git a/packages/Ecotone/src/Test/Flow/RoutingFlowScenarios.php b/packages/Ecotone/src/Test/Flow/RoutingFlowScenarios.php new file mode 100644 index 0000000000..889556a812 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/RoutingFlowScenarios.php @@ -0,0 +1,161 @@ + + */ + public static function all(): array + { + return [ + self::routerSendingToSeveralChannels(), + self::eventReachingAWildcardSubscriber(), + self::eventReachingASubscriberTypedWithItsInterface(), + self::asynchronousAggregateActionOnAnExistingAggregate(), + ]; + } + + private static function routerSendingToSeveralChannels(): FlowScenario + { + $shipments = new class () { + #[CommandHandler('shipment.dispatch', outputChannelName: 'shipment.route', endpointId: 'shipment.dispatching')] + public function dispatch(string $shipmentId): string + { + return $shipmentId; + } + + #[Router('shipment.route')] + public function route(string $shipmentId): array + { + return ['shipment.label', 'shipment.notify']; + } + + #[InternalHandler('shipment.label', endpointId: 'shipment.labelling')] + public function label(string $shipmentId): void + { + } + + #[InternalHandler('shipment.notify', endpointId: 'shipment.notifying')] + public function notify(string $shipmentId): void + { + } + }; + + return new FlowScenario('router sending the command to several channels', [$shipments::class], [$shipments], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('shipment.dispatch', '1', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->containing( + ExpectedSpan::named('process class@anonymous::dispatch'), + ExpectedSpan::named('process class@anonymous::label')->withAttributes([SpanAttribute::ENDPOINT_ID => 'shipment.labelling']), + ExpectedSpan::named('process class@anonymous::notify')->withAttributes([SpanAttribute::ENDPOINT_ID => 'shipment.notifying']), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function eventReachingAWildcardSubscriber(): FlowScenario + { + $audit = new class () { + #[EventHandler('order.*', endpointId: 'order.auditing')] + public function audit(string $orderId): void + { + } + }; + + return new FlowScenario('event reaching a wildcard subscriber', [$audit::class], [$audit], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->publishEventWithRouting('order.placed', '1', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'order.placed'])->containing( + ExpectedSpan::named('route order.auditing')->ofKind(SpanKind::Producer)->containing( + ExpectedSpan::named('process class@anonymous::audit')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.auditing']), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function eventReachingASubscriberTypedWithItsInterface(): FlowScenario + { + $audit = new class () { + #[EventHandler(endpointId: 'order.auditing')] + public function audit(FlowOrderEvent $event): void + { + } + }; + + return new FlowScenario('event reaching a subscriber typed with its interface', [$audit::class, FlowOrderEvent::class, FlowOrderCancelled::class], [$audit], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->publishEvent(new FlowOrderCancelled('1'), metadata: $metadata)) + ->expect( + ExpectedSpan::named('send event')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => FlowOrderCancelled::class])->containing( + ExpectedSpan::named('route order.auditing')->containing( + ExpectedSpan::named('process class@anonymous::audit'), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function asynchronousAggregateActionOnAnExistingAggregate(): FlowScenario + { + $order = new #[Aggregate] class () { + #[Identifier] + public string $orderId = ''; + + #[CommandHandler('order.create', endpointId: 'order.creating')] + public static function create(array $command): self + { + $order = new self(); + $order->orderId = $command['orderId']; + + return $order; + } + + #[Asynchronous('orders')] + #[CommandHandler('order.cancel', endpointId: 'order.cancelling')] + public function cancel(): void + { + } + }; + + return new FlowScenario('asynchronous aggregate action on an existing aggregate', [$order::class], [], [SimpleMessageChannelBuilder::createQueueChannel('orders')], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.create', ['orderId' => '1'], metadata: $metadata)) + ->expect(ExpectedSpan::named('send command')->containing(ExpectedSpan::named('process class@anonymous::create'))) + ->withNoOtherSpans(), + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.cancel', metadata: [...$metadata, 'aggregate.id' => '1'])) + ->expect(ExpectedSpan::named('send command')->asRoot()->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'order.cancel'])->containing(ExpectedSpan::named('send orders')->ofKind(SpanKind::Producer))) + ->expectNoSpanNamed('process class@anonymous::cancel') + ->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('orders', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('receive orders')->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process class@anonymous::cancel')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.cancelling']), + ), + ), + ]); + } +} diff --git a/packages/Ecotone/src/Test/Flow/SpanTree.php b/packages/Ecotone/src/Test/Flow/SpanTree.php new file mode 100644 index 0000000000..ec3678356b --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/SpanTree.php @@ -0,0 +1,96 @@ + $spans + * @param array> $childrenByParent + * @param array $spansById + */ + private function __construct( + private readonly array $spans, + private readonly array $childrenByParent, + private readonly array $spansById, + ) { + } + + public function parentOf(Span $span): ?Span + { + return $span->parentSpanId === null ? null : ($this->spansById[$span->parentSpanId] ?? null); + } + + /** + * @param Span[] $spans + */ + public static function of(array $spans): self + { + $spans = array_values(array_filter($spans, fn (Span $span) => ! ExpectedSpan::isFrameworkNoise($span))); + $childrenByParent = []; + $spansById = []; + foreach ($spans as $span) { + $spansById[$span->spanId] = $span; + if ($span->parentSpanId !== null) { + $childrenByParent[$span->parentSpanId][] = $span; + } + } + + return new self($spans, $childrenByParent, $spansById); + } + + /** + * @return list + */ + public function all(): array + { + return $this->spans; + } + + /** + * @return list + */ + public function roots(): array + { + $ids = []; + foreach ($this->spans as $span) { + $ids[$span->spanId] = true; + } + + return array_values(array_filter($this->spans, fn (Span $span) => $span->parentSpanId === null || ! isset($ids[$span->parentSpanId]))); + } + + /** + * @return list + */ + public function childrenOf(Span $span): array + { + return $this->childrenByParent[$span->spanId] ?? []; + } + + public function render(): string + { + return implode("\n", array_map(fn (Span $root) => $this->renderSpan($root, 1), $this->roots())); + } + + private function renderSpan(Span $span, int $depth): string + { + return implode("\n", [ + str_repeat(' ', $depth) . $span->name . ' [' . $span->kind->name . '] (' . $span->status->name . ')', + ...array_map(fn (Span $child) => $this->renderSpan($child, $depth + 1), $this->childrenOf($span)), + ]); + } +} diff --git a/packages/Ecotone/src/Test/Flow/SynchronousFlowScenarios.php b/packages/Ecotone/src/Test/Flow/SynchronousFlowScenarios.php new file mode 100644 index 0000000000..58cecceb60 --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/SynchronousFlowScenarios.php @@ -0,0 +1,214 @@ + + */ + public static function all(): array + { + return [ + self::commandHandler(), + self::queryHandler(), + self::outputChannelChain(), + self::commandSentFromCommandHandler(), + self::splitter(), + self::router(), + ]; + } + + private static function commandHandler(): FlowScenario + { + $orders = new class () { + #[CommandHandler('order.place', endpointId: 'order.placing')] + public function place(string $orderId): void + { + } + }; + + return new FlowScenario('command handler', [$orders::class], [$orders], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->ofKind(SpanKind::Server)->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'order.place'])->containing( + ExpectedSpan::named('process class@anonymous::place')->ofKind(SpanKind::Server)->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placing']), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function queryHandler(): FlowScenario + { + $orders = new class () { + #[QueryHandler('order.status', endpointId: 'order.status.reading')] + public function status(): string + { + return 'placed'; + } + }; + + return new FlowScenario('query handler', [$orders::class], [$orders], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendQueryWithRouting('order.status', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send query')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'order.status'])->containing( + ExpectedSpan::named('process class@anonymous::status')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.status.reading']), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function outputChannelChain(): FlowScenario + { + $orders = new class () { + #[CommandHandler('order.place', outputChannelName: 'order.validate', endpointId: 'order.placing')] + public function place(string $orderId): string + { + return $orderId; + } + + #[InternalHandler('order.validate', outputChannelName: 'order.store', endpointId: 'order.validating')] + public function validate(string $orderId): string + { + return $orderId; + } + + #[InternalHandler('order.store', endpointId: 'order.storing')] + public function store(string $orderId): void + { + } + }; + + return new FlowScenario('output channel chain of three handlers', [$orders::class], [$orders], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('order.place', '1', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->containing( + ExpectedSpan::named('process class@anonymous::place')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.placing']), + ExpectedSpan::named('process class@anonymous::validate')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.validating']), + ExpectedSpan::named('process class@anonymous::store')->withAttributes([SpanAttribute::ENDPOINT_ID => 'order.storing']), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function commandSentFromCommandHandler(): FlowScenario + { + $users = new class () { + #[CommandHandler('user.register', endpointId: 'user.registration')] + public function register(string $userId, CommandBus $commandBus): void + { + $commandBus->sendWithRouting('audit.record', $userId); + } + + #[CommandHandler('audit.record', endpointId: 'audit.recording')] + public function record(string $userId): void + { + } + }; + + return new FlowScenario('command sent from a command handler', [$users::class], [$users], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('user.register', '1', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'user.register'])->containing( + ExpectedSpan::named('process class@anonymous::register')->withAttributes([SpanAttribute::ENDPOINT_ID => 'user.registration'])->containing( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'audit.record'])->containing( + ExpectedSpan::named('process class@anonymous::record')->withAttributes([SpanAttribute::ENDPOINT_ID => 'audit.recording']), + ), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function splitter(): FlowScenario + { + $imports = new class () { + #[CommandHandler('batch.import', outputChannelName: 'batch.split', endpointId: 'batch.importing')] + public function import(array $items): array + { + return $items; + } + + #[Splitter('batch.split', outputChannelName: 'item.handle')] + public function split(array $items): array + { + return $items; + } + + #[InternalHandler('item.handle', endpointId: 'item.handling')] + public function handle(string $item): void + { + } + }; + + return new FlowScenario('splitter fans one command out into one handler per item', [$imports::class], [$imports], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('batch.import', ['a', 'b'], metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->containing( + ExpectedSpan::named('process class@anonymous::import')->withAttributes([SpanAttribute::ENDPOINT_ID => 'batch.importing']), + ExpectedSpan::named('process class@anonymous::handle')->withAttributes([SpanAttribute::ENDPOINT_ID => 'item.handling']), + ExpectedSpan::named('process class@anonymous::handle')->withAttributes([SpanAttribute::ENDPOINT_ID => 'item.handling']), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function router(): FlowScenario + { + $shipments = new class () { + #[CommandHandler('shipment.dispatch', outputChannelName: 'shipment.route', endpointId: 'shipment.dispatching')] + public function dispatch(string $shipmentId): string + { + return $shipmentId; + } + + #[Router('shipment.route')] + public function route(string $shipmentId): string + { + return 'shipment.express'; + } + + #[InternalHandler('shipment.express', endpointId: 'shipment.expressing')] + public function express(string $shipmentId): void + { + } + + #[InternalHandler('shipment.standard', endpointId: 'shipment.standard.handling')] + public function standard(string $shipmentId): void + { + } + }; + + return new FlowScenario('router leads the command to the handler it chose', [$shipments::class], [$shipments], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('shipment.dispatch', '1', metadata: $metadata)) + ->expect( + ExpectedSpan::named('send command')->withAttributes([SpanAttribute::MESSAGING_DESTINATION_NAME => 'shipment.dispatch'])->containing( + ExpectedSpan::named('process class@anonymous::dispatch')->withAttributes([SpanAttribute::ENDPOINT_ID => 'shipment.dispatching']), + ExpectedSpan::named('process class@anonymous::express')->withAttributes([SpanAttribute::ENDPOINT_ID => 'shipment.expressing']), + ), + ) + ->expectNoSpanNamed('process class@anonymous::standard') + ->withNoOtherSpans(), + ]); + } +} diff --git a/packages/Ecotone/src/Test/Flow/TraceFlowScenarios.php b/packages/Ecotone/src/Test/Flow/TraceFlowScenarios.php new file mode 100644 index 0000000000..6edb0deb3c --- /dev/null +++ b/packages/Ecotone/src/Test/Flow/TraceFlowScenarios.php @@ -0,0 +1,352 @@ + + */ + public static function all(): array + { + return [ + self::twoCommandsStartTwoTraces(), + self::twoAsynchronousCommandsKeepTheirOwnTraces(), + self::userHeadersTravelIntoEverySpan(), + self::scheduledPollsStartATraceEach(), + self::scheduledEndpointWithoutRequestChannelStartsATraceEachPoll(), + self::scheduledEndpointProducingNothingRecordsNothing(), + self::logsBecomeEventsOfTheSpanTheyWereWrittenIn(), + self::commandKeepsTheIdentityItWasSentWith(), + self::messagesSentFromAHandlerPointAtTheMessageThatCausedThem(), + self::commandEventCommandChainPointsEachMessageAtItsCause(), + self::asynchronousConsumerKeepsTheIdentityOfTheMessageItReceived(), + self::commandCarryingATraceCarrierContinuesThatTrace(), + ]; + } + + private static function twoCommandsStartTwoTraces(): FlowScenario + { + $users = self::users(); + $send = fn (string $flowId) => FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('user.register', $flowId, metadata: [...$metadata, 'flowId' => $flowId])) + ->expect( + ExpectedSpan::named('send command')->asRoot()->withAttributes(['flowId' => $flowId])->containing( + ExpectedSpan::named('process class@anonymous::register')->withAttributes(['flowId' => $flowId]), + ), + ) + ->withNoOtherSpans(); + + return new FlowScenario('two commands start two separate traces', [$users::class], [$users], [], [$send('1'), $send('2')]); + } + + private static function twoAsynchronousCommandsKeepTheirOwnTraces(): FlowScenario + { + $users = self::asynchronousUsers(); + $send = fn (string $flowId) => FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('user.register', $flowId, metadata: [...$metadata, 'flowId' => $flowId]))->leavingWorkPending() + ->expect( + ExpectedSpan::named('send command')->asRoot()->withAttributes(['flowId' => $flowId])->containing( + ExpectedSpan::named('send users')->withAttributes(['flowId' => $flowId]), + ), + ); + $consumed = fn (string $flowId) => ExpectedSpan::named('send command')->asRoot()->withAttributes(['flowId' => $flowId])->containing( + ExpectedSpan::named('send users')->withAttributes(['flowId' => $flowId])->containing( + ExpectedSpan::named('receive users')->withAttributes(['flowId' => $flowId])->containing( + ExpectedSpan::named('process class@anonymous::register')->withAttributes(['flowId' => $flowId]), + ), + ), + ); + + return new FlowScenario('two asynchronous commands consumed in one run keep their own traces', [$users::class], [$users], [SimpleMessageChannelBuilder::createQueueChannel('users')], [ + $send('1'), + $send('2'), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('users', ExecutionPollingMetadata::createWithTestingSetup(handledMessageLimit: 2))) + ->expect($consumed('1'), $consumed('2')), + ]); + } + + private static function userHeadersTravelIntoEverySpan(): FlowScenario + { + $users = self::asynchronousUsers(); + $carrying = fn (string $name) => ExpectedSpan::named($name)->withAttributes(['user_id' => '123'])->withoutAttributes('tokens', 'user', MessageHeaders::TRACEPARENT); + + return new FlowScenario('user headers travel into every span, non-scalar ones and the trace carrier do not', [$users::class], [$users], [SimpleMessageChannelBuilder::createQueueChannel('users')], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('user.register', '1', metadata: [...$metadata, 'user_id' => '123', 'tokens' => ['123'], 'user' => new stdClass()]))->leavingWorkPending() + ->expect($carrying('send command')->containing($carrying('send users'))), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('users', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect($carrying('receive users')->containing($carrying('process class@anonymous::register'))), + ]); + } + + private static function scheduledPollsStartATraceEach(): FlowScenario + { + $workflow = new class () { + #[Scheduled(requestChannelName: 'add', endpointId: 'scheduled_adding')] + #[Poller(fixedRateInMilliseconds: 1)] + public function produce(): int + { + return 1; + } + + #[InternalHandler(inputChannelName: 'add', endpointId: 'adding')] + public function add(): void + { + } + }; + $poll = FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('scheduled_adding', ExecutionPollingMetadata::createWithDefaults()->withExecutionAmountLimit(1))) + ->expect( + ExpectedSpan::named('receive add')->asRoot()->ofKind(SpanKind::Consumer)->containing( + ExpectedSpan::named('process class@anonymous::add')->withAttributes([SpanAttribute::ENDPOINT_ID => 'adding']), + ), + ) + ->withNoOtherSpans(); + + return new FlowScenario('scheduled endpoint starts a trace on every poll', [$workflow::class], [$workflow], [], [$poll, $poll]); + } + + private static function scheduledEndpointWithoutRequestChannelStartsATraceEachPoll(): FlowScenario + { + $scheduled = new class () { + #[Scheduled(endpointId: 'scheduled_handler')] + #[Poller(fixedRateInMilliseconds: 1)] + public function handle(): void + { + } + }; + $poll = FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('scheduled_handler', ExecutionPollingMetadata::createWithDefaults()->withExecutionAmountLimit(1))) + ->expect(ExpectedSpan::named('poll scheduled_handler')->asRoot()->ofKind(SpanKind::Consumer)) + ->withNoOtherSpans(); + + return new FlowScenario('scheduled endpoint without a request channel starts a trace on every poll', [$scheduled::class], [$scheduled], [], [$poll, $poll]); + } + + private static function scheduledEndpointProducingNothingRecordsNothing(): FlowScenario + { + $workflow = new class () { + #[Scheduled(requestChannelName: 'add', endpointId: 'scheduled_nothing')] + #[Poller(fixedRateInMilliseconds: 1)] + public function produceNothing(): ?int + { + return null; + } + + #[InternalHandler(inputChannelName: 'add')] + public function add(): void + { + } + }; + + return new FlowScenario('scheduled endpoint producing nothing records nothing', [$workflow::class], [$workflow], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('scheduled_nothing', ExecutionPollingMetadata::createWithDefaults()->withExecutionAmountLimit(1))) + ->withNoOtherSpans(), + ]); + } + + private static function logsBecomeEventsOfTheSpanTheyWereWrittenIn(): FlowScenario + { + $users = new class () { + #[Asynchronous('users')] + #[CommandHandler('user.register', endpointId: 'user.registration')] + public function register(string $userId, EventBus $eventBus): void + { + $eventBus->publishWithRouting('user.registered', $userId); + } + }; + $notifier = new class () { + #[Asynchronous('users')] + #[EventHandler('user.registered', endpointId: 'user.notifying')] + public function notify(string $userId): void + { + } + }; + + return new FlowScenario('logs become events of the span they were written in', [$users::class, $notifier::class], [$users, $notifier], [SimpleMessageChannelBuilder::createQueueChannel('users')], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('user.register', '1', metadata: $metadata))->leavingWorkPending(), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('users', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect( + ExpectedSpan::named('receive users')->containing( + ExpectedSpan::named('process class@anonymous::register')->containing( + ExpectedSpan::named('send event')->withEventStartingWith('Publishing Event Message')->containing( + ExpectedSpan::named('route user.notifying')->withEventStartingWith('Collecting message with id'), + ), + ), + ), + ), + ]); + } + + private static function commandKeepsTheIdentityItWasSentWith(): FlowScenario + { + $users = self::users(); + [$messageId, $correlationId] = [Uuid::v7()->toRfc4122(), Uuid::v7()->toRfc4122()]; + $identified = fn (string $name) => ExpectedSpan::named($name)->withAttributes([ + MessageHeaders::MESSAGE_ID => $messageId, + MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId, + MessageHeaders::TIMESTAMP => self::TIMESTAMP, + ]); + + return new FlowScenario('command keeps the message identity it was sent with', [$users::class], [$users], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('user.register', '1', metadata: [...$metadata, ...self::identity($messageId, $correlationId)])) + ->expect($identified('send command')->containing($identified('process class@anonymous::register'))) + ->withNoOtherSpans(), + ]); + } + + private static function messagesSentFromAHandlerPointAtTheMessageThatCausedThem(): FlowScenario + { + $users = self::users(); + $subscriber = self::merchantSubscriber(); + [$messageId, $correlationId] = [Uuid::v7()->toRfc4122(), Uuid::v7()->toRfc4122()]; + $original = fn (string $name) => ExpectedSpan::named($name)->withAttributes([MessageHeaders::MESSAGE_ID => $messageId, MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId, MessageHeaders::TIMESTAMP => self::TIMESTAMP]); + $caused = fn (string $name) => ExpectedSpan::named($name)->withAttributes([MessageHeaders::PARENT_MESSAGE_ID => $messageId, MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId])->withAttributeOtherThan(MessageHeaders::MESSAGE_ID, $messageId); + + return new FlowScenario('messages sent from a handler point at the message that caused them', [$users::class, $subscriber::class], [$users, $subscriber], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->publishEventWithRouting('merchant.created', '1', metadata: [...$metadata, ...self::identity($messageId, $correlationId)])) + ->expect( + $original('send event')->containing( + $original('route merchant.to.user')->containing( + $original('process class@anonymous::merchantToUser')->containing( + $caused('send command')->containing($caused('process class@anonymous::register')), + ), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function commandEventCommandChainPointsEachMessageAtItsCause(): FlowScenario + { + $merchants = new class () { + #[CommandHandler('merchant.create', endpointId: 'merchant.creation')] + public function create(string $merchantId, EventBus $eventBus): void + { + $eventBus->publishWithRouting('merchant.created', $merchantId); + } + }; + $users = self::users(); + $subscriber = self::merchantSubscriber(); + [$messageId, $correlationId] = [Uuid::v7()->toRfc4122(), Uuid::v7()->toRfc4122()]; + $original = fn (string $name) => ExpectedSpan::named($name)->withAttributes([MessageHeaders::MESSAGE_ID => $messageId, MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId, MessageHeaders::TIMESTAMP => self::TIMESTAMP]); + $causedByOriginal = fn (string $name) => ExpectedSpan::named($name)->withAttributes([MessageHeaders::PARENT_MESSAGE_ID => $messageId, MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId])->withAttributeOtherThan(MessageHeaders::MESSAGE_ID, $messageId); + + return new FlowScenario('command, event and command each point at the message that caused them', [$merchants::class, $users::class, $subscriber::class], [$merchants, $users, $subscriber], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('merchant.create', '1', metadata: [...$metadata, ...self::identity($messageId, $correlationId)])) + ->expect( + $original('send command')->containing( + $original('process class@anonymous::create')->containing( + $causedByOriginal('send event')->containing( + $causedByOriginal('route merchant.to.user')->containing( + $causedByOriginal('process class@anonymous::merchantToUser')->containing( + ExpectedSpan::named('send command') + ->withAttributeEqualToParents(MessageHeaders::PARENT_MESSAGE_ID, MessageHeaders::MESSAGE_ID) + ->withAttributes([MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId]) + ->containing( + ExpectedSpan::named('process class@anonymous::register') + ->withAttributeEqualToParents(MessageHeaders::MESSAGE_ID) + ->withAttributes([MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId]), + ), + ), + ), + ), + ), + ), + ) + ->withNoOtherSpans(), + ]); + } + + private static function asynchronousConsumerKeepsTheIdentityOfTheMessageItReceived(): FlowScenario + { + $users = self::asynchronousUsers(); + [$messageId, $correlationId] = [Uuid::v7()->toRfc4122(), Uuid::v7()->toRfc4122()]; + $identified = fn (string $name) => ExpectedSpan::named($name)->withAttributes([MessageHeaders::MESSAGE_ID => $messageId, MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId]); + + return new FlowScenario('asynchronous consumer keeps the identity of the message it received', [$users::class], [$users], [SimpleMessageChannelBuilder::createQueueChannel('users')], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('user.register', '1', metadata: [...$metadata, ...self::identity($messageId, $correlationId)]))->leavingWorkPending() + ->expect($identified('send command')->containing(ExpectedSpan::named('send users'))), + FlowStage::when(fn (FlowTestSupport $ecotone) => $ecotone->run('users', ExecutionPollingMetadata::createWithTestingSetup())) + ->expect($identified('receive users')->containing($identified('process class@anonymous::register'))), + ]); + } + + private static function commandCarryingATraceCarrierContinuesThatTrace(): FlowScenario + { + $users = self::users(); + + return new FlowScenario('command carrying a trace carrier continues that trace', [$users::class], [$users], [], [ + FlowStage::when(fn (FlowTestSupport $ecotone, array $metadata) => $ecotone->sendCommandWithRouting('user.register', '1', metadata: [...$metadata, MessageHeaders::TRACEPARENT => '00-' . self::CARRIED_TRACE_ID . '-' . self::CARRIED_SPAN_ID . '-01'])) + ->expect( + ExpectedSpan::named('send command')->inTrace(self::CARRIED_TRACE_ID)->withParentSpanId(self::CARRIED_SPAN_ID)->containing( + ExpectedSpan::named('process class@anonymous::register')->inTrace(self::CARRIED_TRACE_ID), + ), + ) + ->withNoOtherSpans(), + ]); + } + + /** + * @return array + */ + private static function identity(string $messageId, string $correlationId): array + { + return [MessageHeaders::MESSAGE_ID => $messageId, MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId, MessageHeaders::TIMESTAMP => self::TIMESTAMP]; + } + + private static function users(): object + { + return new class () { + #[CommandHandler('user.register', endpointId: 'user.registration')] + public function register(string $userId): void + { + } + }; + } + + private static function asynchronousUsers(): object + { + return new class () { + #[Asynchronous('users')] + #[CommandHandler('user.register', endpointId: 'user.registration')] + public function register(string $userId): void + { + } + }; + } + + private static function merchantSubscriber(): object + { + return new class () { + #[EventHandler('merchant.created', endpointId: 'merchant.to.user')] + public function merchantToUser(string $merchantId, CommandBus $commandBus): void + { + $commandBus->sendWithRouting('user.register', $merchantId); + } + }; + } +} diff --git a/packages/Ecotone/src/Tracing/ActiveSpan.php b/packages/Ecotone/src/Tracing/ActiveSpan.php index f1af96a8db..cb50a7fa05 100644 --- a/packages/Ecotone/src/Tracing/ActiveSpan.php +++ b/packages/Ecotone/src/Tracing/ActiveSpan.php @@ -15,6 +15,8 @@ interface ActiveSpan { public function context(): SpanContext; + public function name(): string; + /** * @return array */ @@ -25,6 +27,12 @@ public function attributes(): array; */ public function addEvent(string $name, array $attributes = []): void; + public function setAttribute(string $key, int|float|string|bool $value): void; + + public function childStarted(): void; + + public function hasStartedChildren(): bool; + public function end(): void; public function endedSoFar(): Span; diff --git a/packages/OpenTelemetry/src/Configuration/TraceDynamicConsistencyBoundaryCompilerPass.php b/packages/Ecotone/src/Tracing/Config/TraceDynamicConsistencyBoundaryCompilerPass.php similarity index 58% rename from packages/OpenTelemetry/src/Configuration/TraceDynamicConsistencyBoundaryCompilerPass.php rename to packages/Ecotone/src/Tracing/Config/TraceDynamicConsistencyBoundaryCompilerPass.php index 328bb3e98d..ae1f99ad5b 100644 --- a/packages/OpenTelemetry/src/Configuration/TraceDynamicConsistencyBoundaryCompilerPass.php +++ b/packages/Ecotone/src/Tracing/Config/TraceDynamicConsistencyBoundaryCompilerPass.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Ecotone\OpenTelemetry\Configuration; +namespace Ecotone\Tracing\Config; use Ecotone\Api\EventSourcing\EventStore; use Ecotone\Messaging\Config\Container\Compiler\CompilerPass; @@ -10,9 +10,10 @@ use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\Container\Reference; use Ecotone\Modelling\DecisionModel\DecisionModelBatchLoader; -use Ecotone\OpenTelemetry\TracedDecisionModelBatchLoader; -use Ecotone\OpenTelemetry\TracedEventStore; -use OpenTelemetry\API\Trace\TracerProviderInterface; +use Ecotone\Tracing\MessageSpanAttributes; +use Ecotone\Tracing\TracedDecisionModelBatchLoader; +use Ecotone\Tracing\TracedEventStore; +use Ecotone\Tracing\Tracer; use function str_starts_with; use function strlen; @@ -26,12 +27,6 @@ final class TraceDynamicConsistencyBoundaryCompilerPass implements CompilerPass private const BATCH_LOADER_REFERENCE_PREFIX = 'decisionModel.batchLoader.'; public function process(ContainerBuilder $builder): void - { - $this->traceEachDecisionModelLoad($builder); - $this->traceConditionalAppends($builder); - } - - private function traceEachDecisionModelLoad(ContainerBuilder $builder): void { foreach ($builder->getDefinitions() as $id => $definition) { if (! str_starts_with($id, self::BATCH_LOADER_REFERENCE_PREFIX) || ! $definition instanceof Definition || $definition->getClassName() !== DecisionModelBatchLoader::class) { @@ -41,20 +36,16 @@ private function traceEachDecisionModelLoad(ContainerBuilder $builder): void $builder->replace($id, new Definition(TracedDecisionModelBatchLoader::class, [ $definition, substr($id, strlen(self::BATCH_LOADER_REFERENCE_PREFIX)), - new Reference(TracerProviderInterface::class), + new Reference(Tracer::class), + new Reference(MessageSpanAttributes::class), ])); } - } - private function traceConditionalAppends(ContainerBuilder $builder): void - { - if (! $builder->has(EventStore::RAW_REFERENCE)) { - return; + if ($builder->has(EventStore::RAW_REFERENCE)) { + $builder->replace(EventStore::RAW_REFERENCE, new Definition(TracedEventStore::class, [ + $builder->getDefinition(EventStore::RAW_REFERENCE), + new Reference(Tracer::class), + ])); } - - $builder->replace(EventStore::RAW_REFERENCE, new Definition(TracedEventStore::class, [ - $builder->getDefinition(EventStore::RAW_REFERENCE), - new Reference(TracerProviderInterface::class), - ])); } } diff --git a/packages/Ecotone/src/Tracing/Config/TracingChannelInterceptorBuilder.php b/packages/Ecotone/src/Tracing/Config/TracingChannelInterceptorBuilder.php index 2b802cddcc..a72aae57c7 100644 --- a/packages/Ecotone/src/Tracing/Config/TracingChannelInterceptorBuilder.php +++ b/packages/Ecotone/src/Tracing/Config/TracingChannelInterceptorBuilder.php @@ -4,11 +4,11 @@ namespace Ecotone\Tracing\Config; -use Ecotone\Api\Interceptor\Precedence; use Ecotone\Messaging\Channel\ChannelInterceptorBuilder; use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\Container\MessagingContainerBuilder; use Ecotone\Messaging\Config\Container\Reference; +use Ecotone\Messaging\PrecedenceChannelInterceptor; use Ecotone\Messaging\Support\MessageBuilder; use Ecotone\Tracing\MessageSpanAttributes; use Ecotone\Tracing\Tracer; @@ -30,7 +30,7 @@ public function relatedChannelName(): string public function getPrecedence(): int { - return Precedence::DEFAULT_PRECEDENCE; + return PrecedenceChannelInterceptor::TRACING_PRECEDENCE; } public function compile(MessagingContainerBuilder $builder): Definition diff --git a/packages/Ecotone/src/Tracing/Config/TracingModule.php b/packages/Ecotone/src/Tracing/Config/TracingModule.php index 1984bc332c..9324d7de0e 100644 --- a/packages/Ecotone/src/Tracing/Config/TracingModule.php +++ b/packages/Ecotone/src/Tracing/Config/TracingModule.php @@ -26,6 +26,8 @@ use Ecotone\Api\Tracing\CurrentTrace; use Ecotone\Api\Tracing\SpanRecorder; use Ecotone\Api\Tracing\TracingConfiguration; +use Ecotone\Messaging\Channel\Collector\MessageCollectionObserver; +use Ecotone\Messaging\Channel\Collector\UnobservedMessageCollection; use Ecotone\Messaging\Config\Annotation\AnnotationModule; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ExtensionObjectResolver; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\NoExternalConfigurationModule; @@ -38,6 +40,7 @@ use Ecotone\Messaging\Handler\Processor\MethodInvoker\MethodInterceptorBuilder; use Ecotone\Messaging\Support\MessageBuilder; use Ecotone\Modelling\MessageHandling\MetadataPropagator\MessageHeadersPropagatorInterceptor; +use Ecotone\Projecting\ProjectionOperationEndpoint; use Ecotone\Projecting\ProjectionTriggerEndpoint; use Ecotone\Tracing\CompositeSpanRecorder; use Ecotone\Tracing\EcotoneTracer; @@ -46,6 +49,7 @@ use Ecotone\Tracing\TracerCurrentTrace; use Ecotone\Tracing\TracingDisabledCurrentTrace; use Ecotone\Tracing\TracingInterceptor; +use Ecotone\Tracing\TracingMessageCollectionObserver; #[ModuleAnnotation] /** @@ -93,10 +97,12 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO $spanRecorderReferences = ExtensionObjectResolver::resolve(SpanRecorderReference::class, $extensionObjects); if ($tracingConfiguration->spanRecorderReferenceNames === [] && $tracerReferences === [] && $spanRecorderReferences === []) { $messagingConfiguration->registerServiceDefinition(CurrentTrace::class, new Definition(TracingDisabledCurrentTrace::class)); + $messagingConfiguration->registerServiceDefinition(MessageCollectionObserver::class, new Definition(UnobservedMessageCollection::class)); return; } $messagingConfiguration->registerServiceDefinition(CurrentTrace::class, new Definition(TracerCurrentTrace::class, [new Reference(Tracer::class)])); + $messagingConfiguration->registerServiceDefinition(MessageCollectionObserver::class, new Definition(TracingMessageCollectionObserver::class, [new Reference(Tracer::class)])); $spanRecorders = [ ...array_map(fn (string $referenceName): Reference => new Reference($referenceName), $tracingConfiguration->spanRecorderReferenceNames), @@ -112,6 +118,7 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO ); $messagingConfiguration->addCompilerPass(new RegisterSpanEventLoggerCompilerPass()); $messagingConfiguration->addCompilerPass(new TraceEventRoutesCompilerPass()); + $messagingConfiguration->addCompilerPass(new TraceDynamicConsistencyBoundaryCompilerPass()); $messagingConfiguration->registerServiceDefinition( TracingInterceptor::class, new Definition(TracingInterceptor::class, [new Reference(Tracer::class), new Reference(MessageBuilder::class), new Reference(MessageSpanAttributes::class), $this->endpointIdsByHandler]) @@ -127,6 +134,7 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO 'traceEventHandler' => [EventHandler::class, Precedence::TRACING_HANDLER_PRECEDENCE], 'traceMessageHandler' => [InternalHandler::class . '&¬(' . MessageHeadersPropagatorInterceptor::class . ')', Precedence::TRACING_HANDLER_PRECEDENCE], 'traceProjection' => [ProjectionTriggerEndpoint::class, Precedence::TRACING_HANDLER_PRECEDENCE], + 'traceProjectionOperation' => [ProjectionOperationEndpoint::class, Precedence::TRACING_PRECEDENCE], 'traceCommandBus' => [CommandBus::class, Precedence::TRACING_PRECEDENCE], 'traceQueryBus' => [QueryBus::class, Precedence::TRACING_PRECEDENCE], 'traceEventBus' => [EventBus::class, Precedence::TRACING_PRECEDENCE], diff --git a/packages/OpenTelemetry/src/DynamicConsistencyBoundarySpanAttributes.php b/packages/Ecotone/src/Tracing/DynamicConsistencyBoundarySpanAttributes.php similarity index 98% rename from packages/OpenTelemetry/src/DynamicConsistencyBoundarySpanAttributes.php rename to packages/Ecotone/src/Tracing/DynamicConsistencyBoundarySpanAttributes.php index 248e467e43..2080999ccd 100644 --- a/packages/OpenTelemetry/src/DynamicConsistencyBoundarySpanAttributes.php +++ b/packages/Ecotone/src/Tracing/DynamicConsistencyBoundarySpanAttributes.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Ecotone\OpenTelemetry; +namespace Ecotone\Tracing; use function array_filter; use function array_map; diff --git a/packages/Ecotone/src/Tracing/EcotoneActiveSpan.php b/packages/Ecotone/src/Tracing/EcotoneActiveSpan.php index bfabdc093f..249bd97f58 100644 --- a/packages/Ecotone/src/Tracing/EcotoneActiveSpan.php +++ b/packages/Ecotone/src/Tracing/EcotoneActiveSpan.php @@ -23,6 +23,7 @@ final class EcotoneActiveSpan implements ActiveSpan private array $events = []; private bool $ended = false; private bool $unitOfWorkFailed = false; + private bool $childrenStarted = false; /** * @param array $attributes @@ -39,11 +40,31 @@ public function __construct( ) { } + public function name(): string + { + return $this->name; + } + public function attributes(): array { return $this->attributes; } + public function setAttribute(string $key, int|float|string|bool $value): void + { + $this->attributes[$key] = $value; + } + + public function childStarted(): void + { + $this->childrenStarted = true; + } + + public function hasStartedChildren(): bool + { + return $this->childrenStarted; + } + public function context(): SpanContext { return $this->context; diff --git a/packages/Ecotone/src/Tracing/EcotoneTracer.php b/packages/Ecotone/src/Tracing/EcotoneTracer.php index 3580d2e71e..40a7b31a2d 100644 --- a/packages/Ecotone/src/Tracing/EcotoneTracer.php +++ b/packages/Ecotone/src/Tracing/EcotoneTracer.php @@ -30,6 +30,8 @@ public function __construct( public function startSpan(string $name, SpanKind $kind, array $attributes = [], ?SpanContext $remoteParent = null): ActiveSpan { + $this->currentSpan()?->childStarted(); + return $this->start($name, $kind, $attributes, $remoteParent ?? $this->currentSpan()?->context()); } diff --git a/packages/Ecotone/src/Tracing/TracedDecisionModelBatchLoader.php b/packages/Ecotone/src/Tracing/TracedDecisionModelBatchLoader.php new file mode 100644 index 0000000000..0e5dd242da --- /dev/null +++ b/packages/Ecotone/src/Tracing/TracedDecisionModelBatchLoader.php @@ -0,0 +1,56 @@ + + */ + public function load(Message $message): array + { + [$class, $method] = explode('::', $this->handlerName, 2); + $span = $this->tracer->startSpan('Decision Models: ' . explode("\0", $class, 2)[0] . '::' . $method, SpanKind::Internal, $this->messageSpanAttributes->from($message)); + + try { + $loadedState = $this->batchLoader->load($message); + } catch (Throwable $failure) { + $span->fail($failure); + + throw $failure; + } + + $state = $loadedState[DecisionModelLoadedState::HEADER_NAME]; + $capturedTagVersions = $state->capturedTagVersions(); + $span->setAttribute(DynamicConsistencyBoundarySpanAttributes::MODELS, DynamicConsistencyBoundarySpanAttributes::classList($state->loadedModelClassNames())); + $span->setAttribute(DynamicConsistencyBoundarySpanAttributes::TAGS, DynamicConsistencyBoundarySpanAttributes::userTags($capturedTagVersions)); + $span->setAttribute(DynamicConsistencyBoundarySpanAttributes::CAPTURED_VERSIONS, DynamicConsistencyBoundarySpanAttributes::capturedVersions($capturedTagVersions)); + $span->setAttribute(DynamicConsistencyBoundarySpanAttributes::AGGREGATES, DynamicConsistencyBoundarySpanAttributes::aggregates($capturedTagVersions)); + $span->setAttribute(DynamicConsistencyBoundarySpanAttributes::EVENTS_FOLDED, $state->foldedEventCount()); + $span->end(); + + return $loadedState; + } +} diff --git a/packages/OpenTelemetry/src/TracedEventStore.php b/packages/Ecotone/src/Tracing/TracedEventStore.php similarity index 53% rename from packages/OpenTelemetry/src/TracedEventStore.php rename to packages/Ecotone/src/Tracing/TracedEventStore.php index 2a786bd1c1..79259e20e5 100644 --- a/packages/OpenTelemetry/src/TracedEventStore.php +++ b/packages/Ecotone/src/Tracing/TracedEventStore.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Ecotone\OpenTelemetry; +namespace Ecotone\Tracing; use function count; @@ -12,11 +12,8 @@ use Ecotone\Api\EventSourcing\EventStore; use Ecotone\Api\EventSourcing\LoadedEvents; use Ecotone\Api\EventSourcing\MetadataMatcher; -use OpenTelemetry\API\Trace\SpanInterface; -use OpenTelemetry\API\Trace\SpanKind; -use OpenTelemetry\API\Trace\StatusCode; -use OpenTelemetry\API\Trace\TracerProviderInterface; -use OpenTelemetry\Context\ScopeInterface; +use Ecotone\Api\Tracing\SpanAttribute; +use Ecotone\Api\Tracing\SpanKind; use Throwable; /** @@ -26,7 +23,7 @@ final class TracedEventStore implements EventStore { public function __construct( private readonly EventStore $eventStore, - private readonly TracerProviderInterface $tracerProvider, + private readonly Tracer $tracer, ) { } @@ -38,25 +35,27 @@ public function appendTo(string $streamName, array $streamEvents, ?AppendConditi return; } - $span = $this->conditionalAppendSpan($streamName, $streamEvents, $appendCondition); - $spanScope = $span->activate(); + $span = $this->tracer->startSpan('Conditional Append: ' . $streamName, SpanKind::Internal, [ + SpanAttribute::MESSAGING_SYSTEM => SpanAttribute::ECOTONE, + DynamicConsistencyBoundarySpanAttributes::TAGS => DynamicConsistencyBoundarySpanAttributes::userTags($appendCondition->expectedTagVersions()), + DynamicConsistencyBoundarySpanAttributes::AGGREGATES => DynamicConsistencyBoundarySpanAttributes::aggregates($appendCondition->expectedTagVersions()), + DynamicConsistencyBoundarySpanAttributes::EVENTS_APPENDED => count($streamEvents), + ]); try { $this->eventStore->appendTo($streamName, $streamEvents, $appendCondition); } catch (DecisionModelConcurrencyException $conflict) { $span->addEvent(DynamicConsistencyBoundarySpanAttributes::CONFLICT_EVENT_NAME, $conflict->conflictFields()); - $span->recordException($conflict); - self::closeSpan($span, $spanScope, StatusCode::STATUS_ERROR, $conflict->getMessage()); + $span->fail($conflict); throw $conflict; - } catch (Throwable $exception) { - $span->recordException($exception); - self::closeSpan($span, $spanScope, StatusCode::STATUS_ERROR, $exception->getMessage()); + } catch (Throwable $failure) { + $span->fail($failure); - throw $exception; + throw $failure; } - self::closeSpan($span, $spanScope, StatusCode::STATUS_OK, null); + $span->end(); } public function delete(string $streamName): void @@ -95,26 +94,4 @@ public function loadByCriteria(EventCriteria $criteria): LoadedEvents { return $this->eventStore->loadByCriteria($criteria); } - - /** - * @param object[]|array[] $streamEvents - */ - private function conditionalAppendSpan(string $streamName, array $streamEvents, AppendCondition $appendCondition): SpanInterface - { - return $this->tracerProvider - ->getTracer(EcotoneSpanBuilder::ECOTONE_TRACER_NAME) - ->spanBuilder('Conditional Append: ' . $streamName) - ->setSpanKind(SpanKind::KIND_INTERNAL) - ->setAttribute(DynamicConsistencyBoundarySpanAttributes::TAGS, DynamicConsistencyBoundarySpanAttributes::userTags($appendCondition->expectedTagVersions())) - ->setAttribute(DynamicConsistencyBoundarySpanAttributes::AGGREGATES, DynamicConsistencyBoundarySpanAttributes::aggregates($appendCondition->expectedTagVersions())) - ->setAttribute(DynamicConsistencyBoundarySpanAttributes::EVENTS_APPENDED, count($streamEvents)) - ->startSpan(); - } - - private static function closeSpan(SpanInterface $span, ScopeInterface $spanScope, string $statusCode, ?string $descriptionStatusCode): void - { - $span->setStatus($statusCode, $descriptionStatusCode); - $spanScope->detach(); - $span->end(); - } } diff --git a/packages/Ecotone/src/Tracing/TracedRouteProcessor.php b/packages/Ecotone/src/Tracing/TracedRouteProcessor.php index 2df01f053c..b15d7fe18a 100644 --- a/packages/Ecotone/src/Tracing/TracedRouteProcessor.php +++ b/packages/Ecotone/src/Tracing/TracedRouteProcessor.php @@ -17,6 +17,8 @@ */ final class TracedRouteProcessor implements MessageProcessor { + public const SPAN_NAME_PREFIX = 'route '; + public function __construct( private string $routeName, private MessageProcessor $routeProcessor, @@ -43,6 +45,9 @@ public function process(Message $message): ?Message throw $failure; } + if (! $span->hasStartedChildren() && ! ($span->attributes()[SpanAttribute::ROUTE_COLLECTED] ?? false)) { + $span->setAttribute(SpanAttribute::ROUTE_UNDELIVERED, true); + } $span->end(); return $result; @@ -52,10 +57,10 @@ private function spanName(): string { $projectionName = ProjectingModule::projectionNameOfTriggerChannel($this->routeName); if ($projectionName !== null) { - return 'route ' . $projectionName; + return self::SPAN_NAME_PREFIX . $projectionName; } - return 'route ' . (BusRoutingMapBuilder::endpointIdOfDestinationChannel($this->routeName) ?? $this->routeName); + return self::SPAN_NAME_PREFIX . (BusRoutingMapBuilder::endpointIdOfDestinationChannel($this->routeName) ?? $this->routeName); } /** diff --git a/packages/Ecotone/src/Tracing/TracingChannelInterceptor.php b/packages/Ecotone/src/Tracing/TracingChannelInterceptor.php index c041ea7751..df221b2705 100644 --- a/packages/Ecotone/src/Tracing/TracingChannelInterceptor.php +++ b/packages/Ecotone/src/Tracing/TracingChannelInterceptor.php @@ -4,7 +4,9 @@ namespace Ecotone\Tracing; +use function array_filter; use function array_pop; +use function array_reverse; use Ecotone\Api\Messaging\BatchMessage; use Ecotone\Api\Messaging\Message; @@ -23,7 +25,7 @@ */ final class TracingChannelInterceptor implements ChannelInterceptor { - /** @var ActiveSpan[] */ + /** @var list> */ private array $openSendSpans = []; public function __construct( @@ -36,30 +38,20 @@ public function __construct( public function preSend(Message $message, MessageChannel $messageChannel): ?Message { - $attributes = [ - ...$this->messageSpanAttributes->from($message), - SpanAttribute::MESSAGING_OPERATION_TYPE => SpanAttribute::OPERATION_SEND, - SpanAttribute::MESSAGING_DESTINATION_NAME => $this->channelName, - ]; - $isFirstChannel = self::deliveryChannelOf($message) === null; - if ($isFirstChannel) { - $attributes = [...$attributes, ...$this->deliveryAttributes($message)]; + $payload = $message->getPayload(); + if ($payload instanceof BatchMessage && $this->tracer->currentSpan() === null) { + return $this->relayBatch($message, $payload); } - $attributes = [...$attributes, ...TracingInterceptor::trackingAttributesOf($this->tracer, $message, false)]; - $span = $this->tracer->startSpan( - 'send ' . $this->channelName, - SpanKind::Producer, - $attributes, - $this->tracer->currentSpan() === null ? TracingInterceptor::remoteParentOf($message) : null, - ); - $this->openSendSpans[] = $span; + + $isFirstChannel = self::deliveryChannelOf($message) === null; + $span = $this->startSendSpan($message, $isFirstChannel ? $this->deliveryAttributes($message) : [], $this->tracer->currentSpan() === null); + $this->openSendSpans[] = [$span]; $carrier = TracingInterceptor::carrierOf($span); $sent = $this->messageBuilder->fromMessage($message)->setMultipleHeaders($carrier); if ($isFirstChannel) { $sent = $sent->setHeader(MessageHeaders::TRACING_DELIVERY, $this->channelName . ':' . $message->getHeaders()->getMessageId()); } - $payload = $message->getPayload(); if ($payload instanceof BatchMessage) { $sent = $sent->setPayload(BatchMessage::fromEntries(array_map( fn (array $entry): array => [...$entry, 'headers' => [...($entry['headers'] ?? []), ...$carrier]], @@ -70,6 +62,63 @@ public function preSend(Message $message, MessageChannel $messageChannel): ?Mess return $sent->build(); } + private function relayBatch(Message $message, BatchMessage $batch): Message + { + $entriesByParent = []; + $firstEntryOfParent = []; + foreach ($batch->entries as $index => $entry) { + $entryMessage = $this->messageBuilder->withPayload('')->setMultipleHeaders($entry['headers'] ?? [])->build(); + $parent = TracingInterceptor::remoteParentOf($entryMessage); + $parentKey = $parent === null ? '' : $parent->traceId . '-' . $parent->spanId; + $entriesByParent[$parentKey][$index] = $entry; + $firstEntryOfParent[$parentKey] ??= $entryMessage; + } + + $spans = []; + $entries = $batch->entries; + foreach ($entriesByParent as $parentKey => $traceEntries) { + $firstEntry = $firstEntryOfParent[$parentKey]; + $firstDeliveries = array_filter($traceEntries, fn (array $entry): bool => ! self::isDeliveredAlready($entry['headers'] ?? [])); + $span = $this->startSendSpan($firstEntry, $firstDeliveries === [] ? [] : [SpanAttribute::CHANNEL_NAME => $this->channelName, SpanAttribute::BATCH_DELIVERIES => self::batchDeliveriesOf($firstDeliveries)], true); + $spans[] = $span; + $carrier = TracingInterceptor::carrierOf($span); + foreach ($traceEntries as $index => $entry) { + $entries[$index] = [...$entry, 'headers' => [...($entry['headers'] ?? []), ...$carrier]]; + } + } + $this->openSendSpans[] = $spans; + + $sent = $this->messageBuilder->fromMessage($message)->setPayload(BatchMessage::fromEntries(array_values($entries))); + if ($spans !== []) { + $sent = $sent->setMultipleHeaders(TracingInterceptor::carrierOf($spans[0])); + } + + return $sent->setHeader(MessageHeaders::TRACING_DELIVERY, $this->channelName . ':' . $message->getHeaders()->getMessageId())->build(); + } + + /** + * @param array $deliveryAttributes + */ + private function startSendSpan(Message $message, array $deliveryAttributes, bool $continuesTheMessageTrace): ActiveSpan + { + $name = 'send ' . $this->channelName; + $attributes = [ + ...$this->messageSpanAttributes->from($message), + SpanAttribute::MESSAGING_OPERATION_TYPE => SpanAttribute::OPERATION_SEND, + SpanAttribute::MESSAGING_DESTINATION_NAME => $this->channelName, + ...$deliveryAttributes, + ...($continuesTheMessageTrace ? TracingInterceptor::trackingAttributesCarriedBy($message, false) : TracingInterceptor::trackingAttributesOf($this->tracer, $message, false)), + ]; + if (! $continuesTheMessageTrace) { + return $this->tracer->startSpan($name, SpanKind::Producer, $attributes); + } + $remoteParent = TracingInterceptor::remoteParentOf($message); + + return $remoteParent === null + ? $this->tracer->startRootSpan($name, SpanKind::Producer, $attributes) + : $this->tracer->startSpan($name, SpanKind::Producer, $attributes, $remoteParent); + } + public static function deliveryChannelOf(Message $message): ?string { $headers = $message->getHeaders(); @@ -89,12 +138,7 @@ private function deliveryAttributes(Message $message): array $attributes = [SpanAttribute::CHANNEL_NAME => $this->channelName]; $payload = $message->getPayload(); if ($payload instanceof BatchMessage) { - $deliveries = []; - foreach ($payload->entries as $entry) { - $headers = $entry['headers'] ?? []; - $deliveries[(string) ($headers[MessageHeaders::MESSAGE_ID] ?? '')] = self::targetEndpointIdOfRoutingSlip((string) ($headers[MessageHeaders::ROUTING_SLIP] ?? '')); - } - $attributes[SpanAttribute::BATCH_DELIVERIES] = json_encode($deliveries, JSON_THROW_ON_ERROR | JSON_FORCE_OBJECT); + $attributes[SpanAttribute::BATCH_DELIVERIES] = self::batchDeliveriesOf($payload->entries); return $attributes; } @@ -106,6 +150,30 @@ private function deliveryAttributes(Message $message): array return $attributes; } + /** + * @param array $headers + */ + private static function isDeliveredAlready(array $headers): bool + { + $delivery = (string) ($headers[MessageHeaders::TRACING_DELIVERY] ?? ''); + + return $delivery !== '' && str_ends_with($delivery, ':' . ($headers[MessageHeaders::MESSAGE_ID] ?? '')); + } + + /** + * @param array}> $entries + */ + private static function batchDeliveriesOf(array $entries): string + { + $deliveries = []; + foreach ($entries as $entry) { + $headers = $entry['headers'] ?? []; + $deliveries[(string) ($headers[MessageHeaders::MESSAGE_ID] ?? '')] = self::targetEndpointIdOfRoutingSlip((string) ($headers[MessageHeaders::ROUTING_SLIP] ?? '')); + } + + return json_encode($deliveries, JSON_THROW_ON_ERROR | JSON_FORCE_OBJECT); + } + private static function targetEndpointIdOf(Message $message): ?string { $headers = $message->getHeaders(); @@ -133,11 +201,12 @@ public function postSend(Message $message, MessageChannel $messageChannel): void public function afterSendCompletion(Message $message, MessageChannel $messageChannel, ?Throwable $exception): bool { - $span = array_pop($this->openSendSpans); - if ($exception !== null) { - $span?->fail($exception); - } else { - $span?->end(); + foreach (array_reverse(array_pop($this->openSendSpans) ?? []) as $span) { + if ($exception !== null) { + $span->fail($exception); + } else { + $span->end(); + } } return false; diff --git a/packages/Ecotone/src/Tracing/TracingInterceptor.php b/packages/Ecotone/src/Tracing/TracingInterceptor.php index b25f8292b7..45e4b07102 100644 --- a/packages/Ecotone/src/Tracing/TracingInterceptor.php +++ b/packages/Ecotone/src/Tracing/TracingInterceptor.php @@ -15,8 +15,12 @@ use Ecotone\Api\Tracing\SpanAttribute; use Ecotone\Api\Tracing\SpanContext; use Ecotone\Api\Tracing\SpanKind; +use Ecotone\Messaging\NullableMessageChannel; use Ecotone\Messaging\Support\InvalidArgumentException; use Ecotone\Messaging\Support\MessageBuilder; +use Ecotone\Modelling\Config\MessageBusChannel; +use Ecotone\Projecting\Config\ProjectingModule; +use Ecotone\Projecting\ProjectionOperationEndpoint; use Ecotone\Projecting\ProjectionTriggerEndpoint; use Throwable; @@ -38,17 +42,26 @@ public function __construct( public function traceCommandBus(MethodInvocation $methodInvocation, Message $message): mixed { - return $this->traceEntry('send command', $methodInvocation, $message, SpanKind::Server, [SpanAttribute::MESSAGING_OPERATION_TYPE => SpanAttribute::OPERATION_SEND]); + return $this->traceEntry('send command', $methodInvocation, $message, SpanKind::Server, [ + SpanAttribute::MESSAGING_OPERATION_TYPE => SpanAttribute::OPERATION_SEND, + SpanAttribute::MESSAGING_DESTINATION_NAME => self::busDestinationOf($message, MessageBusChannel::COMMAND_CHANNEL_NAME_BY_NAME), + ]); } public function traceEventBus(MethodInvocation $methodInvocation, Message $message): mixed { - return $this->traceEntry('send event', $methodInvocation, $message, SpanKind::Server, [SpanAttribute::MESSAGING_OPERATION_TYPE => SpanAttribute::OPERATION_SEND]); + return $this->traceEntry('send event', $methodInvocation, $message, SpanKind::Server, [ + SpanAttribute::MESSAGING_OPERATION_TYPE => SpanAttribute::OPERATION_SEND, + SpanAttribute::MESSAGING_DESTINATION_NAME => self::busDestinationOf($message, MessageBusChannel::EVENT_CHANNEL_NAME_BY_NAME), + ]); } public function traceQueryBus(MethodInvocation $methodInvocation, Message $message): mixed { - return $this->traceEntry('send query', $methodInvocation, $message, SpanKind::Server, [SpanAttribute::MESSAGING_OPERATION_TYPE => SpanAttribute::OPERATION_SEND]); + return $this->traceEntry('send query', $methodInvocation, $message, SpanKind::Server, [ + SpanAttribute::MESSAGING_OPERATION_TYPE => SpanAttribute::OPERATION_SEND, + SpanAttribute::MESSAGING_DESTINATION_NAME => self::busDestinationOf($message, MessageBusChannel::QUERY_CHANNEL_NAME_BY_NAME), + ]); } public function traceCommandHandler(MethodInvocation $methodInvocation, Message $message, CommandHandler $endpoint): mixed @@ -76,7 +89,7 @@ public function traceAsynchronousEndpoint(MethodInvocation $methodInvocation, Me $headers = $message->getHeaders(); $name = match (true) { $headers->containsKey(MessageHeaders::POLLED_CHANNEL_NAME) => 'receive ' . $headers->get(MessageHeaders::POLLED_CHANNEL_NAME), - $headers->containsKey(MessageHeaders::INBOUND_REQUEST_CHANNEL) => 'receive ' . $headers->get(MessageHeaders::INBOUND_REQUEST_CHANNEL), + $headers->containsKey(MessageHeaders::INBOUND_REQUEST_CHANNEL) && ! self::isPollOnlyChannel((string) $headers->get(MessageHeaders::INBOUND_REQUEST_CHANNEL)) => 'receive ' . $headers->get(MessageHeaders::INBOUND_REQUEST_CHANNEL), default => 'poll ' . $headers->get(MessageHeaders::CONSUMER_POLLING_METADATA)->getEndpointId(), }; @@ -97,6 +110,15 @@ public function traceProjection(MethodInvocation $methodInvocation, Message $mes ]); } + public function traceProjectionOperation(MethodInvocation $methodInvocation, Message $message, ProjectionOperationEndpoint $endpoint): mixed + { + return $this->traceEntry($endpoint->operation . ' ' . $endpoint->projectionName, $methodInvocation, $message, SpanKind::Server, [ + SpanAttribute::MESSAGING_OPERATION_TYPE => SpanAttribute::OPERATION_PROCESS, + SpanAttribute::PROJECTION_NAME => $endpoint->projectionName, + SpanAttribute::PROJECTION_OPERATION => $endpoint->operation, + ]); + } + public function traceMessageGateway(MethodInvocation $methodInvocation, Message $message, MessageGateway $gateway): mixed { return $this->traceEntry('send ' . $gateway->requestChannel, $methodInvocation, $message, SpanKind::Server, [ @@ -247,6 +269,22 @@ private function traceUnitOfWorkStep(string $name, MethodInvocation $methodInvoc return $result; } + private static function isPollOnlyChannel(string $inboundRequestChannel): bool + { + return $inboundRequestChannel === NullableMessageChannel::CHANNEL_NAME || ProjectingModule::projectionNameOfTriggerChannel($inboundRequestChannel) !== null; + } + + private static function busDestinationOf(Message $message, string $routingKeyHeader): string + { + $headers = $message->getHeaders(); + if ($headers->containsKey($routingKeyHeader)) { + return (string) $headers->get($routingKeyHeader); + } + $payload = $message->getPayload(); + + return is_object($payload) ? $payload::class : get_debug_type($payload); + } + private static function shortNameOf(string $classAndMethod): string { [$class, $method] = explode('::', $classAndMethod, 2); diff --git a/packages/Ecotone/src/Tracing/TracingMessageCollectionObserver.php b/packages/Ecotone/src/Tracing/TracingMessageCollectionObserver.php new file mode 100644 index 0000000000..23aba90e2c --- /dev/null +++ b/packages/Ecotone/src/Tracing/TracingMessageCollectionObserver.php @@ -0,0 +1,27 @@ +tracer->currentSpan(); + if ($span !== null && str_starts_with($span->name(), TracedRouteProcessor::SPAN_NAME_PREFIX)) { + $span->setAttribute(SpanAttribute::ROUTE_COLLECTED, true); + } + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowChannelTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowChannelTest.php new file mode 100644 index 0000000000..29d1ee9057 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowChannelTest.php @@ -0,0 +1,423 @@ +getMessageFlowGraph(); + + foreach (['start' => 'first', 'first' => 'second', 'second' => 'route'] as $from => $to) { + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::OUTPUT, 'handler:' . $service::class . '::' . $from, 'channel:chain.' . $to)); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::INPUT, 'channel:chain.' . $to, 'handler:' . $service::class . '::' . $to)); + } + foreach (['left', 'right'] as $destination) { + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::ROUTES_TO, 'handler:' . $service::class . '::route', 'channel:chain.' . $destination)); + self::assertTrue($edges[0]->isInferred()); + } + } + + public function test_splitter_takes_its_input_channel_and_feeds_its_output_channel(): void + { + $service = new class () { + #[CommandHandler('batch.import', outputChannelName: 'batch.split')] + public function import(array $items): array + { + return $items; + } + + #[Splitter('batch.split', outputChannelName: 'item.handle')] + public function split(array $items): array + { + return $items; + } + + #[InternalHandler('item.handle')] + public function handle(string $item): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $splitter = 'handler:' . $service::class . '::split'; + self::assertSame(FlowNodeKind::SPLITTER, $graph->node($splitter)->kind); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::INPUT, 'channel:batch.split', $splitter)); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::OUTPUT, $splitter, 'channel:item.handle')); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::INPUT, 'channel:item.handle', 'handler:' . $service::class . '::handle')); + } + + public function test_orchestrator_routes_to_the_steps_its_source_names(): void + { + $service = new class () { + #[CommandHandler('approval.request', outputChannelName: 'approval.workflow')] + public function request(string $request): string + { + return $request; + } + + #[Orchestrator('approval.workflow')] + public function workflow(): array + { + return ['approval.validate', 'approval.notify']; + } + + #[InternalHandler('approval.validate')] + public function validate(string $request): string + { + return $request; + } + + #[InternalHandler('approval.notify')] + public function notify(string $request): string + { + return $request; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $orchestrator = 'handler:' . $service::class . '::workflow'; + self::assertSame(FlowNodeKind::ORCHESTRATOR, $graph->node($orchestrator)->kind); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::INPUT, 'channel:approval.workflow', $orchestrator)); + foreach (['validate', 'notify'] as $step) { + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::ROUTES_TO, $orchestrator, 'channel:approval.' . $step)); + self::assertTrue($edges[0]->isInferred()); + } + } + + public function test_transformer_takes_its_input_channel_and_feeds_its_output_channel(): void + { + $service = new class () { + #[CommandHandler('order.place', outputChannelName: 'order.enrich')] + public function place(string $orderId): string + { + return $orderId; + } + + #[Transformer('order.enrich', outputChannelName: 'order.store')] + public function enrich(string $orderId): string + { + return 'order-' . $orderId; + } + + #[InternalHandler('order.store')] + public function store(string $order): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $transformer = 'handler:' . $service::class . '::enrich'; + self::assertSame(FlowNodeKind::TRANSFORMER, $graph->node($transformer)->kind); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::INPUT, 'channel:order.enrich', $transformer)); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::OUTPUT, $transformer, 'channel:order.store')); + } + + public function test_orchestrator_step_continues_to_the_next_step_its_orchestrator_names(): void + { + $service = new class () { + #[Orchestrator('approval.workflow')] + public function workflow(): array + { + return ['approval.validate', 'approval.review', 'approval.notify']; + } + + #[InternalHandler('approval.validate')] + public function validate(string $request): string + { + return $request; + } + + #[Asynchronous('reviews')] + #[InternalHandler('approval.review', endpointId: 'approval.reviewing')] + public function review(string $request): string + { + return $request; + } + + #[InternalHandler('approval.notify')] + public function notify(string $request): string + { + return $request; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], [SimpleMessageChannelBuilder::createQueueChannel('reviews')], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + foreach (['validate' => 'review', 'review' => 'notify'] as $step => $next) { + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::ROUTES_TO, 'handler:' . $service::class . '::' . $step, 'channel:approval.' . $next)); + self::assertTrue($edges[0]->isInferred()); + } + self::assertCount(0, $graph->edgesFrom('handler:' . $service::class . '::notify')); + } + + public function test_optional_router_with_no_destinations_keeps_its_input_edge(): void + { + $service = new class () { + #[Router('optional.route', isResolutionRequired: false)] + public function route(string $payload): array + { + return []; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $id = 'handler:' . $service::class . '::route'; + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::INPUT, 'channel:optional.route', $id)); + self::assertSame([], $graph->edgesFrom($id)); + } + public function test_async_channel_list_shows_the_effective_channels(): void + { + $service = new class () { + #[Asynchronous(['async.first', 'async.second'])] + #[CommandHandler('async.trigger', endpointId: 'async.handler')] + public function handle(string $payload): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [$service::class], + containerOrAvailableServices: [$service], + extensionObjects: [ + SimpleMessageChannelBuilder::createQueueChannel('async.first'), + SimpleMessageChannelBuilder::createQueueChannel('async.second'), + + ], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + $edges = $graph->edgesOfKind(FlowEdgeKind::ASYNCHRONOUS, to: 'handler:' . $service::class . '::handle'); + self::assertEqualsCanonicalizing(['channel:async.first', 'channel:async.second'], array_column($edges, 'from')); + foreach ($edges as $edge) { + self::assertTrue($graph->node($edge->from)->attributes['pollable']); + } + } + + public function test_async_combined_channel_shows_the_effective_channels(): void + { + $service = new class () { + #[Asynchronous('async.combined')] + #[CommandHandler('async.trigger', endpointId: 'async.handler')] + public function handle(string $payload): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [$service::class], + containerOrAvailableServices: [$service], + extensionObjects: [ + SimpleMessageChannelBuilder::createQueueChannel('async.first'), + SimpleMessageChannelBuilder::createQueueChannel('async.second'), + CombinedMessageChannel::create('async.combined', ['async.first', 'async.second']), + ], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + $edges = $graph->edgesOfKind(FlowEdgeKind::ASYNCHRONOUS, to: 'handler:' . $service::class . '::handle'); + self::assertEqualsCanonicalizing(['channel:async.first', 'channel:async.second'], array_column($edges, 'from')); + foreach ($edges as $edge) { + self::assertTrue($graph->node($edge->from)->attributes['pollable']); + } + } + + public function test_async_method_override_shows_the_effective_channels(): void + { + $service = new #[Asynchronous('async.first')] class () { + #[Asynchronous('async.second')] + #[CommandHandler('async.trigger', endpointId: 'async.handler')] + public function handle(string $payload): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [$service::class], + containerOrAvailableServices: [$service], + extensionObjects: [ + SimpleMessageChannelBuilder::createQueueChannel('async.first'), + SimpleMessageChannelBuilder::createQueueChannel('async.second'), + + ], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + $edges = $graph->edgesOfKind(FlowEdgeKind::ASYNCHRONOUS, to: 'handler:' . $service::class . '::handle'); + self::assertEqualsCanonicalizing(['channel:async.second'], array_column($edges, 'from')); + foreach ($edges as $edge) { + self::assertTrue($graph->node($edge->from)->attributes['pollable']); + } + } + + public function test_async_internal_handler_shows_the_effective_channels(): void + { + $service = new class () { + #[Asynchronous('async.first')] + #[InternalHandler('async.input', endpointId: 'async.handler')] + public function handle(string $payload): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [$service::class], + containerOrAvailableServices: [$service], + extensionObjects: [ + SimpleMessageChannelBuilder::createQueueChannel('async.first'), + SimpleMessageChannelBuilder::createQueueChannel('async.second'), + + ], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + $edges = $graph->edgesOfKind(FlowEdgeKind::ASYNCHRONOUS, to: 'handler:' . $service::class . '::handle'); + self::assertEqualsCanonicalizing(['channel:async.first'], array_column($edges, 'from')); + foreach ($edges as $edge) { + self::assertTrue($graph->node($edge->from)->attributes['pollable']); + } + } + + public function test_scheduled_poller_feeds_an_internal_handler_requiring_a_reply(): void + { + $service = new class () { + #[Scheduled('scheduled.ticks', endpointId: 'scheduled.clock')] + #[Poller(fixedRateInMilliseconds: 1000)] + public function tick(): string + { + return 'tick'; + } + + #[InternalHandler('scheduled.ticks', outputChannelName: 'scheduled.done', requiresReply: true)] + public function process(string $payload): string + { + return $payload; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $scheduled = 'scheduled:' . $service::class . '::tick'; + $handler = 'handler:' . $service::class . '::process'; + self::assertSame('scheduled.clock', $graph->node($scheduled)->attributes['endpointId']); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::SENDS_TO, $scheduled, 'channel:scheduled.ticks')); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::INPUT, 'channel:scheduled.ticks', $handler)); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::OUTPUT, $handler, 'channel:scheduled.done')); + } + + public function test_scheduled_method_publishing_an_event_keeps_its_emission(): void + { + $bus = EcotoneLite::bootstrapFlowTesting(enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getGateway(\Ecotone\Api\Gateway\EventBus::class); + $service = new class ($bus) { + public function __construct(private \Ecotone\Api\Gateway\EventBus $bus) + { + } + + #[Scheduled(endpointId: 'scheduled.publisher')] + public function publish(): void + { + $this->bus->publishWithRouting('scheduled.event', []); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'scheduled:' . $service::class . '::publish', 'message:scheduled.event')); + self::assertSame(['inferred' => true, 'via' => 'publishWithRouting'], $edges[0]->attributes); + } + + public function test_router_reaches_a_configured_queue_without_a_local_handler(): void + { + $service = new class () { + #[Router('external.route')] + public function route(string $payload): array + { + return ['external.delivery']; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [$service::class], + containerOrAvailableServices: [$service], + extensionObjects: [SimpleMessageChannelBuilder::createQueueChannel('external.delivery')], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTES_TO, 'handler:' . $service::class . '::route', 'channel:external.delivery')); + self::assertTrue($graph->node('channel:external.delivery')->attributes['pollable']); + } + + public function test_configured_queue_nothing_connects_to_is_left_out_of_the_graph(): void + { + $service = new class () { + #[CommandHandler('unused.queue.trigger')] + public function handle(): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [$service::class], + containerOrAvailableServices: [$service], + extensionObjects: [SimpleMessageChannelBuilder::createQueueChannel('unused.queue')], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + self::assertFalse($graph->hasNode('channel:unused.queue')); + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowCollaboratorTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowCollaboratorTest.php new file mode 100644 index 0000000000..111ae2a3ec --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowCollaboratorTest.php @@ -0,0 +1,150 @@ +bus->publish(new CollaboratorEvent()); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . CollaboratorEvent::class)); + self::assertSame(['inferred' => true, 'via' => 'publish'], $edges[0]->attributes); + } + + public function test_publication_through_nullable_property_is_shown(): void + { + $service = new class () { + public ?EventBus $bus; + + #[CommandHandler('collaborator.nullable_property')] + public function handle(): void + { + $this->bus?->publish(new CollaboratorEvent()); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . CollaboratorEvent::class)); + self::assertSame(['inferred' => true, 'via' => 'publish'], $edges[0]->attributes); + } + + public function test_publication_through_union_property_is_shown(): void + { + $service = new class () { + public CollaboratorEvent|EventBus $bus; + + #[CommandHandler('collaborator.union_property')] + public function handle(): void + { + $this->bus->publish(new CollaboratorEvent()); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . CollaboratorEvent::class)); + self::assertSame(['inferred' => true, 'via' => 'publish'], $edges[0]->attributes); + } + + public function test_publication_through_extended_bus_property_is_shown(): void + { + $service = new class () { + public ExtendedEventBusForFlow $bus; + + #[CommandHandler('collaborator.extended_bus_property')] + public function handle(): void + { + $this->bus->publish(new CollaboratorEvent()); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . CollaboratorEvent::class)); + self::assertSame(['inferred' => true, 'via' => 'publish'], $edges[0]->attributes); + } + + public function test_publication_through_method_parameter_is_shown(): void + { + $service = new class () { + #[CommandHandler('collaborator.method_parameter')] + public function handle(EventBus $bus): void + { + $bus->publish(new CollaboratorEvent()); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . CollaboratorEvent::class)); + self::assertSame(['inferred' => true, 'via' => 'publish'], $edges[0]->attributes); + } + + public function test_publication_through_parent_property_is_shown(): void + { + $service = new class () extends EventBusOwnerForFlow { + #[CommandHandler('collaborator.parent_property')] + public function handle(): void + { + $this->bus->publish(new CollaboratorEvent()); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . CollaboratorEvent::class)); + self::assertSame(['inferred' => true, 'via' => 'publish'], $edges[0]->attributes); + } + + public function test_publication_through_promoted_property_is_shown(): void + { + $bus = EcotoneLite::bootstrapFlowTesting(enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getGateway(EventBus::class); + $service = new class ($bus) { + public function __construct(private EventBus $bus) + { + } + + #[CommandHandler('collaborator.promoted')] + public function handle(): void + { + $this->bus->publish(new CollaboratorEvent()); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . CollaboratorEvent::class)); + self::assertSame(['inferred' => true, 'via' => 'publish'], $edges[0]->attributes); + } +} + +final class CollaboratorEvent +{ +} + +interface ExtendedEventBusForFlow extends EventBus +{ +} + +abstract class EventBusOwnerForFlow +{ + protected EventBus $bus; +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowConsoleTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowConsoleTest.php new file mode 100644 index 0000000000..87e6168cb2 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowConsoleTest.php @@ -0,0 +1,62 @@ +runConsoleCommand('ecotone:flow:dump'); + + self::assertSame("flowchart LR\n", $ecotone->getInMemoryConsoleWriter()->getWrittenContent()); + } + + public function test_empty_application_json_dump_has_empty_node_and_edge_lists(): void + { + $ecotone = EcotoneLite::bootstrapFlowTesting(enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE); + + $ecotone->runConsoleCommand('ecotone:flow:dump', ['format' => 'json']); + + self::assertSame(['nodes' => [], 'edges' => []], json_decode($ecotone->getInMemoryConsoleWriter()->getWrittenContent(), true, flags: JSON_THROW_ON_ERROR)); + } + + public function test_json_dump_reconstructs_the_application_graph_with_anonymous_names(): void + { + $service = new class () { + #[CommandHandler('console.handle')] + public function handle(): void + { + } + }; + $ecotone = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE); + + $ecotone->runConsoleCommand('ecotone:flow:dump', ['format' => 'json']); + + self::assertSame($ecotone->getMessageFlowGraph()->toArray(), MessageFlowGraph::fromJson($ecotone->getInMemoryConsoleWriter()->getWrittenContent())->toArray()); + } + + public function test_unknown_dump_format_explains_both_supported_formats(): void + { + $ecotone = EcotoneLite::bootstrapFlowTesting(enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('ecotone:flow:dump was asked for format `xml`, which it cannot produce. Pass --format=mermaid for a Mermaid flowchart to paste into a diagram viewer, or --format=json for the graph as nodes and edges.'); + + $ecotone->runConsoleCommand('ecotone:flow:dump', ['format' => 'xml']); + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowDecisionTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowDecisionTest.php new file mode 100644 index 0000000000..430867784a --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowDecisionTest.php @@ -0,0 +1,95 @@ +orderId); + } + + #[CommandHandler] + public function handle(DecisionCommandForFlow $command): array + { + return [new DecisionEventForFlow($command->orderId)]; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [$service::class, DecisionEventForFlow::class], + containerOrAvailableServices: [$service], + extensionObjects: [DynamicConsistencyBoundaryConfiguration::createWithDefaults()], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . DecisionEventForFlow::class)); + self::assertSame(['inferred' => true, 'via' => 'return'], $edges[0]->attributes); + } + + public function test_decision_model_handler_return_is_shown_as_emitted_event(): void + { + $service = new class () { + #[CommandHandler] + public function handle(DecisionCommandForFlow $command, DecisionStateForFlow $state): array + { + return [new DecisionEventForFlow($command->orderId)]; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [$service::class, DecisionStateForFlow::class, DecisionEventForFlow::class], + containerOrAvailableServices: [$service], + extensionObjects: [DynamicConsistencyBoundaryConfiguration::createWithDefaults()], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . DecisionEventForFlow::class)); + self::assertSame(['inferred' => true, 'via' => 'return'], $edges[0]->attributes); + } +} + +final class DecisionCommandForFlow +{ + public function __construct(public string $orderId) + { + } +} + +final class DecisionEventForFlow +{ + public function __construct(#[EventTag('order')] public string $orderId) + { + } +} + +#[DecisionModel] +final class DecisionStateForFlow +{ + #[Apply] + public function apply(DecisionEventForFlow $event): void + { + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowDistributedTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowDistributedTest.php new file mode 100644 index 0000000000..4c95cf97b5 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowDistributedTest.php @@ -0,0 +1,60 @@ +publish(new LocalEventForDistribution()); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $handler = 'handler:' . $service::class . '::receive'; + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::DISTRIBUTES, 'distributed:partner.bus', 'message:remote.receive')); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:remote.receive', $handler)); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS, $handler, 'message:' . LocalEventForDistribution::class)); + } + + public function test_dynamic_distributed_destination_keeps_the_bus_edge_without_guessing_a_message(): void + { + $service = new class () { + #[CommandHandler('remote.send')] + public function send(array $payload, DistributedBus $bus): void + { + $bus->sendCommand($payload['service'], $payload['route'], '{}'); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::SENDS_TO, 'handler:' . $service::class . '::send', 'distributed:' . DistributedBus::class)); + self::assertSame(['inferred' => true, 'via' => 'sendCommand'], $edges[0]->attributes); + self::assertCount(1, $graph->nodesOfKind(FlowNodeKind::COMMAND)); + } +} + +final class LocalEventForDistribution +{ +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowEmissionTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowEmissionTest.php new file mode 100644 index 0000000000..5633c6086d --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowEmissionTest.php @@ -0,0 +1,352 @@ +publish(EmissionEvent::create()); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . EmissionEvent::class)); + self::assertSame(['inferred' => true, 'via' => 'publish'], $edges[0]->attributes); + } + + public function test_aggregate_static_helper_event_is_shown_as_recorded_on_the_fresh_instance(): void + { + $aggregate = new #[Aggregate] class () { + use WithEvents; + + #[Identifier] + private string $id = 'order'; + + #[CommandHandler('factory.local')] + public static function create(): static + { + $order = new static(); + $order->recordThat(self::placed()); + + return $order; + } + + private static function placed(): EmissionEvent + { + return new EmissionEvent(); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$aggregate::class], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $aggregate::class . '::create', 'message:' . EmissionEvent::class)); + self::assertSame(['inferred' => true, 'via' => 'recordThat'], $edges[0]->attributes); + } + public function test_event_assigned_before_publication_is_shown_as_published(): void + { + $service = new class () { + #[CommandHandler('event.variable')] + public function handle(array $payload, EventBus $bus): void + { + $event = new EmissionEvent(); + $bus->publish($event); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . EmissionEvent::class)); + self::assertSame('publish', $edges[0]->attributes['via']); + } + + public function test_event_returned_from_private_helper_is_shown_as_published(): void + { + $service = new class () { + #[CommandHandler('event.helper')] + public function handle(array $payload, EventBus $bus): void + { + $bus->publish($this->event()); + } + + private function event(): EmissionEvent + { + return new EmissionEvent(); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . EmissionEvent::class)); + self::assertSame('publish', $edges[0]->attributes['via']); + } + + public function test_this_record_that_is_shown_as_recorded_without_subscribers(): void + { + $aggregate = new #[Aggregate] class () { + use WithEvents; + + #[Identifier] + private string $id = 'order'; + + #[CommandHandler('record.instance')] + public function record(): void + { + $this->recordThat(new EmissionEvent()); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$aggregate::class], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $aggregate::class . '::record', 'message:' . EmissionEvent::class)); + self::assertSame(['inferred' => true, 'via' => 'recordThat'], $edges[0]->attributes); + } + + public function test_shared_recursive_helper_yields_one_emission_for_each_handler(): void + { + $service = new class () { + #[CommandHandler('helper.first')] + public function first(array $payload, EventBus $bus): void + { + $bus->publish($this->event(2)); + } + + #[CommandHandler('helper.second')] + public function second(array $payload, EventBus $bus): void + { + $bus->publish($this->event(3)); + } + + private function event(int $depth): EmissionEvent + { + return $depth > 0 ? $this->event($depth - 1) : new EmissionEvent(); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + foreach (['first', 'second'] as $method) { + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::' . $method)); + self::assertSame('message:' . EmissionEvent::class, $edges[0]->to); + self::assertSame(['inferred' => true, 'via' => 'publish'], $edges[0]->attributes); + } + } + + public function test_class_constant_routing_key_is_shown_as_published(): void + { + $service = new class () { + #[CommandHandler('route.constant')] + public function handle(EventBus $bus): void + { + $bus->publishWithRouting(EmissionEvent::NAME, []); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:emission.created')); + } + + public function test_backed_enum_routing_key_is_shown_as_published(): void + { + $service = new class () { + #[CommandHandler('route.enum')] + public function handle(EventBus $bus): void + { + $bus->publishWithRouting(EmissionRoute::CREATED->value, []); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:emission.enum')); + } + + public function test_named_event_is_one_node_reached_by_its_class_and_its_name(): void + { + $service = new class () { + #[CommandHandler('named.publish')] + public function publish(EventBus $bus): void + { + $bus->publish(new NamedEmissionEvent()); + $bus->publishWithRouting('emission.named', []); + } + + #[EventHandler] + public function typed(NamedEmissionEvent $event): void + { + } + + #[EventHandler('emission.named')] + public function named(array $payload): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $event = 'message:' . NamedEmissionEvent::class; + self::assertFalse($graph->hasNode('message:emission.named')); + self::assertSame('emission.named', $graph->node($event)->attributes['namedAs']); + foreach (['typed', 'named'] as $method) { + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, $event, 'handler:' . $service::class . '::' . $method)); + } + self::assertSame([$event], array_values(array_unique(array_column($graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::publish'), 'to')))); + } + + public function test_named_event_handled_only_by_its_name_is_reached_from_its_publisher(): void + { + $service = new class () { + #[CommandHandler('named.only')] + public function publish(EventBus $bus): void + { + $bus->publish(new NamedEmissionEvent()); + } + + #[EventHandler('emission.named')] + public function named(array $payload): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class, NamedEmissionEvent::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $event = 'message:' . NamedEmissionEvent::class; + self::assertFalse($graph->hasNode('message:emission.named')); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::publish', $event)); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, $event, 'handler:' . $service::class . '::named')); + } + + public function test_command_sent_by_class_has_the_correct_message_kind(): void + { + $service = new class () { + #[CommandHandler('outbound.trigger')] + public function handle(CommandBus $bus): void + { + $bus->send(new EmissionCommand()); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertSame(FlowNodeKind::COMMAND, $graph->node('message:' . EmissionCommand::class)->kind); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . EmissionCommand::class)); + } + + public function test_command_sent_by_routing_key_has_the_correct_message_kind(): void + { + $service = new class () { + #[CommandHandler('outbound.trigger')] + public function handle(CommandBus $bus): void + { + $bus->sendWithRouting('outbound.command', []); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertSame(FlowNodeKind::COMMAND, $graph->node('message:outbound.command')->kind); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:outbound.command')); + } + + public function test_query_sent_by_class_has_the_correct_message_kind(): void + { + $service = new class () { + #[CommandHandler('outbound.trigger')] + public function handle(QueryBus $bus): void + { + $bus->send(new EmissionQuery()); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertSame(FlowNodeKind::QUERY, $graph->node('message:' . EmissionQuery::class)->kind); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . EmissionQuery::class)); + } + + public function test_query_sent_by_routing_key_has_the_correct_message_kind(): void + { + $service = new class () { + #[CommandHandler('outbound.trigger')] + public function handle(QueryBus $bus): void + { + $bus->sendWithRouting('outbound.query', []); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertSame(FlowNodeKind::QUERY, $graph->node('message:outbound.query')->kind); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:outbound.query')); + } + + public function test_single_quoted_routing_keys_keep_literal_backslash_sequences(): void + { + $service = new class () { + #[CommandHandler('literal.single')] + public function handle(EventBus $bus): void + { + $bus->publishWithRouting('literal\new\thing', []); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:literal\new\thing')); + } + + public function test_double_quoted_routing_keys_decode_php_escapes_and_unicode(): void + { + $service = new class () { + #[CommandHandler('literal.double')] + public function handle(EventBus $bus): void + { + $bus->publishWithRouting("literal\path\n\e\u{1F680}", []); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', "message:literal\path\n\e\u{1F680}")); + } + +} + +final class EmissionEvent +{ + public const NAME = 'emission.created'; + + public static function create(): static + { + return new static(); + } +} + +enum EmissionRoute: string +{ + case CREATED = 'emission.enum'; +} + +#[NamedEvent('emission.named')] +final class NamedEmissionEvent +{ +} + +final class EmissionCommand +{ +} + +final class EmissionQuery +{ +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowGatewayTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowGatewayTest.php new file mode 100644 index 0000000000..83dd0329b4 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowGatewayTest.php @@ -0,0 +1,157 @@ +gateway->ship('order', 'ship'); + $this->gateway->cancel('order'); + } + + #[InternalHandler('gateway.ship')] + public function ship(string $payload): void + { + } + + #[InternalHandler('gateway.cancel')] + public function cancel(string $payload): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class, BusinessGatewayForFlow::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $gateway = 'gateway:' . BusinessGatewayForFlow::class . '::ship'; + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::CALLS, 'handler:' . $service::class . '::handle', $gateway)); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::SENDS_TO, $gateway, 'channel:gateway.ship')); + self::assertCount(2, $graph->nodesOfKind(FlowNodeKind::GATEWAY)); + self::assertCount(2, $graph->edgesOfKind(FlowEdgeKind::CALLS, 'handler:' . $service::class . '::handle')); + } + + public function test_gateway_parent_property_preserves_the_call_and_request_channel(): void + { + $service = new class () extends GatewayOwnerForFlow { + #[CommandHandler('gateway.trigger')] + public function handle(): void + { + $this->gateway->ship('order', 'ship'); + } + + #[InternalHandler('gateway.ship')] + public function ship(string $payload): void + { + } + + #[InternalHandler('gateway.cancel')] + public function cancel(string $payload): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class, BusinessGatewayForFlow::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $gateway = 'gateway:' . BusinessGatewayForFlow::class . '::ship'; + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::CALLS, 'handler:' . $service::class . '::handle', $gateway)); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::SENDS_TO, $gateway, 'channel:gateway.ship')); + self::assertCount(2, $graph->nodesOfKind(FlowNodeKind::GATEWAY)); + } + + public function test_gateway_extended_interface_preserves_the_call_and_request_channel(): void + { + $service = new class () { + public ExtendedBusinessGatewayForFlow $gateway; + + #[CommandHandler('gateway.trigger')] + public function handle(): void + { + $this->gateway->cancel('order'); + } + + #[InternalHandler('gateway.ship')] + public function ship(string $payload): void + { + } + + #[InternalHandler('gateway.cancel')] + public function cancel(string $payload): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class, ExtendedBusinessGatewayForFlow::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $gateway = 'gateway:' . ExtendedBusinessGatewayForFlow::class . '::cancel'; + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::CALLS, 'handler:' . $service::class . '::handle', $gateway)); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::SENDS_TO, $gateway, 'channel:gateway.cancel')); + self::assertCount(2, $graph->nodesOfKind(FlowNodeKind::GATEWAY)); + } + + public function test_gateway_routing_header_preserves_the_call_and_request_channel(): void + { + $service = new class () { + public BusinessGatewayForFlow $gateway; + + #[CommandHandler('gateway.trigger')] + public function handle(): void + { + $this->gateway->ship('order', 'shipping.express'); + } + + #[InternalHandler('gateway.ship')] + public function ship(string $payload): void + { + } + + #[InternalHandler('gateway.cancel')] + public function cancel(string $payload): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class, BusinessGatewayForFlow::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $gateway = 'gateway:' . BusinessGatewayForFlow::class . '::ship'; + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::CALLS, 'handler:' . $service::class . '::handle', $gateway)); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::SENDS_TO, $gateway, 'channel:gateway.ship')); + self::assertCount(2, $graph->nodesOfKind(FlowNodeKind::GATEWAY)); + } + +} + +interface BusinessGatewayForFlow +{ + #[MessageGateway('gateway.ship')] + public function ship(string $payload, #[Header('routingKey')] string $routingKey): void; + + #[MessageGateway('gateway.cancel')] + public function cancel(string $payload): void; +} + +interface ExtendedBusinessGatewayForFlow extends BusinessGatewayForFlow +{ +} + +abstract class GatewayOwnerForFlow +{ + protected BusinessGatewayForFlow $gateway; +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowGraphChallengeTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowGraphChallengeTest.php new file mode 100644 index 0000000000..301562e59a --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowGraphChallengeTest.php @@ -0,0 +1,121 @@ +getMessageFlowGraph(); + + self::assertSame(['nodes' => [], 'edges' => []], $graph->toArray()); + } + + public function test_application_graph_excludes_framework_handlers_and_bus_gateways(): void + { + $service = new class () { + #[CommandHandler('application.handle')] + public function handle(EventBus $bus): void + { + $bus->publishWithRouting('application.event', []); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->nodesOfKind(FlowNodeKind::COMMAND_HANDLER)); + self::assertSame([], $graph->nodesOfKind(FlowNodeKind::INTERNAL_HANDLER, FlowNodeKind::GATEWAY)); + foreach ($graph->nodes() as $node) { + self::assertStringStartsNotWith('Ecotone\\', $node->attributes['class'] ?? $node->attributes['interface'] ?? ''); + } + } + public function test_repeated_publication_collapses_duplicate_inferred_edges(): void + { + $service = new class () { + #[CommandHandler('duplicates.trigger')] + public function handle(EventBus $bus): void + { + $bus->publishWithRouting('duplicates.event', []); + $bus->publishWithRouting('duplicates.event', []); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS)); + } + + public function test_duplicate_edges_stay_collapsed_after_public_copy_and_reconstruction(): void + { + $service = new class () { + #[CommandHandler('duplicates.copy')] + public function handle(): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + $edge = $graph->edges()[0]; + + self::assertCount(1, $graph->withEdge($edge)->edges()); + $data = $graph->toArray(); + $data['edges'][] = $edge->toArray(); + self::assertCount(1, MessageFlowGraph::fromArray($data)->edges()); + } + + public function test_generated_endpoint_ids_do_not_change_the_graph_between_bootstraps(): void + { + $service = new class () { + #[CommandHandler('stable.trigger')] + public function handle(EventBus $bus): void + { + $bus->publishWithRouting('stable.event', []); + } + }; + $first = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + $second = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertSame($first->toArray(), $second->toArray()); + } + + public function test_anonymous_class_nul_bytes_survive_array_and_json_round_trips(): void + { + $service = new class () { + #[CommandHandler('roundtrip.trigger')] + public function handle(): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertStringContainsString('\u0000', $graph->toJson()); + self::assertSame($graph->toArray(), MessageFlowGraph::fromJson($graph->toJson())->toArray()); + self::assertSame($graph->toArray(), MessageFlowGraph::fromArray($graph->toArray())->toArray()); + } + + public function test_mermaid_labels_escape_quotes_and_pipes_while_preserving_backslashes(): void + { + $service = new class () { + #[CommandHandler('quote"pipe|back\slash')] + public function handle(): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertStringContainsString('quote#quot;pipe#124;back\slash', $graph->toMermaid()); + } + +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowGraphConformanceTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowGraphConformanceTest.php new file mode 100644 index 0000000000..db127c4339 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowGraphConformanceTest.php @@ -0,0 +1,46 @@ + 'The steps an orchestrator gateway runs are the routing argument its caller passes at runtime, so no static graph can name them.', + ]; + + #[DataProviderExternal(FlowScenarioCatalogue::class, 'dataProvider')] + public function test_every_recorded_step_of_a_catalogued_flow_is_a_path_in_the_message_flow_graph(FlowScenario $scenario): void + { + if (isset(self::DECIDED_ONLY_AT_RUNTIME[$scenario->name])) { + self::markTestSkipped(self::DECIDED_ONLY_AT_RUNTIME[$scenario->name]); + } + $recorder = new InMemorySpanRecorder(); + $ecotone = EcotoneLite::bootstrapFlowTesting( + $scenario->classesToResolve, + [...$scenario->services, InMemorySpanRecorder::class => $recorder], + [...$scenario->extensionObjects, TracingConfiguration::createWithDefaults()->withSpanRecorder(InMemorySpanRecorder::class)], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + ); + foreach (array_keys($scenario->stages) as $index) { + self::assertNull($scenario->runStage($index, $ecotone)); + } + self::assertNull(MessageFlowGraphVerifier::mismatchIn($ecotone->getMessageFlowGraph(), $recorder->recordedSpans())); + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowGraphTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowGraphTest.php new file mode 100644 index 0000000000..03c41eee90 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowGraphTest.php @@ -0,0 +1,702 @@ +orderId; + } + + #[InternalHandler(inputChannelName: 'order.placed.pipeline', endpointId: 'order.notify')] + public function notify(string $orderId): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $handlerId = 'handler:' . $service::class . '::place'; + $internalId = 'handler:' . $service::class . '::notify'; + + self::assertSame(FlowNodeKind::COMMAND, $graph->node('message:' . PlaceOrderForFlowGraph::class)->kind); + self::assertSame(FlowNodeKind::COMMAND_HANDLER, $graph->node($handlerId)->kind); + self::assertSame('order.place', $graph->node($handlerId)->attributes['endpointId']); + self::assertSame(FlowNodeKind::INTERNAL_HANDLER, $graph->node($internalId)->kind); + self::assertSame(FlowNodeKind::CHANNEL, $graph->node('channel:order.placed.pipeline')->kind); + self::assertEdges([ + [FlowEdgeKind::ROUTED_TO, 'message:' . PlaceOrderForFlowGraph::class, $handlerId], + [FlowEdgeKind::OUTPUT, $handlerId, 'channel:order.placed.pipeline'], + [FlowEdgeKind::INPUT, 'channel:order.placed.pipeline', $internalId], + ], $graph); + } + + public function test_command_and_query_routed_by_routing_key(): void + { + $service = new class () { + #[CommandHandler('order.cancel', endpointId: 'order.cancel.handler')] + public function cancel(array $payload): void + { + } + + #[QueryHandler('order.status', endpointId: 'order.status.handler')] + public function status(array $payload): string + { + return 'placed'; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertSame(FlowNodeKind::COMMAND, $graph->node('message:order.cancel')->kind); + self::assertSame(FlowNodeKind::QUERY, $graph->node('message:order.status')->kind); + self::assertSame(FlowNodeKind::QUERY_HANDLER, $graph->node('handler:' . $service::class . '::status')->kind); + self::assertEdges([ + [FlowEdgeKind::ROUTED_TO, 'message:order.cancel', 'handler:' . $service::class . '::cancel'], + [FlowEdgeKind::ROUTED_TO, 'message:order.status', 'handler:' . $service::class . '::status'], + ], $graph); + } + + public function test_asynchronous_handler_executes_through_pollable_channel(): void + { + $service = new class () { + #[Asynchronous('orders')] + #[EventHandler(endpointId: 'order.placed.mail')] + public function mail(OrderPlacedForFlowGraph $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting( + [$service::class], + [$service], + [SimpleMessageChannelBuilder::createQueueChannel('orders')], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + $handlerId = 'handler:' . $service::class . '::mail'; + self::assertSame(FlowNodeKind::EVENT, $graph->node('message:' . OrderPlacedForFlowGraph::class)->kind); + self::assertTrue($graph->node('channel:orders')->attributes['pollable']); + self::assertEdges([ + [FlowEdgeKind::ROUTED_TO, 'message:' . OrderPlacedForFlowGraph::class, $handlerId], + [FlowEdgeKind::ASYNCHRONOUS, 'channel:orders', $handlerId], + ], $graph); + } + + public function test_class_level_asynchronous_applies_to_every_handler_of_the_class_except_queries(): void + { + $service = new #[Asynchronous('orders')] class () { + #[CommandHandler(endpointId: 'order.place')] + public function place(PlaceOrderForFlowGraph $command): void + { + } + + #[QueryHandler(endpointId: 'order.find')] + public function find(FindOrderForFlowGraph $query): string + { + return 'placed'; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting( + [$service::class], + [$service], + [SimpleMessageChannelBuilder::createQueueChannel('orders')], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + self::assertEdges([[FlowEdgeKind::ASYNCHRONOUS, 'channel:orders', 'handler:' . $service::class . '::place']], $graph); + self::assertSame([], $graph->edgesOfKind(FlowEdgeKind::ASYNCHRONOUS, to: 'handler:' . $service::class . '::find')); + } + + public function test_event_sourced_aggregate_applies_declared_events_and_handlers_belong_to_the_aggregate(): void + { + $aggregate = new #[EventSourcingAggregate] class () { + use WithAggregateVersioning; + + #[Identifier] + private string $orderId = ''; + + #[CommandHandler] + public static function place(PlaceOrderForFlowGraph $command): array + { + return [new OrderPlacedForFlowGraph($command->orderId)]; + } + + #[Apply] + public function whenPlaced(OrderPlacedForFlowGraph $event): void + { + $this->orderId = $event->orderId; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$aggregate::class], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $aggregateId = 'aggregate:' . $aggregate::class; + $handlerId = 'handler:' . $aggregate::class . '::place'; + self::assertSame(FlowNodeKind::AGGREGATE, $graph->node($aggregateId)->kind); + self::assertTrue($graph->node($aggregateId)->attributes['eventSourced']); + self::assertSame($aggregateId, $graph->node($handlerId)->attributes['owner']); + self::assertEdges([ + [FlowEdgeKind::APPLIES, $aggregateId, 'message:' . OrderPlacedForFlowGraph::class], + [FlowEdgeKind::EMITS, $handlerId, 'message:' . OrderPlacedForFlowGraph::class], + ], $graph); + self::assertSame('return', $graph->edgesOfKind(FlowEdgeKind::EMITS, from: $handlerId)[0]->attributes['via']); + } + + public function test_state_stored_aggregate_recording_events_is_inferred_from_its_source(): void + { + $aggregate = new #[Aggregate] class () { + use WithEvents; + + #[Identifier] + private string $orderId = ''; + + #[CommandHandler] + public static function place(PlaceOrderForFlowGraph $command): static + { + $order = new static(); + $order->orderId = $command->orderId; + $order->recordThat(new OrderPlacedForFlowGraph($command->orderId)); + + return $order; + } + }; + $subscriber = new class () { + #[EventHandler(endpointId: 'order.placed.audit')] + public function audit(OrderPlacedForFlowGraph $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$aggregate::class, $subscriber::class], [$subscriber], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $handlerId = 'handler:' . $aggregate::class . '::place'; + self::assertFalse($graph->node('aggregate:' . $aggregate::class)->attributes['eventSourced']); + self::assertEdges([ + [FlowEdgeKind::EMITS, $handlerId, 'message:' . OrderPlacedForFlowGraph::class], + [FlowEdgeKind::ROUTED_TO, 'message:' . OrderPlacedForFlowGraph::class, 'handler:' . $subscriber::class . '::audit'], + ], $graph); + $emits = $graph->edgesOfKind(FlowEdgeKind::EMITS, from: $handlerId)[0]; + self::assertTrue($emits->isInferred()); + self::assertSame('recordThat', $emits->attributes['via']); + } + + public function test_service_publishing_through_event_bus_parameter_and_sending_by_routing_key_through_injected_command_bus(): void + { + $service = new class () { + public function __construct(private ?CommandBus $commandBus = null) + { + } + + #[CommandHandler(endpointId: 'order.place')] + public function place(PlaceOrderForFlowGraph $command, EventBus $eventBus): void + { + $eventBus->publish(new OrderPlacedForFlowGraph($command->orderId)); + $this->commandBus->sendWithRouting('order.ship', ['orderId' => $command->orderId]); + } + + #[CommandHandler('order.ship', endpointId: 'order.ship.handler')] + public function ship(array $payload): void + { + } + + #[EventHandler(endpointId: 'order.placed.mail')] + public function mail(OrderPlacedForFlowGraph $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $placeId = 'handler:' . $service::class . '::place'; + self::assertEdges([ + [FlowEdgeKind::EMITS, $placeId, 'message:' . OrderPlacedForFlowGraph::class], + [FlowEdgeKind::EMITS, $placeId, 'message:order.ship'], + [FlowEdgeKind::ROUTED_TO, 'message:order.ship', 'handler:' . $service::class . '::ship'], + ], $graph); + $viaByTarget = []; + foreach ($graph->edgesOfKind(FlowEdgeKind::EMITS, from: $placeId) as $edge) { + $viaByTarget[$edge->to] = $edge->attributes['via']; + } + self::assertSame(['message:' . OrderPlacedForFlowGraph::class => 'publish', 'message:order.ship' => 'sendWithRouting'], $viaByTarget); + } + + public function test_event_published_by_a_handler_without_any_subscriber_appears_as_unhandled_event(): void + { + $service = new class () { + #[CommandHandler(endpointId: 'order.place')] + public function place(PlaceOrderForFlowGraph $command, EventBus $eventBus): void + { + $eventBus->publish(new OrderPlacedForFlowGraph($command->orderId)); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $event = $graph->node('message:' . OrderPlacedForFlowGraph::class); + self::assertSame(FlowNodeKind::EVENT, $event->kind); + self::assertFalse($event->attributes['handled']); + } + + public function test_business_interface_sends_to_its_request_channel_and_a_handler_calling_it_is_linked(): void + { + $service = new class () { + public function __construct(private ?OrderShippingForFlowGraph $shipping = null) + { + } + + #[CommandHandler(endpointId: 'order.place')] + public function place(PlaceOrderForFlowGraph $command): void + { + $this->shipping->ship($command->orderId); + } + + #[InternalHandler(inputChannelName: 'shipping.ship', endpointId: 'shipping.handler')] + public function doShip(string $orderId): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class, OrderShippingForFlowGraph::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $gatewayId = 'gateway:' . OrderShippingForFlowGraph::class . '::ship'; + self::assertSame(FlowNodeKind::GATEWAY, $graph->node($gatewayId)->kind); + self::assertEdges([ + [FlowEdgeKind::CALLS, 'handler:' . $service::class . '::place', $gatewayId], + [FlowEdgeKind::SENDS_TO, $gatewayId, 'channel:shipping.ship'], + [FlowEdgeKind::INPUT, 'channel:shipping.ship', 'handler:' . $service::class . '::doShip'], + ], $graph); + } + + public function test_router_is_linked_to_the_channels_its_source_names(): void + { + $service = new class () { + #[Router(inputChannelName: 'order.route', endpointId: 'order.router')] + public function route(string $orderId): string + { + return $orderId === 'vip' ? 'order.vip' : 'order.regular'; + } + + #[InternalHandler(inputChannelName: 'order.vip', endpointId: 'order.vip.handler')] + public function vip(string $orderId): void + { + } + + #[InternalHandler(inputChannelName: 'order.regular', endpointId: 'order.regular.handler')] + public function regular(string $orderId): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $routerId = 'handler:' . $service::class . '::route'; + self::assertSame(FlowNodeKind::ROUTER, $graph->node($routerId)->kind); + self::assertEdges([ + [FlowEdgeKind::INPUT, 'channel:order.route', $routerId], + [FlowEdgeKind::ROUTES_TO, $routerId, 'channel:order.vip'], + [FlowEdgeKind::ROUTES_TO, $routerId, 'channel:order.regular'], + ], $graph); + } + + public function test_projection_handlers_belong_to_the_projection_node(): void + { + $projection = new #[Projection('orders_list'), FromStream('orders')] class () { + #[EventHandler(endpointId: 'orders_list.placed')] + public function whenPlaced(OrderPlacedForFlowGraph $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$projection::class], [$projection], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertSame(FlowNodeKind::PROJECTION, $graph->node('projection:orders_list')->kind); + self::assertSame('projection:orders_list', $graph->node('handler:' . $projection::class . '::whenPlaced')->attributes['owner']); + self::assertEdges([ + [FlowEdgeKind::ROUTED_TO, 'message:' . OrderPlacedForFlowGraph::class, 'handler:' . $projection::class . '::whenPlaced'], + ], $graph); + } + + public function test_distributed_handler_receives_its_message_from_the_distributed_bus(): void + { + $service = new class () { + #[Distributed] + #[CommandHandler('ticket.register', endpointId: 'ticket.register.handler')] + public function register(array $payload): void + { + } + + #[Distributed] + #[EventHandler('user.registered', endpointId: 'user.registered.handler')] + public function whenUserRegistered(array $payload): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $busId = 'distributed:' . DistributedBus::class; + self::assertSame(FlowNodeKind::DISTRIBUTED_BUS, $graph->node($busId)->kind); + self::assertTrue($graph->node('message:ticket.register')->attributes['distributed']); + self::assertTrue($graph->node('handler:' . $service::class . '::register')->attributes['distributed']); + self::assertEdges([ + [FlowEdgeKind::DISTRIBUTES, $busId, 'message:ticket.register'], + [FlowEdgeKind::DISTRIBUTES, $busId, 'message:user.registered'], + [FlowEdgeKind::ROUTED_TO, 'message:ticket.register', 'handler:' . $service::class . '::register'], + ], $graph); + } + + public function test_handler_sending_through_the_distributed_bus_is_linked_to_the_remote_messages_and_the_bus(): void + { + $service = new class () { + public function __construct(private ?DistributedBus $distributedBus = null) + { + } + + #[CommandHandler(endpointId: 'order.place')] + public function place(PlaceOrderForFlowGraph $command): void + { + $this->distributedBus->convertAndSendCommand('billing', 'invoice.create', ['orderId' => $command->orderId]); + $this->distributedBus->publishEvent('order.placed', '{}'); + $this->distributedBus->sendMessage('billing', 'billing.notifications', '{}'); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $busId = 'distributed:' . DistributedBus::class; + $placeId = 'handler:' . $service::class . '::place'; + self::assertSame(FlowNodeKind::COMMAND, $graph->node('message:invoice.create')->kind); + self::assertSame('billing', $graph->node('message:invoice.create')->attributes['targetService']); + self::assertFalse($graph->node('message:invoice.create')->attributes['handled']); + self::assertSame(FlowNodeKind::EVENT, $graph->node('message:order.placed')->kind); + self::assertEdges([ + [FlowEdgeKind::EMITS, $placeId, 'message:invoice.create'], + [FlowEdgeKind::SENDS_TO, 'message:invoice.create', $busId], + [FlowEdgeKind::EMITS, $placeId, 'message:order.placed'], + [FlowEdgeKind::SENDS_TO, 'message:order.placed', $busId], + [FlowEdgeKind::SENDS_TO, $placeId, $busId], + ], $graph); + $direct = $graph->edgesOfKind(FlowEdgeKind::SENDS_TO, from: $placeId, to: $busId)[0]; + self::assertSame(['inferred' => true, 'via' => 'sendMessage', 'targetService' => 'billing', 'targetChannel' => 'billing.notifications'], $direct->attributes); + self::assertSame('convertAndSendCommand', $graph->edgesOfKind(FlowEdgeKind::EMITS, from: $placeId, to: 'message:invoice.create')[0]->attributes['via']); + } + + public function test_service_map_wires_the_distributed_bus_to_its_command_and_event_channels(): void + { + $graph = EcotoneLite::bootstrapFlowTesting( + extensionObjects: [ + SimpleMessageChannelBuilder::createQueueChannel('billing_channel'), + SimpleMessageChannelBuilder::createQueueChannel('events_channel'), + DistributedServiceMap::initialize() + ->withCommandMapping('billing', 'billing_channel') + ->withEventMapping('events_channel', ['order.*']), + ServiceConfiguration::createWithDefaults()->withServiceName('orders'), + ], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + $busId = 'distributed:' . DistributedBus::class; + self::assertSame(['billing' => 'billing_channel'], $graph->node($busId)->attributes['commandMapping']); + self::assertSame(['events_channel' => ['order.*']], $graph->node($busId)->attributes['eventSubscriptions']); + self::assertEdges([ + [FlowEdgeKind::SENDS_TO, $busId, 'channel:billing_channel'], + [FlowEdgeKind::SENDS_TO, $busId, 'channel:events_channel'], + ], $graph); + self::assertSame('billing', $graph->edgesOfKind(FlowEdgeKind::SENDS_TO, from: $busId, to: 'channel:billing_channel')[0]->attributes['targetService']); + self::assertSame(['order.*'], $graph->edgesOfKind(FlowEdgeKind::SENDS_TO, from: $busId, to: 'channel:events_channel')[0]->attributes['subscriptionKeys']); + } + + public function test_services_sharing_a_transport_channel_each_keep_their_route_from_the_distributed_bus(): void + { + $graph = EcotoneLite::bootstrapFlowTesting( + extensionObjects: [ + SimpleMessageChannelBuilder::createQueueChannel('shared_channel'), + DistributedServiceMap::initialize() + ->withCommandMapping('billing', 'shared_channel') + ->withCommandMapping('shipping', 'shared_channel'), + ServiceConfiguration::createWithDefaults()->withServiceName('orders'), + ], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + $routes = $graph->edgesOfKind(FlowEdgeKind::SENDS_TO, from: 'distributed:' . DistributedBus::class, to: 'channel:shared_channel'); + self::assertSame(['billing', 'shipping'], array_map(fn ($edge) => $edge->attributes['targetService'], $routes)); + } + + public function test_distributed_bus_publishes_events_only_to_channels_this_service_may_publish_to(): void + { + $graph = EcotoneLite::bootstrapFlowTesting( + extensionObjects: [ + SimpleMessageChannelBuilder::createQueueChannel('excluding_channel'), + SimpleMessageChannelBuilder::createQueueChannel('including_others_channel'), + SimpleMessageChannelBuilder::createQueueChannel('including_this_channel'), + DistributedServiceMap::initialize() + ->withEventMapping('excluding_channel', ['order.*'], excludePublishingServices: ['orders']) + ->withEventMapping('including_others_channel', ['order.*'], includePublishingServices: ['billing']) + ->withEventMapping('including_this_channel', ['order.*'], includePublishingServices: ['orders']), + ServiceConfiguration::createWithDefaults()->withServiceName('orders'), + ], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + self::assertSame( + ['channel:including_this_channel'], + array_map(fn ($edge) => $edge->to, $graph->edgesOfKind(FlowEdgeKind::SENDS_TO, from: 'distributed:' . DistributedBus::class)), + ); + } + + public function test_channel_mapped_to_this_service_delivers_into_its_distributed_bus(): void + { + $graph = EcotoneLite::bootstrapFlowTesting( + extensionObjects: [ + SimpleMessageChannelBuilder::createQueueChannel('orders_channel'), + SimpleMessageChannelBuilder::createQueueChannel('billing_channel'), + DistributedServiceMap::initialize() + ->withCommandMapping('orders', 'orders_channel') + ->withCommandMapping('billing', 'billing_channel'), + ServiceConfiguration::createWithDefaults()->withServiceName('orders'), + ], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + $busId = 'distributed:' . DistributedBus::class; + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::INPUT, 'channel:orders_channel', $busId)); + self::assertCount(0, $graph->edgesOfKind(FlowEdgeKind::INPUT, 'channel:billing_channel', $busId)); + } + + public function test_event_channel_this_service_subscribes_to_delivers_into_its_distributed_bus(): void + { + $billing = new class () { + #[Distributed] + #[EventHandler('user.billing_changed')] + public function update(string $details): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting( + [$billing::class], + [$billing], + [ + SimpleMessageChannelBuilder::createQueueChannel('billing_events'), + SimpleMessageChannelBuilder::createQueueChannel('audit_events'), + DistributedServiceMap::initialize() + ->withEventMapping(channelName: 'billing_events', subscriptionKeys: ['user.*']) + ->withEventMapping(channelName: 'audit_events', subscriptionKeys: ['order.*']), + ServiceConfiguration::createWithDefaults()->withServiceName('billing'), + ], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + $busId = 'distributed:' . DistributedBus::class; + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::INPUT, 'channel:billing_events', $busId)); + self::assertCount(0, $graph->edgesOfKind(FlowEdgeKind::INPUT, 'channel:audit_events', $busId)); + } + + public function test_graph_serializes_to_array_and_back_without_loss(): void + { + $service = new class () { + #[CommandHandler(endpointId: 'order.place', outputChannelName: 'order.placed.pipeline')] + public function place(PlaceOrderForFlowGraph $command, EventBus $eventBus): string + { + $eventBus->publish(new OrderPlacedForFlowGraph($command->orderId)); + + return $command->orderId; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertEquals($graph, MessageFlowGraph::fromArray($graph->toArray())); + self::assertSame($graph->toArray(), json_decode($graph->toJson(), true)); + } + + public function test_mermaid_rendering_groups_aggregate_handlers_and_marks_inferred_edges_as_dotted(): void + { + $aggregate = new #[Aggregate] class () { + use WithEvents; + + #[Identifier] + private string $orderId = ''; + + #[CommandHandler] + public static function place(PlaceOrderForFlowGraph $command): static + { + $order = new static(); + $order->orderId = $command->orderId; + $order->recordThat(new OrderPlacedForFlowGraph($command->orderId)); + + return $order; + } + }; + $subscriber = new class () { + #[Asynchronous('orders')] + #[EventHandler(endpointId: 'order.placed.mail')] + public function mail(OrderPlacedForFlowGraph $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting( + [$aggregate::class, $subscriber::class], + [$subscriber], + [SimpleMessageChannelBuilder::createQueueChannel('orders')], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + $mermaid = $graph->toMermaid(); + + self::assertStringContainsString('flowchart LR', $mermaid); + self::assertStringContainsString('subgraph', $mermaid); + self::assertStringContainsString('Aggregate', $mermaid); + self::assertStringContainsString('PlaceOrderForFlowGraph', $mermaid); + self::assertStringContainsString('-.->|"emits (recordThat)"|', $mermaid); + self::assertStringContainsString('==>|"async"|', $mermaid); + self::assertStringContainsString('[("Channel
orders")]', $mermaid); + } + + public function test_mermaid_draws_an_asynchronous_handler_behind_its_channel_in_the_order_the_message_travels(): void + { + $notifications = new class () { + #[Asynchronous('notifications')] + #[EventHandler('order.placed', endpointId: 'notifications.orderPlaced')] + public function whenPlaced(string $orderId): void + { + } + }; + $invoicing = new class () { + #[EventHandler('order.placed')] + public function whenPlaced(string $orderId): void + { + } + }; + $mermaid = EcotoneLite::bootstrapFlowTesting( + [$notifications::class, $invoicing::class], + [$notifications, $invoicing], + [SimpleMessageChannelBuilder::createQueueChannel('notifications')], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph()->toMermaid(); + + $idOf = function (string $nodeLine) use ($mermaid): string { + self::assertSame(1, preg_match('/^\s*(n\d+)' . preg_quote($nodeLine, '/') . '$/m', $mermaid), "Missing node {$nodeLine} in:\n{$mermaid}"); + preg_match('/^\s*(n\d+)' . preg_quote($nodeLine, '/') . '$/m', $mermaid, $match); + + return $match[1]; + }; + $event = $idOf('(["order.placed"])'); + $channel = $idOf('[("Channel
notifications")]'); + $asynchronous = $idOf('["Event Handler
' . $notifications::class . '::whenPlaced"]'); + $synchronous = $idOf('["Event Handler
' . $invoicing::class . '::whenPlaced"]'); + + self::assertStringContainsString("{$event} --> {$channel}\n", $mermaid); + self::assertStringContainsString("{$channel} ==>|\"async\"| {$asynchronous}\n", $mermaid); + self::assertStringContainsString("{$event} --> {$synchronous}\n", $mermaid); + self::assertStringNotContainsString("{$event} --> {$asynchronous}\n", $mermaid); + } + + public function test_dump_console_command_writes_mermaid_by_default_and_json_on_request(): void + { + $service = new class () { + #[CommandHandler(endpointId: 'order.place')] + public function place(PlaceOrderForFlowGraph $command): void + { + } + }; + $ecotone = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE); + + $ecotone->runConsoleCommand('ecotone:flow:dump'); + self::assertStringContainsString('flowchart LR', $ecotone->getInMemoryConsoleWriter()->getWrittenContent()); + + $ecotone->runConsoleCommand('ecotone:flow:dump', ['format' => 'json']); + self::assertStringContainsString('"kind": "commandHandler"', $ecotone->getInMemoryConsoleWriter()->getWrittenContent()); + self::assertStringContainsString('"endpointId": "order.place"', $ecotone->getInMemoryConsoleWriter()->getWrittenContent()); + } + + public function test_dump_console_command_refuses_unknown_format_naming_the_available_ones(): void + { + $ecotone = EcotoneLite::bootstrapFlowTesting(enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('ecotone:flow:dump was asked for format `yaml`, which it cannot produce. Pass --format=mermaid for a Mermaid flowchart to paste into a diagram viewer, or --format=json for the graph as nodes and edges.'); + + $ecotone->runConsoleCommand('ecotone:flow:dump', ['format' => 'yaml']); + } + + /** + * @param list $expected + */ + private static function assertEdges(array $expected, MessageFlowGraph $graph): void + { + $actual = array_map(fn (FlowEdge $edge) => [$edge->kind, $edge->from, $edge->to], $graph->edges()); + foreach ($expected as $edge) { + self::assertContains($edge, $actual, 'Missing edge ' . $edge[0]->value . ' from ' . $edge[1] . ' to ' . $edge[2] . ' in ' . $graph->toMermaid()); + } + } +} + +final class PlaceOrderForFlowGraph +{ + public function __construct(public readonly string $orderId) + { + } +} + +final class FindOrderForFlowGraph +{ + public function __construct(public readonly string $orderId) + { + } +} + +final class OrderPlacedForFlowGraph +{ + public function __construct(public readonly string $orderId) + { + } +} + +interface OrderShippingForFlowGraph +{ + #[MessageGateway(requestChannel: 'shipping.ship')] + public function ship(string $orderId): void; +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowInterceptorTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowInterceptorTest.php new file mode 100644 index 0000000000..1c3f355edd --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowInterceptorTest.php @@ -0,0 +1,91 @@ +sendWithRouting('audit.record', $orderId); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$orders::class, $auditing::class], [$orders, $auditing], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $interceptor = 'interceptor:' . $auditing::class . '::audit'; + self::assertSame(FlowNodeKind::INTERCEPTOR, $graph->node($interceptor)->kind); + self::assertSame('before', $graph->node($interceptor)->attributes['position']); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::INTERCEPTED_BY, 'handler:' . $orders::class . '::place', $interceptor)); + self::assertCount(0, $graph->edgesOfKind(FlowEdgeKind::INTERCEPTED_BY, 'handler:' . $orders::class . '::record', $interceptor)); + self::assertCount(1, $emits = $graph->edgesOfKind(FlowEdgeKind::EMITS, $interceptor, 'message:audit.record')); + self::assertSame(['inferred' => true, 'via' => 'sendWithRouting'], $emits[0]->attributes); + } + + public function test_interceptor_cutting_a_handler_attribute_reaches_every_handler_of_that_kind_only(): void + { + $service = new class () { + #[CommandHandler('order.place')] + public function place(string $orderId): void + { + } + + #[EventHandler('order.placed')] + public function whenPlaced(string $orderId): void + { + } + }; + $timing = new class () { + #[Around(pointcut: CommandHandler::class)] + public function time(MethodInvocation $invocation): mixed + { + return $invocation->proceed(); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class, $timing::class], [$service, $timing], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $interceptor = 'interceptor:' . $timing::class . '::time'; + self::assertSame('around', $graph->node($interceptor)->attributes['position']); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::INTERCEPTED_BY, 'handler:' . $service::class . '::place', $interceptor)); + self::assertCount(0, $graph->edgesOfKind(FlowEdgeKind::INTERCEPTED_BY, 'handler:' . $service::class . '::whenPlaced', $interceptor)); + } +} + +#[Attribute(Attribute::TARGET_METHOD)] +final class AuditedForFlow +{ +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowLicenceTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowLicenceTest.php new file mode 100644 index 0000000000..a0f6595153 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowLicenceTest.php @@ -0,0 +1,73 @@ +expectException(LicensingException::class); + $this->expectExceptionMessage(self::REFUSAL); + + $ecotone->getMessageFlowGraph(); + } + + public function test_flow_dump_command_is_not_registered_without_enterprise_licence(): void + { + $ecotone = EcotoneLite::bootstrapFlowTesting(enabledModules: []); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('Console command `ecotone:flow:dump` is not registered.'); + + $ecotone->runConsoleCommand('ecotone:flow:dump'); + } + + public function test_application_without_enterprise_licence_still_bootstraps_and_handles_messages(): void + { + $service = new class () { + public bool $handled = false; + + #[CommandHandler('licence.check')] + public function handle(): void + { + $this->handled = true; + } + }; + + EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [])->sendCommandWithRouting('licence.check'); + + self::assertTrue($service->handled); + } + + public function test_message_flow_graph_with_enterprise_licence_shows_the_application(): void + { + $service = new class () { + #[CommandHandler('licence.graph')] + public function handle(): void + { + } + }; + + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:licence.graph', 'handler:' . $service::class . '::handle')); + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowProjectionTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowProjectionTest.php new file mode 100644 index 0000000000..27bdf3d28c --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowProjectionTest.php @@ -0,0 +1,134 @@ +getMessageFlowGraph(); + + self::assertSame('polling.endpoint', $graph->node('projection:polling.view')->attributes['endpointId'] ?? null); + foreach (['created', 'changed'] as $method) { + self::assertSame('projection:polling.view', $graph->node('handler:' . $projection::class . '::' . $method)->attributes['owner']); + } + } + + public function test_streaming_projection_is_linked_to_its_source_channel(): void + { + $projection = new #[Projection('streaming.view'), Streaming('projection.stream')] class () { + #[EventHandler('order.created')] + public function created(array $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [$projection::class], + containerOrAvailableServices: [$projection], + extensionObjects: [SimpleMessageChannelBuilder::createStreamingChannel('projection.stream')], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::INPUT, 'channel:projection.stream', 'projection:streaming.view')); + self::assertSame('projection:streaming.view', $graph->node('handler:' . $projection::class . '::created')->attributes['owner']); + } + + public function test_projection_backfilled_and_rebuilt_asynchronously_sends_through_and_is_fed_by_its_channels(): void + { + $projection = new #[Projection('replayed.view'), FromStream('orders'), ProjectionBackfill(asyncChannelName: 'backfill'), ProjectionRebuild(asyncChannelName: 'rebuild')] class () { + #[EventHandler('order.created')] + public function created(array $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [$projection::class], + containerOrAvailableServices: [$projection], + extensionObjects: [SimpleMessageChannelBuilder::createQueueChannel('backfill'), SimpleMessageChannelBuilder::createQueueChannel('rebuild')], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + )->getMessageFlowGraph(); + + foreach (['backfill', 'rebuild'] as $channel) { + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ASYNCHRONOUS, 'projection:replayed.view', 'channel:' . $channel)); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::INPUT, 'channel:' . $channel, 'projection:replayed.view')); + } + } + + public function test_projection_from_aggregate_stream_keeps_event_handler_ownership(): void + { + $projection = new #[Projection('aggregate.view'), FromAggregateStream(ProjectionAggregateForFlow::class)] class () { + #[EventHandler] + public function created(ProjectionEventForFlow $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$projection::class, ProjectionAggregateForFlow::class], [$projection], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertTrue($graph->hasNode('aggregate:' . ProjectionAggregateForFlow::class)); + self::assertSame('projection:aggregate.view', $graph->node('handler:' . $projection::class . '::created')->attributes['owner']); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:' . ProjectionEventForFlow::class, 'handler:' . $projection::class . '::created')); + } +} + +#[EventSourcingAggregate] +final class ProjectionAggregateForFlow +{ + use WithAggregateVersioning; + + #[Identifier] + private string $id = 'order'; + + #[CommandHandler('projection.create')] + public static function create(): array + { + return [new ProjectionEventForFlow()]; + } + + #[Apply] + public function apply(ProjectionEventForFlow $event): void + { + $this->id = 'order'; + } +} + +final class ProjectionEventForFlow +{ +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowRoutingTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowRoutingTest.php new file mode 100644 index 0000000000..0a81b76281 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowRoutingTest.php @@ -0,0 +1,211 @@ +publish(new RoutingEvent()); + } + + #[EventHandler] + public function subscribe(RoutingEventContract $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:' . RoutingEvent::class, 'handler:' . $service::class . '::subscribe')); + self::assertTrue($graph->node('message:' . RoutingEvent::class)->attributes['handled']); + } + + public function test_emitted_event_reaches_parent_subscriber(): void + { + $service = new class () { + #[CommandHandler('routing.publish')] + public function publish(EventBus $bus): void + { + $bus->publish(new RoutingEvent()); + } + + #[EventHandler] + public function subscribe(RoutingParentEvent $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:' . RoutingEvent::class, 'handler:' . $service::class . '::subscribe')); + self::assertTrue($graph->node('message:' . RoutingEvent::class)->attributes['handled']); + } + + public function test_emitted_event_reaches_object_subscriber(): void + { + $service = new class () { + #[CommandHandler('routing.publish')] + public function publish(EventBus $bus): void + { + $bus->publish(new RoutingEvent()); + } + + #[EventHandler] + public function subscribe(object $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:' . RoutingEvent::class, 'handler:' . $service::class . '::subscribe')); + self::assertTrue($graph->node('message:' . RoutingEvent::class)->attributes['handled']); + } + + public function test_emitted_event_reaches_union_subscriber(): void + { + $service = new class () { + #[CommandHandler('routing.publish')] + public function publish(EventBus $bus): void + { + $bus->publish(new RoutingEvent()); + } + + #[EventHandler] + public function subscribe(RoutingEvent|OtherRoutingEvent $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:' . RoutingEvent::class, 'handler:' . $service::class . '::subscribe')); + self::assertTrue($graph->node('message:' . RoutingEvent::class)->attributes['handled']); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:' . OtherRoutingEvent::class, 'handler:' . $service::class . '::subscribe')); + } + + public function test_named_event_reaches_array_catch_all_subscriber(): void + { + $service = new class () { + #[CommandHandler('routing.publish')] + public function publish(EventBus $bus): void + { + $bus->publishWithRouting('routing.created', []); + } + + #[EventHandler('*')] + public function subscribe(array $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:routing.created', 'handler:' . $service::class . '::subscribe')); + self::assertTrue($graph->node('message:routing.created')->attributes['handled']); + } + public function test_one_event_reaches_every_registered_handler(): void + { + $service = new class () { + #[EventHandler] + public function first(RoutingEvent $event): void + { + } + + #[EventHandler] + public function second(RoutingEvent $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(2, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:' . RoutingEvent::class)); + } + + public function test_repeated_event_attributes_preserve_all_routing_keys(): void + { + $service = new class () { + #[EventHandler('event.first', endpointId: 'first')] + #[EventHandler('event.second', endpointId: 'second')] + public function handle(array $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $id = 'handler:' . $service::class . '::handle'; + self::assertEqualsCanonicalizing(['event.first', 'event.second'], $graph->node($id)->attributes['routingKeys']); + self::assertCount(2, $graph->edgesTo($id)); + } + + public function test_command_and_event_attributes_show_both_handler_roles(): void + { + $service = new class () { + #[CommandHandler('dual.command', endpointId: 'dual.command.handler')] + #[EventHandler('dual.event', endpointId: 'dual.event.handler')] + public function handle(array $payload): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $commands = $graph->nodesOfKind(\Ecotone\Api\Messaging\Flow\FlowNodeKind::COMMAND_HANDLER)); + self::assertCount(1, $events = $graph->nodesOfKind(\Ecotone\Api\Messaging\Flow\FlowNodeKind::EVENT_HANDLER)); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:dual.command', $commands[0]->id)); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:dual.event', $events[0]->id)); + } + + public function test_glob_subscription_matches_dotted_keys_without_treating_dots_as_wildcards(): void + { + $service = new class () { + #[CommandHandler('glob.publish')] + public function publish(EventBus $bus): void + { + $bus->publishWithRouting('orders.created.v2', []); + $bus->publishWithRouting('ordersXcreated', []); + } + + #[EventHandler('orders.*')] + public function subscribe(array $event): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $id = 'handler:' . $service::class . '::subscribe'; + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:orders.created.v2', $id)); + self::assertSame([], $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:ordersXcreated', $id)); + self::assertTrue($graph->hasNode('message:orders.*')); + } + +} + +interface RoutingEventContract +{ +} + +abstract class RoutingParentEvent +{ +} + +final class RoutingEvent extends RoutingParentEvent implements RoutingEventContract +{ +} + +final class OtherRoutingEvent +{ +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowSourceNamesTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowSourceNamesTest.php new file mode 100644 index 0000000000..40ce768ab9 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowSourceNamesTest.php @@ -0,0 +1,247 @@ +graphFromSource(<<<'PHP_SOURCE' + publish(new FirstEvent()); + $bus->publish(new OtherSourceNamesEvent()); + } + }; + PHP_SOURCE); + + self::assertEqualsCanonicalizing(['message:' . SourceNamesEvent::class, 'message:' . OtherSourceNamesEvent::class], array_column($graph->edgesOfKind(FlowEdgeKind::EMITS), 'to')); + } + + public function test_namespace_relative_new_resolves_the_event_in_its_namespace(): void + { + $graph = $this->graphFromSource(<<<'PHP_SOURCE' + publish(new namespace\SourceNamesEvent()); + } + }; + PHP_SOURCE); + + self::assertSame(['message:' . SourceNamesEvent::class], array_column($graph->edgesOfKind(FlowEdgeKind::EMITS), 'to')); + } + + public function test_fully_qualified_new_ignores_the_current_namespace(): void + { + $graph = $this->graphFromSource(<<<'PHP_SOURCE' + publish(new \Test\Ecotone\Messaging\Unit\Config\Flow\SourceNamesEvent()); + } + }; + PHP_SOURCE); + + self::assertSame(['message:' . SourceNamesEvent::class], array_column($graph->edgesOfKind(FlowEdgeKind::EMITS), 'to')); + } + + public function test_new_static_resolves_to_the_registered_handler_class(): void + { + $graph = EcotoneLite::bootstrapFlowTesting([SourceNamesEvent::class], [new SourceNamesEvent()], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertSame(['message:' . SourceNamesEvent::class], array_column($graph->edgesOfKind(FlowEdgeKind::EMITS), 'to')); + } + + public function test_runtime_class_expression_does_not_invent_an_event_class(): void + { + $service = new class () { + #[CommandHandler('names.dynamic')] + public function handle(array $payload, EventBus $bus): void + { + $bus->publish(new ($payload['class'])()); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertSame([], $graph->edgesOfKind(FlowEdgeKind::EMITS)); + } + + public function test_new_anonymous_event_does_not_invent_a_named_event_class(): void + { + $service = new class () { + #[CommandHandler('names.anonymous')] + public function handle(EventBus $bus): void + { + $bus->publish(new class () { + }); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertSame([], $graph->edgesOfKind(FlowEdgeKind::EMITS)); + } + + public function test_file_without_namespace_preserves_imported_event_names(): void + { + $graph = $this->graphFromSource(<<<'PHP_SOURCE' + publish(new SourceNamesEvent()); + } + }; + PHP_SOURCE); + + self::assertSame(['message:' . SourceNamesEvent::class], array_column($graph->edgesOfKind(FlowEdgeKind::EMITS), 'to')); + } + + public function test_evaluated_handler_without_readable_source_keeps_declared_routes(): void + { + $service = eval(<<<'PHP_SOURCE' + return new class () { + #[\Ecotone\Api\Attribute\CommandHandler('names.evaluated')] + public function handle(\Ecotone\Api\Gateway\EventBus $bus): void + { + $bus->publish(new \Test\Ecotone\Messaging\Unit\Config\Flow\SourceNamesEvent()); + } + }; + PHP_SOURCE); + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:names.evaluated')); + self::assertSame([], $graph->edgesOfKind(FlowEdgeKind::EMITS)); + } + + public function test_deleted_included_source_keeps_declared_routes(): void + { + $file = tempnam(sys_get_temp_dir(), 'flow-missing-'); + file_put_contents($file, <<<'PHP_SOURCE' + publish(new \Test\Ecotone\Messaging\Unit\Config\Flow\SourceNamesEvent()); + } + }; + PHP_SOURCE); + try { + $service = require $file; + } finally { + unlink($file); + } + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:names.deleted')); + self::assertSame([], $graph->edgesOfKind(FlowEdgeKind::EMITS)); + } + + public function test_bracketed_namespaces_keep_their_own_import_aliases(): void + { + $graph = $this->graphFromSource(<<<'PHP_SOURCE' + publish(new PublishedEvent()); + } + }; + } + namespace FlowChallenge\Second { + use Test\Ecotone\Messaging\Unit\Config\Flow\OtherSourceNamesEvent as PublishedEvent; + } + PHP_SOURCE); + + self::assertSame(['message:' . SourceNamesEvent::class], array_column($graph->edgesOfKind(FlowEdgeKind::EMITS), 'to')); + } + + public function test_semicolon_namespaces_keep_their_own_import_aliases(): void + { + $graph = $this->graphFromSource(<<<'PHP_SOURCE' + publish(new PublishedEvent()); + } + }; + namespace FlowChallenge\Second; + use Test\Ecotone\Messaging\Unit\Config\Flow\OtherSourceNamesEvent as PublishedEvent; + PHP_SOURCE); + + self::assertSame(['message:' . SourceNamesEvent::class], array_column($graph->edgesOfKind(FlowEdgeKind::EMITS), 'to')); + } + + private function graphFromSource(string $source): MessageFlowGraph + { + $file = tempnam(sys_get_temp_dir(), 'flow-source-'); + file_put_contents($file, $source); + try { + $service = require $file; + + return EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + } finally { + unlink($file); + } + } +} + +final class SourceNamesEvent +{ + #[CommandHandler('names.static')] + public static function handle(EventBus $bus): void + { + $bus->publish(new static()); + } +} + +final class OtherSourceNamesEvent +{ +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowSourcedTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowSourcedTest.php new file mode 100644 index 0000000000..84a03babb9 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowSourcedTest.php @@ -0,0 +1,137 @@ +id = 'order'; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$aggregate::class], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $aggregate::class . '::create', 'message:' . SourcedEvent::class)); + self::assertSame(['inferred' => true, 'via' => 'return'], $edges[0]->attributes); + } + + public function test_event_sourced_event_array_shows_its_emitted_event(): void + { + $aggregate = new #[EventSourcingAggregate] class () { + use WithAggregateVersioning; + + #[Identifier] + private string $id = 'order'; + + #[CommandHandler('sourced.create')] + public static function create(): array + { + return [new SourcedEvent()]; + } + + #[Apply] + public function apply(SourcedEvent $event): void + { + $this->id = 'order'; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$aggregate::class], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $aggregate::class . '::create', 'message:' . SourcedEvent::class)); + self::assertSame(['inferred' => true, 'via' => 'return'], $edges[0]->attributes); + } + + public function test_event_sourced_event_generator_shows_its_emitted_event(): void + { + $aggregate = new #[EventSourcingAggregate] class () { + use WithAggregateVersioning; + + #[Identifier] + private string $id = 'order'; + + #[CommandHandler('sourced.create')] + public static function create(): Generator + { + yield new SourcedEvent(); + } + + #[Apply] + public function apply(SourcedEvent $event): void + { + $this->id = 'order'; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$aggregate::class], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $aggregate::class . '::create', 'message:' . SourcedEvent::class)); + self::assertSame(['inferred' => true, 'via' => 'return'], $edges[0]->attributes); + } + + public function test_event_sourced_internal_recorder_shows_its_emitted_event(): void + { + $aggregate = new #[EventSourcingAggregate(withInternalEventRecorder: true)] class () { + use WithAggregateVersioning; + use WithEvents; + + #[Identifier] + private string $id = 'order'; + + #[CommandHandler('sourced.create')] + public static function create(): static + { + $aggregate = new static(); + $aggregate->recordThat(new SourcedEvent()); + + return $aggregate; + } + + #[Apply] + public function apply(SourcedEvent $event): void + { + $this->id = 'order'; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$aggregate::class], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $aggregate::class . '::create', 'message:' . SourcedEvent::class)); + self::assertSame(['inferred' => true, 'via' => 'recordThat'], $edges[0]->attributes); + } + +} + +final class SourcedEvent +{ +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowStructureTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowStructureTest.php new file mode 100644 index 0000000000..c71780f310 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowStructureTest.php @@ -0,0 +1,110 @@ +sendWithRouting('saga.next', []); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$saga::class], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $id = 'handler:' . $saga::class . '::handle'; + self::assertSame(FlowNodeKind::SAGA, $graph->node('saga:' . $saga::class)->kind); + self::assertSame('saga:' . $saga::class, $graph->node($id)->attributes['owner']); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS, $id, 'message:saga.next')); + } + + public function test_saga_event_handler_shows_its_owner_and_emitted_command(): void + { + $saga = new #[Saga] class () { + #[Identifier] + private string $id = 'order'; + + #[EventHandler('saga.event')] + public function handle(CommandBus $bus): void + { + $bus->sendWithRouting('saga.next', []); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$saga::class], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $id = 'handler:' . $saga::class . '::handle'; + self::assertSame('saga:' . $saga::class, $graph->node($id)->attributes['owner']); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:saga.event', $id)); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS, $id, 'message:saga.next')); + } + public function test_aggregate_query_is_owned_by_the_aggregate(): void + { + $aggregate = new #[Aggregate] class () { + #[Identifier] + private string $id = 'order'; + + #[QueryHandler('aggregate.status')] + public function status(): string + { + return $this->id; + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$aggregate::class], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + $id = 'handler:' . $aggregate::class . '::status'; + self::assertSame(FlowNodeKind::QUERY_HANDLER, $graph->node($id)->kind); + self::assertSame('aggregate:' . $aggregate::class, $graph->node($id)->attributes['owner']); + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:aggregate.status', $id)); + } + + public function test_aggregate_factory_and_action_both_receive_the_same_command(): void + { + $aggregate = new #[Aggregate] class () { + #[Identifier] + private string $id = 'order'; + + #[CommandHandler('aggregate.upsert')] + public static function create(): static + { + return new static(); + } + + #[CommandHandler('aggregate.upsert')] + public function update(): void + { + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$aggregate::class], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(2, $graph->edgesOfKind(FlowEdgeKind::ROUTED_TO, 'message:aggregate.upsert')); + foreach (['create', 'update'] as $method) { + self::assertSame('aggregate:' . $aggregate::class, $graph->node('handler:' . $aggregate::class . '::' . $method)->attributes['owner']); + } + } + +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowSyntaxTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowSyntaxTest.php new file mode 100644 index 0000000000..1882689746 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Flow/MessageFlowSyntaxTest.php @@ -0,0 +1,144 @@ +getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . SyntaxEventForFlow::class)); + } + + public function test_inherited_handler_keeps_its_emitted_event(): void + { + $service = new class () extends HandlerParentForFlow { + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle', 'message:' . SyntaxEventForFlow::class)); + } + + public function test_anonymous_classes_with_identical_method_names_keep_separate_emissions(): void + { + $first = new class () { + #[CommandHandler('syntax.first')] + public function handle(EventBus $bus): void + { + $bus->publish(new SyntaxEventForFlow()); + } + }; + $second = new class () { + #[CommandHandler('syntax.second')] + public function handle(EventBus $bus): void + { + $bus->publish(new OtherSyntaxEventForFlow()); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$first::class, $second::class], [$first, $second], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertSame(['message:' . SyntaxEventForFlow::class], array_column($graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $first::class . '::handle'), 'to')); + self::assertSame(['message:' . OtherSyntaxEventForFlow::class], array_column($graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $second::class . '::handle'), 'to')); + } + + public function test_closure_use_preserves_imports_and_captured_event_publication(): void + { + $service = new class () { + #[CommandHandler('syntax.closure')] + public function handle(EventBus $bus): void + { + $event = new SyntaxEventForFlow(); + $publish = function () use ($event, $bus): void { + $bus->publish($event); + }; + $publish(); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle')); + self::assertSame('message:' . SyntaxEventForFlow::class, $edges[0]->to); + self::assertSame('publish', $edges[0]->attributes['via']); + } + + public function test_nested_parentheses_and_code_like_strings_keep_the_outer_publication(): void + { + $service = new class () { + #[CommandHandler('syntax.parentheses')] + public function handle(EventBus $bus): void + { + $bus->publish(new SyntaxEventForFlow(str_replace('(', ')', 'new -> ('))); + } + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertCount(1, $edges = $graph->edgesOfKind(FlowEdgeKind::EMITS, 'handler:' . $service::class . '::handle')); + self::assertSame('message:' . SyntaxEventForFlow::class, $edges[0]->to); + self::assertSame('publish', $edges[0]->attributes['via']); + } + public function test_inherited_self_and_static_construction_keep_their_distinct_event_classes(): void + { + $service = new class () extends SelfEventForFlow { + }; + $graph = EcotoneLite::bootstrapFlowTesting([$service::class], [$service], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE)->getMessageFlowGraph(); + + self::assertEqualsCanonicalizing(['message:' . SelfEventForFlow::class, 'message:' . $service::class], array_column($graph->edgesOfKind(FlowEdgeKind::EMITS), 'to')); + } + +} + +trait HandlerTraitForFlow +{ + #[CommandHandler('syntax.trait')] + public function handle(EventBus $bus): void + { + $bus->publish(new SyntaxEventForFlow()); + } +} + +abstract class HandlerParentForFlow +{ + #[CommandHandler('syntax.parent')] + public function handle(EventBus $bus): void + { + $bus->publish(new SyntaxEventForFlow()); + } +} + +final class SyntaxEventForFlow +{ + public function __construct(public string $text = '') + { + } +} + +final class OtherSyntaxEventForFlow +{ +} + +class SelfEventForFlow +{ + #[CommandHandler('syntax.self')] + public function handle(EventBus $bus): void + { + $bus->publish(new self()); + $bus->publish(new static()); + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/ImmutableConfigurationCopyTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/ImmutableConfigurationCopyTest.php index 3ff4f012de..a994c33499 100644 --- a/packages/Ecotone/tests/Messaging/Unit/Config/ImmutableConfigurationCopyTest.php +++ b/packages/Ecotone/tests/Messaging/Unit/Config/ImmutableConfigurationCopyTest.php @@ -87,7 +87,7 @@ public function test_distributed_service_map_copy_changes_only_the_named_mapping 'channelsForInvoiceIssued' => $map->getAllSubscriptionChannels('shop', 'invoice.issued'), ]; - self::assertSame(['commandMapping', 'distributedBusAnnotations'], array_keys(get_object_vars($original))); + self::assertSame(['commandMapping', 'eventSubscriptions', 'distributedBusAnnotations'], array_keys(get_object_vars($original))); self::assertCopyChangesOnly( $original, fn (DistributedServiceMap $map) => $map->withCommandMapping('payments', 'payments_channel'), @@ -97,7 +97,13 @@ public function test_distributed_service_map_copy_changes_only_the_named_mapping self::assertCopyChangesOnly( $original, fn (DistributedServiceMap $map) => $map->withEventMapping('invoices_channel', ['invoice.*']), - ['channelsForInvoiceIssued' => ['invoices_channel']], + [ + 'eventSubscriptions' => [ + 'audit_channel' => ['keys' => ['order.*'], 'exclude' => [], 'include' => []], + 'invoices_channel' => ['keys' => ['invoice.*'], 'exclude' => [], 'include' => []], + ], + 'channelsForInvoiceIssued' => ['invoices_channel'], + ], $observe, ); self::assertCopyChangesOnly( diff --git a/packages/Ecotone/tests/Projecting/ProjectingTest.php b/packages/Ecotone/tests/Projecting/ProjectingTest.php index d0b26fcfb2..ebebe01a5b 100644 --- a/packages/Ecotone/tests/Projecting/ProjectingTest.php +++ b/packages/Ecotone/tests/Projecting/ProjectingTest.php @@ -56,6 +56,7 @@ use Ecotone\Projecting\StreamSource; use Ecotone\Projecting\Transaction; use Ecotone\Test\LicenceTesting; +use InvalidArgumentException; use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -750,6 +751,33 @@ public function handle(array $event): void self::assertCount(2, $projection->processedEvents, 'resetProjection() must replay previously processed events from scratch, not merely clear and re-init state'); } + public function test_operating_on_an_unknown_projection_names_the_registered_ones(): void + { + $projection = new #[Projection('known_projection'), FromStream('test_stream')] class { + #[EventHandler('*')] + public function handle(array $event): void + { + } + }; + $ecotone = EcotoneLite::bootstrapFlowTesting([$projection::class], [$projection], enabledModules: [], licenceKey: LicenceTesting::VALID_LICENCE); + + foreach ([ + 'trigger' => fn () => $ecotone->triggerProjection('unknown_projection'), + 'reset' => fn () => $ecotone->resetProjection('unknown_projection'), + 'initialize' => fn () => $ecotone->initializeProjection('unknown_projection'), + 'delete' => fn () => $ecotone->deleteProjection('unknown_projection'), + 'backfill' => fn () => $ecotone->runConsoleCommand('ecotone:projection:backfill', ['name' => 'unknown_projection']), + 'rebuild' => fn () => $ecotone->runConsoleCommand('ecotone:projection:rebuild', ['name' => 'unknown_projection']), + ] as $operation => $operate) { + try { + $operate(); + self::fail("{$operation} of an unknown projection was accepted"); + } catch (InvalidArgumentException $exception) { + self::assertSame("There is no projection with name unknown_projection. Registered projections: [known_projection]. Use the name given in #[Projection] of the projection to {$operation}.", $exception->getMessage()); + } + } + } + #[RequiresPhpExtension('pcntl')] #[RequiresPhpExtension('posix')] public function test_pcntl_signals_handling(): void diff --git a/packages/Ecotone/tests/Tracing/AsynchronousTracingTest.php b/packages/Ecotone/tests/Tracing/AsynchronousTracingTest.php deleted file mode 100644 index b48481abe4..0000000000 --- a/packages/Ecotone/tests/Tracing/AsynchronousTracingTest.php +++ /dev/null @@ -1,413 +0,0 @@ -asynchronousUsers(); - - EcotoneLite::bootstrapFlowTesting( - [$users::class], - [$users, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration(), SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - ) - ->sendCommandWithRouting('user.register', '1') - ->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send command'], - 'children' => [ - [ - 'details' => ['name' => 'send async_channel', 'kind' => SpanKind::Producer], - 'children' => [ - [ - 'details' => ['name' => 'receive async_channel', 'kind' => SpanKind::Consumer], - 'children' => [ - ['details' => ['name' => 'process class@anonymous::register'], 'children' => []], - ], - ], - ], - ], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - } - - public function test_the_consumer_continues_the_trace_of_the_sender_across_the_queue(): void - { - $recorder = new InMemorySpanRecorder(); - $users = $this->asynchronousUsers(); - - EcotoneLite::bootstrapFlowTesting( - [$users::class], - [$users, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration(), SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - ) - ->sendCommandWithRouting('user.register', '1') - ->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()); - - $traceIds = array_unique(array_map(fn (Span $span): string => $span->traceId, $recorder->recordedSpans())); - $this->assertCount(1, $traceIds); - } - - public function test_tracing_same_message_for_asynchronous_scenario(): void - { - $recorder = new InMemorySpanRecorder(); - $users = $this->asynchronousUsers(); - $messageId = Uuid::v7()->toRfc4122(); - $correlationId = Uuid::v7()->toRfc4122(); - - EcotoneLite::bootstrapFlowTesting( - [$users::class], - [$users, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration(), SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - ) - ->sendCommandWithRouting('user.register', '1', metadata: [ - MessageHeaders::MESSAGE_ID => $messageId, - MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId, - ]) - ->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()); - - $node = self::getNodeAtTargetedSpan( - [ - 'details' => ['name' => 'send command'], - 'child' => [ - 'details' => ['name' => 'send async_channel'], - 'child' => ['details' => ['name' => 'receive async_channel']], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - - $this->assertSame($correlationId, $node['details']['attributes'][MessageHeaders::MESSAGE_CORRELATION_ID]); - $this->assertSame($messageId, $node['details']['attributes'][MessageHeaders::MESSAGE_ID]); - $child = $node['children'][array_key_first($node['children'])]; - $this->assertSame($correlationId, $child['details']['attributes'][MessageHeaders::MESSAGE_CORRELATION_ID]); - $this->assertSame($messageId, $child['details']['attributes'][MessageHeaders::MESSAGE_ID]); - } - - public function test_two_traces_with_asynchronous_handlers(): void - { - $recorder = new InMemorySpanRecorder(); - $users = $this->asynchronousUsers(); - - EcotoneLite::bootstrapFlowTesting( - [$users::class], - [$users, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration(), SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - ) - ->sendCommandWithRouting('user.register', '1', metadata: ['flowId' => '1']) - ->sendCommandWithRouting('user.register', '2', metadata: ['flowId' => '2']) - ->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup(handledMessageLimit: 2)); - - $expectedFlow = fn (string $flowId): array => [ - 'details' => ['name' => 'send command', 'attributes' => ['flowId' => $flowId]], - 'children' => [ - [ - 'details' => ['name' => 'send async_channel', 'attributes' => ['flowId' => $flowId]], - 'children' => [ - [ - 'details' => ['name' => 'receive async_channel', 'attributes' => ['flowId' => $flowId]], - 'children' => [ - ['details' => ['name' => 'process class@anonymous::register', 'attributes' => ['flowId' => $flowId]], 'children' => []], - ], - ], - ], - ], - ], - ]; - self::compareTreesByDetails([$expectedFlow('1'), $expectedFlow('2')], self::buildTree($recorder->recordedSpans())); - $this->assertCount(2, array_unique(array_map(fn (Span $span): string => $span->traceId, $recorder->recordedSpans()))); - } - - public function test_user_headers_travel_with_the_message_into_every_span(): void - { - $recorder = new InMemorySpanRecorder(); - $users = $this->asynchronousUsers(); - - EcotoneLite::bootstrapFlowTesting( - [$users::class], - [$users, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration(), SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - ) - ->sendCommandWithRouting('user.register', '1', metadata: ['user_id' => '123']) - ->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()); - - foreach (['send command', 'send async_channel', 'receive async_channel', 'process class@anonymous::register'] as $spanName) { - $spans = array_values(array_filter($recorder->recordedSpans(), fn (Span $span): bool => $span->name === $spanName)); - $this->assertCount(1, $spans, $spanName); - $this->assertSame('123', $spans[0]->attributes['user_id'], $spanName); - } - } - - public function test_the_trace_carrier_header_is_not_reported_as_a_user_attribute(): void - { - $recorder = new InMemorySpanRecorder(); - $users = $this->asynchronousUsers(); - - EcotoneLite::bootstrapFlowTesting( - [$users::class], - [$users, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration(), SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - ) - ->sendCommandWithRouting('user.register', '1') - ->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()); - - foreach ($recorder->recordedSpans() as $span) { - $this->assertArrayNotHasKey(MessageHeaders::TRACEPARENT, $span->attributes, $span->name); - } - } - - public function test_an_event_collected_during_the_asynchronous_handler_is_sent_under_the_consumer_span_in_the_same_trace(): void - { - $recorder = new InMemorySpanRecorder(); - $users = new class () { - #[Asynchronous('async_channel')] - #[CommandHandler('user.register', endpointId: 'user.register.endpoint')] - public function register(string $userId, EventBus $eventBus): void - { - $eventBus->publishWithRouting('user.registered', $userId); - } - }; - $notifier = new class () { - #[Asynchronous('async_channel')] - #[EventHandler('user.registered', endpointId: 'user.registered.endpoint')] - public function notify(string $userId): void - { - } - }; - - EcotoneLite::bootstrapFlowTesting( - [$users::class, $notifier::class], - [$users, $notifier, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration(), SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - ) - ->sendCommandWithRouting('user.register', '1') - ->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup(handledMessageLimit: 2)); - - self::getNodeAtTargetedSpan( - [ - 'details' => ['name' => 'send command'], - 'child' => [ - 'details' => ['name' => 'send async_channel'], - 'child' => [ - 'details' => ['name' => 'receive async_channel'], - 'child' => [ - 'details' => ['name' => 'send async_channel'], - 'child' => [ - 'details' => ['name' => 'receive async_channel'], - 'child' => ['details' => ['name' => 'process class@anonymous::notify']], - ], - ], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - self::getNodeAtTargetedSpan( - [ - 'details' => ['name' => 'send command'], - 'child' => [ - 'details' => ['name' => 'send async_channel'], - 'child' => [ - 'details' => ['name' => 'receive async_channel'], - 'child' => [ - 'details' => ['name' => 'process class@anonymous::register'], - 'child' => ['details' => ['name' => 'send event']], - ], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - $this->assertCount(1, array_unique(array_map(fn (Span $span): string => $span->traceId, $recorder->recordedSpans()))); - } - - public function test_adding_logs_as_events_in_span(): void - { - $recorder = new InMemorySpanRecorder(); - $users = new class () { - #[Asynchronous('async_channel')] - #[CommandHandler('user.register', endpointId: 'user.register.endpoint')] - public function register(string $userId, EventBus $eventBus): void - { - $eventBus->publishWithRouting('user.registered', $userId); - } - }; - $notifier = new class () { - #[Asynchronous('async_channel')] - #[EventHandler('user.registered', endpointId: 'user.registered.endpoint')] - public function notify(string $userId): void - { - } - }; - - EcotoneLite::bootstrapFlowTesting( - [$users::class, $notifier::class], - [$users, $notifier, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration(), SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - ) - ->sendCommandWithRouting('user.register', '1') - ->run('async_channel'); - - $eventBus = self::getNodeAtTargetedSpan( - [ - 'details' => ['name' => 'send command'], - 'child' => [ - 'details' => ['name' => 'send async_channel'], - 'child' => [ - 'details' => ['name' => 'receive async_channel'], - 'child' => [ - 'details' => ['name' => 'process class@anonymous::register'], - 'child' => ['details' => ['name' => 'send event']], - ], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - - $eventBusEvents = array_map(fn ($event): string => $event->name, $eventBus['details']['events']); - $this->assertNotEmpty(array_filter($eventBusEvents, fn (string $name): bool => str_starts_with($name, 'Publishing Event Message'))); - - $route = $eventBus['children'][array_key_first($eventBus['children'])]; - $this->assertSame('route user.registered.endpoint', $route['details']['name']); - $routeEvents = array_map(fn ($event): string => $event->name, $route['details']['events']); - $this->assertNotEmpty(array_filter($routeEvents, fn (string $name): bool => str_starts_with($name, 'Collecting message with id'))); - } - - public function test_tracing_scheduled_handler(): void - { - $recorder = new InMemorySpanRecorder(); - $scheduled = new class () { - #[Scheduled(endpointId: 'scheduled_handler')] - #[Poller(fixedRateInMilliseconds: 1)] - public function handle(): void - { - } - }; - - $ecotone = EcotoneLite::bootstrapFlowTesting( - [$scheduled::class], - [$scheduled, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ); - - $ecotone->run('scheduled_handler', ExecutionPollingMetadata::createWithDefaults()->withExecutionAmountLimit(1)); - $ecotone->run('scheduled_handler', ExecutionPollingMetadata::createWithDefaults()->withExecutionAmountLimit(1)); - - $polls = array_values(array_filter($recorder->recordedSpans(), fn (Span $span): bool => $span->name === 'receive nullChannel')); - $this->assertCount(2, $polls); - foreach ($polls as $poll) { - $this->assertSame(SpanKind::Consumer, $poll->kind); - $this->assertTrue($poll->isRoot()); - } - $this->assertNotSame($polls[0]->traceId, $polls[1]->traceId); - } - - public function test_tracing_workflow_scheduled_handler(): void - { - $recorder = new InMemorySpanRecorder(); - $workflow = $this->workflowScheduledHandler(); - - $ecotone = EcotoneLite::bootstrapFlowTesting( - [$workflow::class], - [$workflow, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ); - - $ecotone->run('scheduled_handler', ExecutionPollingMetadata::createWithDefaults()->withExecutionAmountLimit(1)); - $ecotone->run('scheduled_handler', ExecutionPollingMetadata::createWithDefaults()->withExecutionAmountLimit(1)); - - $spans = $recorder->recordedSpans(); - $polls = array_values(array_filter($spans, fn (Span $span): bool => $span->name === 'receive add')); - $this->assertCount(2, $polls); - foreach ($polls as $poll) { - $this->assertTrue($poll->isRoot()); - $handlers = array_values(array_filter($spans, fn (Span $span): bool => $span->parentSpanId === $poll->spanId)); - $this->assertCount(1, $handlers); - $this->assertMatchesRegularExpression('/^process class@anonymous\S*::add$/', $handlers[0]->name); - $this->assertSame($poll->traceId, $handlers[0]->traceId); - } - $this->assertNotSame($polls[0]->traceId, $polls[1]->traceId); - } - - public function test_no_tracing_when_there_is_nothing_to_trace(): void - { - $recorder = new InMemorySpanRecorder(); - $workflow = $this->workflowScheduledHandler(); - - EcotoneLite::bootstrapFlowTesting( - [$workflow::class], - [$workflow, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ) - ->run('scheduled_handler_without_result', ExecutionPollingMetadata::createWithDefaults()->withExecutionAmountLimit(1)); - - $this->assertSame([], $recorder->recordedSpans()); - } - - private function asynchronousUsers(): object - { - return new class () { - #[Asynchronous('async_channel')] - #[CommandHandler('user.register', endpointId: 'user.register.endpoint')] - public function register(string $userId): void - { - } - }; - } - - private function workflowScheduledHandler(): object - { - return new class () { - #[Scheduled(requestChannelName: 'add', endpointId: 'scheduled_handler')] - #[Poller(fixedRateInMilliseconds: 1)] - public function produce(): int - { - return 1; - } - - #[Scheduled(requestChannelName: 'add', endpointId: 'scheduled_handler_without_result')] - #[Poller(fixedRateInMilliseconds: 1)] - public function produceNothing(): ?int - { - return null; - } - - #[InternalHandler(inputChannelName: 'add')] - public function add(): void - { - } - }; - } -} diff --git a/packages/Ecotone/tests/Tracing/FlowShapeTracingTest.php b/packages/Ecotone/tests/Tracing/FlowShapeTracingTest.php deleted file mode 100644 index 0297ba536c..0000000000 --- a/packages/Ecotone/tests/Tracing/FlowShapeTracingTest.php +++ /dev/null @@ -1,329 +0,0 @@ -sendCommandWithRouting('order.place', '1'); - - $this->assertSame( - ['process class@anonymous::place', 'process class@anonymous::store', 'process class@anonymous::validate', 'send command'], - self::spanNames($recorder), - ); - $this->assertSame( - ['process class@anonymous::place' => 'order.placing', 'process class@anonymous::store' => 'order.storing', 'process class@anonymous::validate' => 'order.validating'], - self::endpointIdsByHandlerSpan($recorder), - ); - $this->assertCount(1, self::traceIds($recorder)); - } - - public function test_a_splitter_fans_one_command_out_into_one_handler_span_per_item(): void - { - $recorder = new InMemorySpanRecorder(); - $imports = new class () { - #[CommandHandler('batch.import', outputChannelName: 'batch.split', endpointId: 'batch.importing')] - public function import(array $items): array - { - return $items; - } - - #[Splitter('batch.split', outputChannelName: 'item.handle')] - public function split(array $items): array - { - return $items; - } - - #[InternalHandler('item.handle', endpointId: 'item.handling')] - public function handle(string $item): void - { - } - }; - - self::bootstrap($recorder, [$imports])->sendCommandWithRouting('batch.import', ['a', 'b']); - - $this->assertSame( - ['process class@anonymous::handle', 'process class@anonymous::handle', 'process class@anonymous::import', 'send command'], - self::spanNames($recorder), - ); - $this->assertSame(['item.handling', 'item.handling'], self::endpointIdsOfSpansNamed('process class@anonymous::handle', $recorder)); - $this->assertCount(1, self::traceIds($recorder)); - } - - public function test_a_router_leads_the_trace_to_the_handler_it_chose(): void - { - $recorder = new InMemorySpanRecorder(); - $shipments = new class () { - #[CommandHandler('shipment.dispatch', outputChannelName: 'shipment.route', endpointId: 'shipment.dispatching')] - public function dispatch(string $shipmentId): string - { - return $shipmentId; - } - - #[Router('shipment.route')] - public function route(string $shipmentId): string - { - return 'shipment.express'; - } - - #[InternalHandler('shipment.express', endpointId: 'shipment.expressing')] - public function express(string $shipmentId): void - { - } - - #[InternalHandler('shipment.standard', endpointId: 'shipment.standard.handling')] - public function standard(string $shipmentId): void - { - } - }; - - self::bootstrap($recorder, [$shipments])->sendCommandWithRouting('shipment.dispatch', '1'); - - $this->assertSame( - ['process class@anonymous::dispatch', 'process class@anonymous::express', 'send command'], - self::spanNames($recorder), - ); - $this->assertSame(['shipment.expressing'], self::endpointIdsOfSpansNamed('process class@anonymous::express', $recorder)); - } - - public function test_an_orchestrator_gateway_starting_the_flow_traces_each_step_under_one_trace(): void - { - $recorder = new InMemorySpanRecorder(); - $steps = new class () { - #[InternalHandler('approval.validate', endpointId: 'approval.validating')] - public function validate(string $request): string - { - return $request; - } - - #[InternalHandler('approval.notify', endpointId: 'approval.notifying')] - public function notify(string $request): string - { - return $request; - } - }; - $ecotone = self::bootstrap($recorder, [$steps], [ApprovalWorkflow::class], LicenceTesting::VALID_LICENCE); - - $ecotone->getGateway(ApprovalWorkflow::class)->start(['approval.validate', 'approval.notify'], 'request-1'); - - $this->assertSame( - ['process class@anonymous::notify', 'process class@anonymous::validate', 'send orchestrator'], - self::spanNames($recorder), - ); - $this->assertSame( - ['process class@anonymous::notify' => 'approval.notifying', 'process class@anonymous::validate' => 'approval.validating'], - self::endpointIdsByHandlerSpan($recorder), - ); - $this->assertCount(1, self::traceIds($recorder)); - } - - public function test_an_orchestrator_reached_from_a_command_handler_continues_that_command_trace(): void - { - $recorder = new InMemorySpanRecorder(); - $approvals = new class () { - #[CommandHandler('approval.request', outputChannelName: 'approval.workflow', endpointId: 'approval.requesting')] - public function request(string $request): string - { - return $request; - } - - #[Orchestrator('approval.workflow')] - public function workflow(): array - { - return ['approval.validate', 'approval.notify']; - } - - #[InternalHandler('approval.validate', endpointId: 'approval.validating')] - public function validate(string $request): string - { - return $request; - } - - #[InternalHandler('approval.notify', endpointId: 'approval.notifying')] - public function notify(string $request): string - { - return $request; - } - }; - - self::bootstrap($recorder, [$approvals], [], LicenceTesting::VALID_LICENCE)->sendCommandWithRouting('approval.request', 'request-1'); - - $this->assertSame( - ['process class@anonymous::notify', 'process class@anonymous::request', 'process class@anonymous::validate', 'send command'], - self::spanNames($recorder), - ); - $this->assertSame( - ['process class@anonymous::notify' => 'approval.notifying', 'process class@anonymous::request' => 'approval.requesting', 'process class@anonymous::validate' => 'approval.validating'], - self::endpointIdsByHandlerSpan($recorder), - ); - $this->assertCount(1, self::traceIds($recorder)); - } - - public function test_a_message_gateway_starting_the_flow_is_the_root_of_the_handler_it_reaches(): void - { - $recorder = new InMemorySpanRecorder(); - $notifications = new class () { - #[InternalHandler('notification.send', endpointId: 'notification.sending')] - public function send(string $text): void - { - } - }; - $ecotone = self::bootstrap($recorder, [$notifications], [NotificationGateway::class]); - - $ecotone->getGateway(NotificationGateway::class)->send('hello'); - - $this->assertSame(['process class@anonymous::send', 'send notification.send'], self::spanNames($recorder)); - $this->assertCount(1, self::traceIds($recorder)); - $this->assertSame(['notification.sending'], self::endpointIdsOfSpansNamed('process class@anonymous::send', $recorder)); - } - - public function test_a_command_bus_called_inside_a_command_handler_nests_the_second_command_under_the_first_handler(): void - { - $recorder = new InMemorySpanRecorder(); - $users = new class () { - #[CommandHandler('user.register', endpointId: 'user.registration')] - public function register(string $userId, CommandBus $commandBus): void - { - $commandBus->sendWithRouting('audit.record', $userId); - } - - #[CommandHandler('audit.record', endpointId: 'audit.recording')] - public function record(string $userId): void - { - } - }; - - self::bootstrap($recorder, [$users])->sendCommandWithRouting('user.register', '1'); - - self::getNodeAtTargetedSpan( - [ - 'details' => ['name' => 'send command'], - 'child' => [ - 'details' => ['name' => 'process class@anonymous::register', 'attributes' => [SpanAttribute::ENDPOINT_ID => 'user.registration']], - 'child' => [ - 'details' => ['name' => 'send command'], - 'child' => ['details' => ['name' => 'process class@anonymous::record', 'attributes' => [SpanAttribute::ENDPOINT_ID => 'audit.recording']]], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - } - - /** - * @param object[] $services - * @param class-string[] $gateways - */ - private static function bootstrap(InMemorySpanRecorder $recorder, array $services, array $gateways = [], ?string $licenceKey = null): FlowTestSupport - { - return EcotoneLite::bootstrapFlowTesting( - [...array_map(fn (object $service): string => $service::class, $services), ...$gateways], - [...$services, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - licenceKey: $licenceKey, - ); - } - - /** - * @return string[] - */ - private static function spanNames(InMemorySpanRecorder $recorder): array - { - $names = array_map( - fn (string $name): string => preg_replace('/^process class@anonymous\S*::/', 'process class@anonymous::', $name), - self::spanNamesWithoutFrameworkNoise($recorder->recordedSpans()), - ); - sort($names); - - return $names; - } - - /** - * @return array - */ - private static function endpointIdsByHandlerSpan(InMemorySpanRecorder $recorder): array - { - $endpointIds = []; - foreach ($recorder->recordedSpans() as $span) { - if (isset($span->attributes[SpanAttribute::ENDPOINT_ID])) { - $endpointIds[preg_replace('/^process class@anonymous\S*::/', 'process class@anonymous::', $span->name)] = $span->attributes[SpanAttribute::ENDPOINT_ID]; - } - } - ksort($endpointIds); - - return $endpointIds; - } - - /** - * @return string[] - */ - private static function endpointIdsOfSpansNamed(string $name, InMemorySpanRecorder $recorder): array - { - return array_values(array_map( - fn (Span $span): string => $span->attributes[SpanAttribute::ENDPOINT_ID] ?? '', - array_filter($recorder->recordedSpans(), fn (Span $span): bool => preg_replace('/^process class@anonymous\S*::/', 'process class@anonymous::', $span->name) === $name), - )); - } - - /** - * @return string[] - */ - private static function traceIds(InMemorySpanRecorder $recorder): array - { - return array_values(array_unique(array_map(fn (Span $span): string => $span->traceId, $recorder->recordedSpans()))); - } -} - -interface ApprovalWorkflow -{ - #[OrchestratorGateway] - public function start(array $routing, mixed $data): mixed; -} - -interface NotificationGateway -{ - #[MessageGateway('notification.send')] - public function send(string $text): void; -} diff --git a/packages/Ecotone/tests/Tracing/RouteTracingTest.php b/packages/Ecotone/tests/Tracing/RouteTracingTest.php deleted file mode 100644 index 245d5e1510..0000000000 --- a/packages/Ecotone/tests/Tracing/RouteTracingTest.php +++ /dev/null @@ -1,236 +0,0 @@ -publishEventWithRouting('merchant.created', '1'); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send event'], - 'children' => [ - [ - 'details' => [ - 'name' => 'route merchant.notifier', - 'kind' => SpanKind::Producer, - 'attributes' => ['messaging.destination.name' => 'merchant.notifier.target', 'ecotone.endpoint.id' => 'merchant.notifier'], - ], - 'children' => [ - ['details' => ['name' => 'process class@anonymous::notify', 'attributes' => ['ecotone.endpoint.id' => 'merchant.notifier']], 'children' => []], - ], - ], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - } - - public function test_a_failing_synchronous_handler_fails_its_route_span(): void - { - $recorder = new InMemorySpanRecorder(); - $subscriber = new class () { - #[EventHandler('merchant.created', endpointId: 'merchant.notifier')] - public function notify(string $merchantId): void - { - throw new RuntimeException('Notification failed'); - } - }; - $ecotone = EcotoneLite::bootstrapFlowTesting( - [$subscriber::class], - [$subscriber, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ); - - try { - $ecotone->publishEventWithRouting('merchant.created', '1'); - $this->fail('Expected the handler failure to propagate'); - } catch (RuntimeException) { - } - - $this->assertSame(SpanStatus::Error, self::spanNamed('route merchant.notifier', $recorder)->status); - } - - public function test_an_asynchronous_event_route_leads_through_the_channel_to_its_handler(): void - { - $recorder = new InMemorySpanRecorder(); - $subscriber = new class () { - #[Asynchronous('async_channel')] - #[EventHandler('merchant.created', endpointId: 'merchant.notifier')] - public function notify(string $merchantId): void - { - } - }; - - EcotoneLite::bootstrapFlowTesting( - [$subscriber::class], - [$subscriber, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration(), SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - ) - ->publishEventWithRouting('merchant.created', '1') - ->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()); - - self::getNodeAtTargetedSpan( - [ - 'details' => ['name' => 'send event'], - 'child' => [ - 'details' => ['name' => 'route merchant.notifier'], - 'child' => [ - 'details' => ['name' => 'send async_channel', 'attributes' => ['messaging.destination.name' => 'async_channel']], - 'child' => [ - 'details' => ['name' => 'receive async_channel', 'attributes' => ['messaging.destination.name' => 'async_channel']], - 'child' => ['details' => ['name' => 'process class@anonymous::notify']], - ], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - } - - public function test_an_event_driven_projection_is_a_consumer_span_under_its_route(): void - { - $recorder = new InMemorySpanRecorder(); - $projection = new #[Projection('orders'), FromStream('orders_stream')] class () { - #[EventHandler('order.placed')] - public function whenPlaced(array $event): void - { - } - }; - - $ecotone = EcotoneLite::bootstrapFlowTesting( - [$projection::class], - [$projection, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ); - $ecotone->withEvents([Event::createWithType('order.placed', ['orderId' => '1'])]); - - $ecotone->publishEventWithRouting('order.placed', []); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send event'], - 'children' => [ - [ - 'details' => ['name' => 'route orders', 'kind' => SpanKind::Producer, 'attributes' => ['ecotone.projection.name' => 'orders']], - 'children' => [ - ['details' => ['name' => 'process orders', 'kind' => SpanKind::Consumer, 'attributes' => ['ecotone.projection.name' => 'orders']], 'children' => []], - ], - ], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - } - - public function test_an_asynchronous_projection_is_a_consumer_span_after_its_channel(): void - { - $recorder = new InMemorySpanRecorder(); - $projection = new #[Projection('orders'), FromStream('orders_stream'), Asynchronous('async_channel')] class () { - #[EventHandler('order.placed')] - public function whenPlaced(array $event): void - { - } - }; - - $ecotone = EcotoneLite::bootstrapFlowTesting( - [$projection::class], - [$projection, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration(), SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - licenceKey: LicenceTesting::VALID_LICENCE, - ); - $ecotone->withEvents([Event::createWithType('order.placed', ['orderId' => '1'])]); - - $ecotone->publishEventWithRouting('order.placed', []) - ->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()); - - self::getNodeAtTargetedSpan( - [ - 'details' => ['name' => 'send event'], - 'child' => [ - 'details' => ['name' => 'route orders'], - 'child' => [ - 'details' => ['name' => 'send async_channel'], - 'child' => [ - 'details' => ['name' => 'receive async_channel'], - 'child' => ['details' => ['name' => 'process orders', 'kind' => SpanKind::Consumer]], - ], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - } - - public function test_every_span_names_the_messaging_system_and_handler_spans_name_their_endpoint(): void - { - $recorder = new InMemorySpanRecorder(); - $users = new class () { - #[CommandHandler('user.register', endpointId: 'user.registration')] - public function register(string $userId): void - { - } - }; - - EcotoneLite::bootstrapFlowTesting( - [$users::class], - [$users, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ) - ->sendCommandWithRouting('user.register', '1'); - - foreach ($recorder->recordedSpans() as $span) { - $this->assertSame('ecotone', $span->attributes['messaging.system'], $span->name); - } - $this->assertSame('user.registration', self::spanNamed('process class@anonymous::register', $recorder)->attributes['ecotone.endpoint.id']); - } - - private static function spanNamed(string $name, InMemorySpanRecorder $recorder): Span - { - $spans = array_values(array_filter($recorder->recordedSpans(), fn (Span $span): bool => $span->name === $name)); - self::assertNotEmpty($spans, 'Expected a span named ' . $name . ', got: ' . implode(', ', self::spanNamesWithoutFrameworkNoise($recorder->recordedSpans()))); - - return $spans[0]; - } -} diff --git a/packages/Ecotone/tests/Tracing/TracingEdgeCasesTest.php b/packages/Ecotone/tests/Tracing/TracingEdgeCasesTest.php deleted file mode 100644 index d963aa17ec..0000000000 --- a/packages/Ecotone/tests/Tracing/TracingEdgeCasesTest.php +++ /dev/null @@ -1,206 +0,0 @@ -bootstrapWithEventForwardedToSecondChannel($recorder, [ - SimpleMessageChannelBuilder::createQueueChannel('async_channel'), - SimpleMessageChannelBuilder::createQueueChannel('failing_channel'), - ], [$blockingInterceptor]); - $ecotone->sendCommandWithRouting('user.register', '1'); - - $caught = $this->runConsumer($ecotone); - $this->assertStringContainsString('Exception before send', $caught?->getMessage() ?? ''); - - $recorder->clear(); - $ecotone->sendCommandWithRouting('user.register', '2'); - $this->assertSame('send command', self::rootSpan($recorder)->name); - } - - public function test_a_command_carrying_a_trace_carrier_continues_that_trace_as_a_child(): void - { - $recorder = new InMemorySpanRecorder(); - $users = new class () { - #[CommandHandler('user.register')] - public function register(string $userId): void - { - } - }; - - EcotoneLite::bootstrapFlowTesting( - [$users::class], - [$users, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ) - ->sendCommandWithRouting('user.register', '1', metadata: [MessageHeaders::TRACEPARENT => '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01']); - - $commandBus = self::singleSpanNamed('send command', $recorder); - $this->assertSame('0af7651916cd43dd8448eb211c80319c', $commandBus->traceId); - $this->assertSame('b7ad6b7169203331', $commandBus->parentSpanId); - $this->assertSame($commandBus->spanId, self::singleSpanNamed('process class@anonymous::register', $recorder)->parentSpanId); - } - - public function test_the_trace_carrier_of_the_incoming_command_does_not_leak_into_the_handler_span_parent(): void - { - $recorder = new InMemorySpanRecorder(); - $users = new class () { - #[CommandHandler('user.register')] - public function register(string $userId): void - { - } - }; - - EcotoneLite::bootstrapFlowTesting( - [$users::class], - [$users, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ) - ->sendCommandWithRouting('user.register', '1', metadata: [MessageHeaders::TRACEPARENT => '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01']); - - $handler = self::singleSpanNamed('process class@anonymous::register', $recorder); - $this->assertNotSame('b7ad6b7169203331', $handler->parentSpanId); - } - - public function test_distributed_bus_send_is_a_producer_span_carrying_the_trace_to_the_other_service(): void - { - $recorder = new InMemorySpanRecorder(); - $ticketService = new class () { - public ?string $traceparent = null; - - #[CommandHandler('ticket.create')] - public function create(string $description, #[Header(MessageHeaders::TRACEPARENT)] ?string $traceparent = null): void - { - $this->traceparent = $traceparent; - } - }; - - EcotoneLite::bootstrapFlowTesting( - [$ticketService::class], - [$ticketService, ...self::recordingSpansInto($recorder)], - [ - self::tracingConfiguration(), - SimpleMessageChannelBuilder::createQueueChannel('distributed_channel'), - DistributedServiceMap::initialize()->withCommandMapping(targetServiceName: 'ticket_service', channelName: 'distributed_channel'), - ServiceConfiguration::createWithDefaults()->withServiceName('ticket_service'), - ], - licenceKey: LicenceTesting::VALID_LICENCE, - ) - ->getDistributedBus()->sendCommand('ticket_service', 'ticket.create', 'User changed billing address'); - - $distributedBus = self::singleSpanNamed('send distributed', $recorder); - $this->assertSame(SpanKind::Producer, $distributedBus->kind); - $this->assertSame($distributedBus->spanId, self::singleSpanNamed('send distributed_channel', $recorder)->parentSpanId); - } - - private function bootstrapWithEventForwardedToSecondChannel(InMemorySpanRecorder $recorder, array $channelsToRegister, array $servicesToRegister = []): FlowTestSupport - { - $users = new class () { - #[Asynchronous('async_channel')] - #[CommandHandler('user.register', endpointId: 'userRegisterEndpoint')] - public function register(string $userId, EventBus $eventBus): void - { - $eventBus->publishWithRouting('user.registered', $userId); - } - }; - $notifier = new class () { - #[Asynchronous('failing_channel')] - #[EventHandler('user.registered', endpointId: 'userNotifierEndpoint')] - public function notify(string $userId): void - { - } - }; - - return EcotoneLite::bootstrapFlowTesting( - [$users::class, $notifier::class, ...array_map(fn (object $service): string => $service::class, $servicesToRegister)], - [$users, $notifier, ...self::recordingSpansInto($recorder), ...$servicesToRegister], - [self::tracingConfiguration(), ...$channelsToRegister], - enabledModules: [ModulePackageList::CORE_PACKAGE], - ); - } - - private function asynchronousUsers(): object - { - return new class () { - #[Asynchronous('async_channel')] - #[CommandHandler('user.register', endpointId: 'user.register.endpoint')] - public function register(string $userId): void - { - } - }; - } - - private function runConsumer(FlowTestSupport $ecotone, string $channel = 'async_channel'): ?Throwable - { - try { - $ecotone->run($channel, ExecutionPollingMetadata::createWithTestingSetup()); - } catch (Throwable $failure) { - return $failure; - } - - return null; - } - - private static function singleSpanNamed(string $name, InMemorySpanRecorder $recorder): Span - { - $spans = array_values(array_filter($recorder->recordedSpans(), fn (Span $span): bool => $span->name === $name)); - self::assertCount(1, $spans, 'Expected exactly one span named ' . $name . ', got: ' . implode(', ', self::spanNamesWithoutFrameworkNoise($recorder->recordedSpans()))); - - return $spans[0]; - } - - private static function assertEverySpanNamedHasStatus(string $name, SpanStatus $status, InMemorySpanRecorder $recorder): void - { - $spans = array_values(array_filter($recorder->recordedSpans(), fn (Span $span): bool => $span->name === $name)); - self::assertNotEmpty($spans, 'Expected spans named ' . $name . ', got: ' . implode(', ', self::spanNamesWithoutFrameworkNoise($recorder->recordedSpans()))); - foreach ($spans as $span) { - self::assertSame($status, $span->status, $name); - } - } - - private static function rootSpan(InMemorySpanRecorder $recorder): Span - { - $roots = array_values(array_filter($recorder->recordedSpans(), fn (Span $span): bool => $span->isRoot())); - self::assertCount(1, $roots, 'Expected exactly one root span, got: ' . implode(', ', array_map(fn (Span $span): string => $span->name, $roots))); - - return $roots[0]; - } -} diff --git a/packages/Ecotone/tests/Tracing/TracingFlowConformanceTest.php b/packages/Ecotone/tests/Tracing/TracingFlowConformanceTest.php new file mode 100644 index 0000000000..8e46aafeb1 --- /dev/null +++ b/packages/Ecotone/tests/Tracing/TracingFlowConformanceTest.php @@ -0,0 +1,38 @@ +classesToResolve, + [...$scenario->services, ...self::recordingSpansInto($recorder)], + [...$scenario->extensionObjects, self::tracingConfiguration()], + enabledModules: [], + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + foreach (array_keys($scenario->stages) as $index) { + self::assertNull($scenario->runStage($index, $ecotone)); + + self::assertNull($scenario->mismatchAfterStage($index, $recorder->recordedSpans())); + } + } +} diff --git a/packages/Ecotone/tests/Tracing/TracingTestCase.php b/packages/Ecotone/tests/Tracing/TracingTestCase.php index 696dd04e6b..bb1fb0666d 100644 --- a/packages/Ecotone/tests/Tracing/TracingTestCase.php +++ b/packages/Ecotone/tests/Tracing/TracingTestCase.php @@ -5,11 +5,7 @@ namespace Test\Ecotone\Tracing; use Ecotone\Api\Tracing\InMemorySpanRecorder; -use Ecotone\Api\Tracing\Span; use Ecotone\Api\Tracing\TracingConfiguration; - -use function json_encode; - use PHPUnit\Framework\TestCase; /** @@ -17,8 +13,6 @@ */ abstract class TracingTestCase extends TestCase { - private const INTERNAL_NOISE_SPAN_NAME_PREFIX = 'process MessageHeadersPropagatorInterceptor::'; - protected static function recordingSpansInto(InMemorySpanRecorder $recorder): array { return [InMemorySpanRecorder::class => $recorder]; @@ -28,148 +22,4 @@ protected static function tracingConfiguration(): TracingConfiguration { return TracingConfiguration::createWithDefaults()->withSpanRecorder(InMemorySpanRecorder::class); } - - /** - * @param Span[] $spans - * @return string[] - */ - public static function spanNamesWithoutFrameworkNoise(array $spans): array - { - return array_values(array_filter( - array_map(fn (Span $span): string => $span->name, $spans), - fn (string $name): bool => ! str_starts_with($name, self::INTERNAL_NOISE_SPAN_NAME_PREFIX), - )); - } - - /** - * @param Span[] $spans - */ - public static function buildTree(array $spans): array - { - $tree = []; - foreach ($spans as $span) { - if (str_starts_with($span->name, self::INTERNAL_NOISE_SPAN_NAME_PREFIX)) { - continue; - } - - $preparedSpan = [ - 'details' => [ - 'name' => $span->name, - 'trace_id' => $span->traceId, - 'span_id' => $span->spanId, - 'parent_id' => $span->parentSpanId, - 'attributes' => $span->attributes, - 'kind' => $span->kind, - 'status' => $span->status, - 'events' => $span->events, - ], - 'children' => [], - ]; - - $tree = self::putInTree($preparedSpan, $tree, true); - } - - return $tree; - } - - public static function compareTreesByDetails(array $expectedTree, array $collectedTree): void - { - foreach ($expectedTree as $expectedNode) { - self::findAndCompareNode($expectedNode, $collectedTree); - } - } - - private static function putInTree(array $searchedSpan, array $tree, bool $isRoot): array - { - $searchedSpanId = $searchedSpan['details']['span_id']; - $searchedParentId = $searchedSpan['details']['parent_id']; - - foreach ($tree as $nodeId => $node) { - if ($node['details']['parent_id'] === $searchedSpanId) { - unset($tree[$nodeId]); - $searchedSpan['children'][$nodeId] = $node; - } - } - $changedStructure = $tree; - - foreach ($tree as $nodeId => $node) { - if ($nodeId === $searchedParentId) { - if (in_array($searchedSpanId, array_keys($node['children']))) { - continue; - } - - $changedStructure[$nodeId]['children'][$searchedSpanId] = $searchedSpan; - } elseif ($node['details']['parent_id'] === $searchedSpanId) { - unset($changedStructure[$nodeId]); - $searchedSpan['children'][$nodeId] = $node; - - $changedStructure = self::putInTree($searchedSpan, $changedStructure, true); - } else { - $changedStructure[$nodeId]['children'] = self::putInTree($searchedSpan, $node['children'], false); - } - } - - if ($isRoot && $changedStructure == $tree) { - $changedStructure[$searchedSpanId] = $searchedSpan; - } - - return $changedStructure; - } - - private static function findAndCompareNode(array $expectedTreeNode, array $collectedTree): void - { - foreach ($collectedTree as $collectedNode) { - if ($expectedTreeNode['details']['name'] !== $collectedNode['details']['name']) { - continue; - } - if (isset($expectedTreeNode['details']['attributes']['flowId'])) { - if (($collectedNode['details']['attributes']['flowId'] ?? null) != $expectedTreeNode['details']['attributes']['flowId']) { - continue; - } - } - - foreach ($expectedTreeNode['details'] as $expectedKey => $expectedValue) { - self::assertArrayHasKey($expectedKey, $collectedNode['details']); - if ($expectedKey === 'attributes') { - foreach ($expectedValue as $expectedAttributeKey => $expectedAttributeValue) { - self::assertArrayHasKey($expectedAttributeKey, $collectedNode['details']['attributes'], "Attribute {$expectedAttributeKey} missing on span {$collectedNode['details']['name']}"); - self::assertSame($expectedAttributeValue, $collectedNode['details']['attributes'][$expectedAttributeKey]); - } - } else { - self::assertSame($expectedValue, $collectedNode['details'][$expectedKey]); - } - } - self::compareTreesByDetails($expectedTreeNode['children'], $collectedNode['children']); - - return; - } - - self::fail("Could not find span named {$expectedTreeNode['details']['name']}. Spans at this level: " . json_encode(array_map(fn (array $node) => $node['details']['name'], array_values($collectedTree)))); - } - - public static function getNodeAtTargetedSpan(array $expectedTreeNode, array $collectedTree): array - { - foreach ($collectedTree as $collectedNode) { - if ($expectedTreeNode['details']['name'] !== $collectedNode['details']['name']) { - continue; - } - foreach ($expectedTreeNode['details'] as $expectedKey => $expectedValue) { - if ($expectedKey === 'attributes') { - foreach ($expectedValue as $expectedAttributeKey => $expectedAttributeValue) { - self::assertSame($expectedAttributeValue, $collectedNode['details']['attributes'][$expectedAttributeKey] ?? null, "Attribute {$expectedAttributeKey} on span {$collectedNode['details']['name']}"); - } - } else { - self::assertSame($expectedValue, $collectedNode['details'][$expectedKey]); - } - } - - if (! array_key_exists('child', $expectedTreeNode)) { - return $collectedNode; - } - - return self::getNodeAtTargetedSpan($expectedTreeNode['child'], $collectedNode['children']); - } - - self::fail("Could not find span named {$expectedTreeNode['details']['name']}. Spans at this level: " . json_encode(array_map(fn (array $node) => $node['details']['name'], array_values($collectedTree)))); - } } diff --git a/packages/Ecotone/tests/Tracing/TracingTreeTest.php b/packages/Ecotone/tests/Tracing/TracingTreeTest.php deleted file mode 100644 index 4787f331ae..0000000000 --- a/packages/Ecotone/tests/Tracing/TracingTreeTest.php +++ /dev/null @@ -1,449 +0,0 @@ -sendCommandWithRouting('user.register', '1'); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send command', 'kind' => SpanKind::Server, 'parent_id' => null], - 'children' => [ - [ - 'details' => ['name' => 'process class@anonymous::register', 'kind' => SpanKind::Server], - 'children' => [], - ], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - } - - public function test_tracing_tree_with_two_levels_of_nesting_and_message_handler(): void - { - $recorder = new InMemorySpanRecorder(); - $handler = new class () { - #[CommandHandler('handleCommand', outputChannelName: 'handleMessage')] - public function handleCommand(Message $message): Message - { - return $message; - } - - #[InternalHandler('handleMessage')] - public function handle(Message $message): void - { - } - }; - - EcotoneLite::bootstrapFlowTesting( - [$handler::class], - [$handler, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ) - ->sendCommandWithRouting('handleCommand'); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send command'], - 'children' => [ - ['details' => ['name' => 'process class@anonymous::handleCommand'], 'children' => []], - ['details' => ['name' => 'process class@anonymous::handle'], 'children' => []], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - } - - public function test_tracing_tree_with_two_levels_of_nesting(): void - { - $recorder = new InMemorySpanRecorder(); - $users = $this->users(); - $subscriber = $this->merchantSubscriber(); - - EcotoneLite::bootstrapFlowTesting( - [$users::class, $subscriber::class], - [$users, $subscriber, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ) - ->publishEventWithRouting('merchant.created', '1'); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send event'], - 'children' => [ - [ - 'details' => ['name' => 'route merchant.to.user', 'kind' => SpanKind::Producer], - 'children' => [ - [ - 'details' => ['name' => 'process class@anonymous::merchantToUser'], - 'children' => [ - [ - 'details' => ['name' => 'send command'], - 'children' => [ - ['details' => ['name' => 'process class@anonymous::register'], 'children' => []], - ], - ], - ], - ], - ], - ], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - } - - public function test_tracing_tree_with_two_branches_on_same_level(): void - { - $recorder = new InMemorySpanRecorder(); - $users = $this->users(); - $subscriberOne = $this->merchantSubscriber(); - $subscriberTwo = new class () { - #[EventHandler('merchant.created', endpointId: 'merchant.to.second.user')] - public function merchantToUser(string $merchantId, CommandBus $commandBus): void - { - $commandBus->sendWithRouting('user.register', $merchantId . '-second'); - } - }; - - EcotoneLite::bootstrapFlowTesting( - [$users::class, $subscriberOne::class, $subscriberTwo::class], - [$users, $subscriberOne, $subscriberTwo, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ) - ->publishEventWithRouting('merchant.created', '1'); - - $commandBusChild = [ - 'details' => ['name' => 'send command'], - 'children' => [ - ['details' => ['name' => 'process class@anonymous::register'], 'children' => []], - ], - ]; - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send event'], - 'children' => [ - [ - 'details' => ['name' => 'route merchant.to.user'], - 'children' => [['details' => ['name' => 'process class@anonymous::merchantToUser'], 'children' => [$commandBusChild]]], - ], - [ - 'details' => ['name' => 'route merchant.to.second.user'], - 'children' => [['details' => ['name' => 'process class@anonymous::merchantToUser'], 'children' => [$commandBusChild]]], - ], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - $this->assertCount(9, self::spanNamesWithoutFrameworkNoise($recorder->recordedSpans())); - } - - public function test_tracing_tree_with_three_levels_of_nesting(): void - { - $recorder = new InMemorySpanRecorder(); - $merchants = new class () { - #[CommandHandler('merchant.create')] - public function create(string $merchantId, EventBus $eventBus): void - { - $eventBus->publishWithRouting('merchant.created', $merchantId); - } - }; - $users = $this->users(); - $subscriber = $this->merchantSubscriber(); - - EcotoneLite::bootstrapFlowTesting( - [$merchants::class, $users::class, $subscriber::class], - [$merchants, $users, $subscriber, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ) - ->sendCommandWithRouting('merchant.create', '1'); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send command'], - 'children' => [ - [ - 'details' => ['name' => 'process class@anonymous::create'], - 'children' => [ - [ - 'details' => ['name' => 'send event'], - 'children' => [ - [ - 'details' => ['name' => 'route merchant.to.user'], - 'children' => [ - [ - 'details' => ['name' => 'process class@anonymous::merchantToUser'], - 'children' => [ - [ - 'details' => ['name' => 'send command'], - 'children' => [ - ['details' => ['name' => 'process class@anonymous::register'], 'children' => []], - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - } - - public function test_every_span_of_one_flow_shares_the_trace_id_of_its_root(): void - { - $recorder = new InMemorySpanRecorder(); - $users = $this->users(); - $subscriber = $this->merchantSubscriber(); - - EcotoneLite::bootstrapFlowTesting( - [$users::class, $subscriber::class], - [$users, $subscriber, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ) - ->publishEventWithRouting('merchant.created', '1'); - - $traceIds = array_unique(array_map(fn (Span $span): string => $span->traceId, $recorder->recordedSpans())); - $this->assertCount(1, $traceIds); - $this->assertCount(1, array_filter($recorder->recordedSpans(), fn (Span $span): bool => $span->isRoot())); - } - - public function test_separate_traces(): void - { - $recorder = new InMemorySpanRecorder(); - $users = $this->users(); - - EcotoneLite::bootstrapFlowTesting( - [$users::class], - [$users, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ) - ->sendCommandWithRouting('user.register', '1', metadata: ['flowId' => '1']) - ->sendCommandWithRouting('user.register', '2', metadata: ['flowId' => '2']); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send command', 'attributes' => ['flowId' => '1']], - 'children' => [ - ['details' => ['name' => 'process class@anonymous::register', 'attributes' => ['flowId' => '1']], 'children' => []], - ], - ], - [ - 'details' => ['name' => 'send command', 'attributes' => ['flowId' => '2']], - 'children' => [ - ['details' => ['name' => 'process class@anonymous::register', 'attributes' => ['flowId' => '2']], 'children' => []], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - $traceIds = array_unique(array_map(fn (Span $span): string => $span->traceId, $recorder->recordedSpans())); - $this->assertCount(2, $traceIds); - } - - public function test_user_land_metadata_is_skipped_when_it_is_not_scalar(): void - { - $recorder = new InMemorySpanRecorder(); - $users = $this->users(); - - EcotoneLite::bootstrapFlowTesting( - [$users::class], - [$users, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ) - ->sendCommandWithRouting('user.register', '1', metadata: ['tokens' => ['123'], 'user' => new stdClass(), 'user_id' => '123']); - - $node = self::getNodeAtTargetedSpan(['details' => ['name' => 'send command']], self::buildTree($recorder->recordedSpans())); - - $this->assertArrayNotHasKey('user', $node['details']['attributes']); - $this->assertArrayNotHasKey('tokens', $node['details']['attributes']); - $this->assertSame('123', $node['details']['attributes']['user_id']); - } - - public function test_spans_carry_message_identity_and_a_child_message_points_at_its_parent(): void - { - $recorder = new InMemorySpanRecorder(); - $users = $this->users(); - $subscriber = $this->merchantSubscriber(); - $messageId = Uuid::v7()->toRfc4122(); - $correlationId = Uuid::v7()->toRfc4122(); - $timestamp = 1680436648; - - EcotoneLite::bootstrapFlowTesting( - [$users::class, $subscriber::class], - [$users, $subscriber, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ) - ->publishEventWithRouting('merchant.created', '1', metadata: [ - MessageHeaders::MESSAGE_ID => $messageId, - MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId, - MessageHeaders::TIMESTAMP => $timestamp, - ]); - - $spans = $recorder->recordedSpans(); - $this->assertCount(5, $spans); - - $this->assertChildSpan('process class@anonymous::register', $spans[0], $messageId, $correlationId); - $this->assertChildSpan('send command', $spans[1], $messageId, $correlationId); - $this->assertSpanWith('process class@anonymous::merchantToUser', $spans[2], $messageId, $correlationId, $timestamp); - $this->assertSpanWith('route merchant.to.user', $spans[3], $messageId, $correlationId, $timestamp); - $this->assertSpanWith('send event', $spans[4], $messageId, $correlationId, $timestamp); - } - - public function test_a_failing_handler_marks_its_span_and_the_bus_span_as_failed_with_the_exception_recorded(): void - { - $recorder = new InMemorySpanRecorder(); - $users = new class () { - #[CommandHandler('user.register')] - public function register(string $userId): void - { - throw new InvalidArgumentException('User already registered'); - } - }; - $ecotone = EcotoneLite::bootstrapFlowTesting( - [$users::class], - [$users, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ); - - try { - $ecotone->sendCommandWithRouting('user.register', '1'); - $this->fail('Expected the handler failure to propagate'); - } catch (InvalidArgumentException) { - } - - $spans = $recorder->recordedSpans(); - $this->assertCount(2, $spans); - foreach ($spans as $span) { - $this->assertSame(SpanStatus::Error, $span->status); - $this->assertSame('User already registered', $span->statusDescription); - $exceptionEvents = array_values(array_filter($span->events, fn ($event): bool => $event->name === 'exception')); - $this->assertCount(1, $exceptionEvents); - $this->assertSame(InvalidArgumentException::class, $exceptionEvents[0]->attributes['exception.type']); - $this->assertSame('User already registered', $exceptionEvents[0]->attributes['exception.message']); - } - } - - public function test_query_bus_and_query_handler_are_traced(): void - { - $recorder = new InMemorySpanRecorder(); - $users = $this->users(); - - EcotoneLite::bootstrapFlowTesting( - [$users::class], - [$users, ...self::recordingSpansInto($recorder)], - [self::tracingConfiguration()], - ) - ->sendQueryWithRouting('user.get'); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send query'], - 'children' => [ - ['details' => ['name' => 'process class@anonymous::isRegistered'], 'children' => []], - ], - ], - ], - self::buildTree($recorder->recordedSpans()) - ); - } - - private function users(): object - { - return new class () { - #[CommandHandler('user.register')] - public function register(string $userId): void - { - } - - #[QueryHandler('user.get')] - public function isRegistered(): bool - { - return true; - } - }; - } - - private function merchantSubscriber(): object - { - return new class () { - #[EventHandler('merchant.created', endpointId: 'merchant.to.user')] - public function merchantToUser(string $merchantId, CommandBus $commandBus): void - { - $commandBus->sendWithRouting('user.register', $merchantId); - } - }; - } - - private function assertSpanWith(string $expectedName, Span $span, string $messageId, string $correlationId, int $timestamp): void - { - $this->assertSame($expectedName, $span->name); - $this->assertSame($messageId, $span->attributes[MessageHeaders::MESSAGE_ID]); - $this->assertSame($correlationId, $span->attributes[MessageHeaders::MESSAGE_CORRELATION_ID]); - $this->assertSame($timestamp, $span->attributes[MessageHeaders::TIMESTAMP]); - $this->assertSame(SpanStatus::Ok, $span->status); - } - - private function assertChildSpan(string $expectedName, Span $span, string $messageId, string $correlationId): void - { - $this->assertSame($expectedName, $span->name); - $this->assertNotSame($messageId, $span->attributes[MessageHeaders::MESSAGE_ID]); - $this->assertSame($messageId, $span->attributes[MessageHeaders::PARENT_MESSAGE_ID]); - $this->assertSame($correlationId, $span->attributes[MessageHeaders::MESSAGE_CORRELATION_ID]); - } -} diff --git a/packages/OpenTelemetry/src/Configuration/OpenTelemetryModule.php b/packages/OpenTelemetry/src/Configuration/OpenTelemetryModule.php index f64bafb6f2..31e26334c0 100644 --- a/packages/OpenTelemetry/src/Configuration/OpenTelemetryModule.php +++ b/packages/OpenTelemetry/src/Configuration/OpenTelemetryModule.php @@ -68,7 +68,6 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO new Reference(SpanRecorder::class), ]) ); - $messagingConfiguration->addCompilerPass(new TraceDynamicConsistencyBoundaryCompilerPass()); $pointcut = ''; if ($openTelemetryConfiguration->forceFlushOnBusExecution) { diff --git a/packages/OpenTelemetry/src/EcotoneSpanBuilder.php b/packages/OpenTelemetry/src/EcotoneSpanBuilder.php deleted file mode 100644 index f9377fa76c..0000000000 --- a/packages/OpenTelemetry/src/EcotoneSpanBuilder.php +++ /dev/null @@ -1,48 +0,0 @@ -getHeaders()->headers()); - - foreach ($userHeaders as $key => $value) { - if (! is_scalar($value)) { - unset($userHeaders[$key]); - } - } - - return $tracerProvider - ->getTracer(self::ECOTONE_TRACER_NAME) - ->spanBuilder($traceName) - ->setSpanKind($type) - ->setAttribute(MessageHeaders::MESSAGE_ID, $context->getHeaders()->getMessageId()) - ->setAttribute(MessageHeaders::MESSAGE_CORRELATION_ID, $context->getHeaders()->getCorrelationId()) - ->setAttribute(MessageHeaders::PARENT_MESSAGE_ID, $context->getHeaders()->getParentId()) - ->setAttribute(MessageHeaders::TIMESTAMP, $context->getHeaders()->getTimestamp()) - ->setAttributes($userHeaders); - } -} diff --git a/packages/OpenTelemetry/src/OpenTelemetryActiveSpan.php b/packages/OpenTelemetry/src/OpenTelemetryActiveSpan.php index 03d8a0ce9f..7c155bfbbc 100644 --- a/packages/OpenTelemetry/src/OpenTelemetryActiveSpan.php +++ b/packages/OpenTelemetry/src/OpenTelemetryActiveSpan.php @@ -27,6 +27,7 @@ final class OpenTelemetryActiveSpan implements ActiveSpan private array $events = []; private bool $ended = false; private bool $unitOfWorkFailed = false; + private bool $childrenStarted = false; /** * @param array $attributes @@ -45,11 +46,32 @@ public function __construct( ) { } + public function name(): string + { + return $this->name; + } + public function attributes(): array { return $this->attributes; } + public function setAttribute(string $key, int|float|string|bool $value): void + { + $this->span->setAttribute($key, $value); + $this->attributes[$key] = $value; + } + + public function childStarted(): void + { + $this->childrenStarted = true; + } + + public function hasStartedChildren(): bool + { + return $this->childrenStarted; + } + public function context(): SpanContext { return $this->context; diff --git a/packages/OpenTelemetry/src/OpenTelemetryTracer.php b/packages/OpenTelemetry/src/OpenTelemetryTracer.php index 06f6839cd5..33d33cf819 100644 --- a/packages/OpenTelemetry/src/OpenTelemetryTracer.php +++ b/packages/OpenTelemetry/src/OpenTelemetryTracer.php @@ -26,6 +26,8 @@ */ final class OpenTelemetryTracer implements Tracer { + public const ECOTONE_TRACER_NAME = 'io.opentelemetry.contrib.php.ecotone'; + /** @var OpenTelemetryActiveSpan[] */ private array $activeSpans = []; @@ -43,6 +45,8 @@ public function startSpan(string $name, SpanKind $kind, array $attributes = [], $spanBuilder->setParent(TraceContextPropagator::getInstance()->extract([MessageHeaders::TRACEPARENT => $remoteParent->toTraceparent(), MessageHeaders::TRACESTATE => $remoteParent->traceState])); } + $this->currentSpan()?->childStarted(); + return $this->start($spanBuilder, $name, $kind, $attributes, $remoteParent ?? $this->currentSpan()?->context()); } @@ -57,7 +61,7 @@ public function startRootSpan(string $name, SpanKind $kind, array $attributes = private function spanBuilderFor(string $name, SpanKind $kind, array $attributes): SpanBuilderInterface { return $this->tracerProvider - ->getTracer(EcotoneSpanBuilder::ECOTONE_TRACER_NAME) + ->getTracer(self::ECOTONE_TRACER_NAME) ->spanBuilder($name) ->setSpanKind(self::kindOf($kind)) ->setAttributes($attributes); diff --git a/packages/OpenTelemetry/src/Test/ExportedSpans.php b/packages/OpenTelemetry/src/Test/ExportedSpans.php new file mode 100644 index 0000000000..23bfd120f2 --- /dev/null +++ b/packages/OpenTelemetry/src/Test/ExportedSpans.php @@ -0,0 +1,62 @@ + + */ + public static function of(InMemoryExporter $exporter): array + { + return array_map(self::asSpan(...), $exporter->getSpans()); + } + + private static function asSpan(SpanDataInterface $exported): Span + { + return new Span( + $exported->getTraceId(), + $exported->getSpanId(), + $exported->getParentContext()->isValid() ? $exported->getParentSpanId() : null, + $exported->getName(), + match ($exported->getKind()) { + OpenTelemetrySpanKind::KIND_SERVER => SpanKind::Server, + OpenTelemetrySpanKind::KIND_CLIENT => SpanKind::Client, + OpenTelemetrySpanKind::KIND_PRODUCER => SpanKind::Producer, + OpenTelemetrySpanKind::KIND_CONSUMER => SpanKind::Consumer, + default => SpanKind::Internal, + }, + $exported->getAttributes()->toArray(), + match ($exported->getStatus()->getCode()) { + StatusCode::STATUS_OK => SpanStatus::Ok, + StatusCode::STATUS_ERROR => SpanStatus::Error, + default => SpanStatus::Unset, + }, + $exported->getStatus()->getDescription(), + array_map( + fn (EventInterface $event) => new SpanEvent($event->getName(), $event->getAttributes()->toArray(), DatePoint::createFromTimestamp($event->getEpochNanos() / 1_000_000_000)), + $exported->getEvents(), + ), + DatePoint::createFromTimestamp($exported->getStartEpochNanos() / 1_000_000_000), + DatePoint::createFromTimestamp($exported->getEndEpochNanos() / 1_000_000_000), + ); + } +} diff --git a/packages/OpenTelemetry/src/TracedDecisionModelBatchLoader.php b/packages/OpenTelemetry/src/TracedDecisionModelBatchLoader.php deleted file mode 100644 index 564f1da9fc..0000000000 --- a/packages/OpenTelemetry/src/TracedDecisionModelBatchLoader.php +++ /dev/null @@ -1,70 +0,0 @@ - - */ - public function load(Message $message): array - { - $span = EcotoneSpanBuilder::create($message, 'Decision Models: ' . $this->handlerName, $this->tracerProvider, SpanKind::KIND_INTERNAL) - ->startSpan(); - $spanScope = $span->activate(); - - try { - $loadedState = $this->batchLoader->load($message); - } catch (Throwable $exception) { - $span->recordException($exception); - self::closeSpan($span, $spanScope, StatusCode::STATUS_ERROR, $exception->getMessage()); - - throw $exception; - } - - self::describeLoad($span, $loadedState[DecisionModelLoadedState::HEADER_NAME]); - self::closeSpan($span, $spanScope, StatusCode::STATUS_OK, null); - - return $loadedState; - } - - private static function describeLoad(SpanInterface $span, DecisionModelLoadedState $loadedState): void - { - $capturedTagVersions = $loadedState->capturedTagVersions(); - - $span->setAttribute(DynamicConsistencyBoundarySpanAttributes::MODELS, DynamicConsistencyBoundarySpanAttributes::classList($loadedState->loadedModelClassNames())); - $span->setAttribute(DynamicConsistencyBoundarySpanAttributes::TAGS, DynamicConsistencyBoundarySpanAttributes::userTags($capturedTagVersions)); - $span->setAttribute(DynamicConsistencyBoundarySpanAttributes::CAPTURED_VERSIONS, DynamicConsistencyBoundarySpanAttributes::capturedVersions($capturedTagVersions)); - $span->setAttribute(DynamicConsistencyBoundarySpanAttributes::AGGREGATES, DynamicConsistencyBoundarySpanAttributes::aggregates($capturedTagVersions)); - $span->setAttribute(DynamicConsistencyBoundarySpanAttributes::EVENTS_FOLDED, $loadedState->foldedEventCount()); - } - - private static function closeSpan(SpanInterface $span, ScopeInterface $spanScope, string $statusCode, ?string $descriptionStatusCode): void - { - $span->setStatus($statusCode, $descriptionStatusCode); - $spanScope->detach(); - $span->end(); - } -} diff --git a/packages/OpenTelemetry/tests/Fixture/CommandEventFlow/CreateMerchant.php b/packages/OpenTelemetry/tests/Fixture/CommandEventFlow/CreateMerchant.php deleted file mode 100644 index 0cf0f848fe..0000000000 --- a/packages/OpenTelemetry/tests/Fixture/CommandEventFlow/CreateMerchant.php +++ /dev/null @@ -1,15 +0,0 @@ -merchantId = $command->merchantId; - - $eventBus->publish(new MerchantCreated($command->merchantId)); - - return $self; - } -} diff --git a/packages/OpenTelemetry/tests/Fixture/CommandEventFlow/MerchantCreated.php b/packages/OpenTelemetry/tests/Fixture/CommandEventFlow/MerchantCreated.php deleted file mode 100644 index c11575fdc3..0000000000 --- a/packages/OpenTelemetry/tests/Fixture/CommandEventFlow/MerchantCreated.php +++ /dev/null @@ -1,15 +0,0 @@ -send(new RegisterUser($event->merchantId)); - } -} diff --git a/packages/OpenTelemetry/tests/Fixture/CommandEventFlow/MerchantSubscriberTwo.php b/packages/OpenTelemetry/tests/Fixture/CommandEventFlow/MerchantSubscriberTwo.php deleted file mode 100644 index a1221a68ca..0000000000 --- a/packages/OpenTelemetry/tests/Fixture/CommandEventFlow/MerchantSubscriberTwo.php +++ /dev/null @@ -1,20 +0,0 @@ -send(new RegisterUser($event->merchantId . '-second')); - } -} diff --git a/packages/OpenTelemetry/tests/Fixture/ExceptionFlow/User.php b/packages/OpenTelemetry/tests/Fixture/ExceptionFlow/User.php deleted file mode 100644 index 77730bdb1f..0000000000 --- a/packages/OpenTelemetry/tests/Fixture/ExceptionFlow/User.php +++ /dev/null @@ -1,27 +0,0 @@ -storage = new ArrayObject(); - } - - protected function doExport(iterable $spans): bool - { - foreach ($spans as $span) { - $this->storage[] = $span; - } - - return true; - } - - public function getSpans(): array - { - return (array) $this->storage; - } - - public function clean(): void - { - $this->storage = new ArrayObject(); - } -} diff --git a/packages/OpenTelemetry/tests/Fixture/MessageHandlerFlow/ExampleMessageHandler.php b/packages/OpenTelemetry/tests/Fixture/MessageHandlerFlow/ExampleMessageHandler.php deleted file mode 100644 index 0c12ad2dee..0000000000 --- a/packages/OpenTelemetry/tests/Fixture/MessageHandlerFlow/ExampleMessageHandler.php +++ /dev/null @@ -1,27 +0,0 @@ -counter += 1; - } - - public function getCounter(): int - { - return $this->counter; - } -} diff --git a/packages/OpenTelemetry/tests/Fixture/ScheduledHandler/WorkflowScheduledHandler.php b/packages/OpenTelemetry/tests/Fixture/ScheduledHandler/WorkflowScheduledHandler.php deleted file mode 100644 index 08d951af69..0000000000 --- a/packages/OpenTelemetry/tests/Fixture/ScheduledHandler/WorkflowScheduledHandler.php +++ /dev/null @@ -1,42 +0,0 @@ -counter += 1; - } - - public function getCounter(): int - { - return $this->counter; - } -} diff --git a/packages/OpenTelemetry/tests/Integration/CorrelatedHeadersPropagationTest.php b/packages/OpenTelemetry/tests/Integration/CorrelatedHeadersPropagationTest.php deleted file mode 100644 index 2ab4ccc64c..0000000000 --- a/packages/OpenTelemetry/tests/Integration/CorrelatedHeadersPropagationTest.php +++ /dev/null @@ -1,244 +0,0 @@ -toRfc4122(); - $correlationId = Uuid::v7()->toRfc4122(); - $timestamp = 1680436648; - - EcotoneLite::bootstrapFlowTesting( - [User::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter)], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - ) - ->sendCommand(new RegisterUser('1'), metadata: [ - MessageHeaders::MESSAGE_ID => $messageId, - MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId, - MessageHeaders::TIMESTAMP => $timestamp, - ]); - - /** @var ImmutableSpan[] $spans */ - $spans = $exporter->getSpans(); - /** Command Bus and Command Handler */ - $this->assertCount(2, $spans); - - $this->assertSpanWith('process User::register', $spans[0], $messageId, $correlationId, $timestamp); - $this->assertSpanWith('send command', $spans[1], $messageId, $correlationId, $timestamp); - } - - public function test_tracing_with_two_levels_of_nesting() - { - $exporter = new InMemoryExporter(new ArrayObject()); - - $messageId = Uuid::v7()->toRfc4122(); - $correlationId = Uuid::v7()->toRfc4122(); - $timestamp = 1680436648; - - EcotoneLite::bootstrapFlowTesting( - [User::class, MerchantSubscriberOne::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter), new MerchantSubscriberOne()], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - ) - ->publishEvent(new MerchantCreated('1'), [ - MessageHeaders::MESSAGE_ID => $messageId, - MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId, - MessageHeaders::TIMESTAMP => $timestamp, - ]); - - /** @var ImmutableSpan[] $spans */ - $spans = $exporter->getSpans(); - $this->assertCount(5, $spans); - - $this->assertChildSpan('process User::register', $spans[0], $messageId, $correlationId); - $this->assertChildSpan('send command', $spans[1], $messageId, $correlationId); - - $this->assertSpanWith('process MerchantSubscriberOne::merchantToUser', $spans[2], $messageId, $correlationId, $timestamp); - $this->assertSpanWith('route merchant.subscriber.one', $spans[3], $messageId, $correlationId, $timestamp); - $this->assertSpanWith('send event', $spans[4], $messageId, $correlationId, $timestamp); - } - - public function test_tracing_with_three_levels_of_nesting() - { - $exporter = new InMemoryExporter(new ArrayObject()); - - $messageId = Uuid::v7()->toRfc4122(); - $correlationId = Uuid::v7()->toRfc4122(); - $timestamp = 1680436648; - - EcotoneLite::bootstrapFlowTesting( - [Merchant::class, User::class, MerchantSubscriberOne::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter), new MerchantSubscriberOne()], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - ) - ->sendCommand(new CreateMerchant('1'), [ - MessageHeaders::MESSAGE_ID => $messageId, - MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId, - MessageHeaders::TIMESTAMP => $timestamp, - ]); - - /** @var ImmutableSpan[] $spans */ - $spans = $exporter->getSpans(); - $this->assertCount(7, $spans); - - $this->assertChildSpan('process User::register', $spans[0], $spans[2]->getAttributes()->get(MessageHeaders::MESSAGE_ID), $correlationId); - $this->assertChildSpan('send command', $spans[1], $spans[2]->getAttributes()->get(MessageHeaders::MESSAGE_ID), $correlationId); - - $this->assertChildSpan('process MerchantSubscriberOne::merchantToUser', $spans[2], $messageId, $correlationId); - $this->assertChildSpan('route merchant.subscriber.one', $spans[3], $messageId, $correlationId); - $this->assertChildSpan('send event', $spans[4], $messageId, $correlationId); - - $this->assertSpanWith('process Merchant::create', $spans[5], $messageId, $correlationId, $timestamp); - $this->assertSpanWith('send command', $spans[6], $messageId, $correlationId, $timestamp); - } - - public function test_tracing_same_message_for_asynchronous_scenario() - { - $exporter = new InMemoryExporter(new ArrayObject()); - - $messageId = Uuid::v7()->toRfc4122(); - $correlationId = Uuid::v7()->toRfc4122(); - $timestamp = 1680436648; - - EcotoneLite::bootstrapFlowTesting( - [\Test\Ecotone\OpenTelemetry\Fixture\AsynchronousFlow\User::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter)], - extensionObjects: [SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - enabledModules: [ModulePackageList::TRACING_PACKAGE, ], - ) - ->sendCommand(new RegisterUser('1'), [ - MessageHeaders::MESSAGE_ID => $messageId, - MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId, - MessageHeaders::TIMESTAMP => $timestamp, - ]) - ->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()); - - $node = $this->getNodeAtTargetedSpan( - [ - 'details' => ['name' => 'send command'], - 'child' => [ - 'details' => ['name' => 'send async_channel'], - 'child' => [ - 'details' => ['name' => 'receive async_channel'], - ], - ], - ], - self::buildTree($exporter) - ); - - $this->assertSame( - $correlationId, - $node['details']['attributes'][MessageHeaders::MESSAGE_CORRELATION_ID] - ); - $this->assertSame( - $correlationId, - $node['children'][array_key_first($node['children'])]['details']['attributes'][MessageHeaders::MESSAGE_CORRELATION_ID] - ); - $this->assertSame( - $messageId, - $node['details']['attributes'][MessageHeaders::MESSAGE_ID] - ); - $this->assertSame( - $messageId, - $node['children'][array_key_first($node['children'])]['details']['attributes'][MessageHeaders::MESSAGE_ID] - ); - } - - public function test_tracing_with_exception() - { - $exporter = new InMemoryExporter(new ArrayObject()); - - $messageId = Uuid::v7()->toRfc4122(); - $correlationId = Uuid::v7()->toRfc4122(); - $timestamp = 1680436648; - - try { - EcotoneLite::bootstrapFlowTesting( - [\Test\Ecotone\OpenTelemetry\Fixture\ExceptionFlow\User::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter)], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - ) - ->sendCommand(new RegisterUser('1'), metadata: [ - MessageHeaders::MESSAGE_ID => $messageId, - MessageHeaders::MESSAGE_CORRELATION_ID => $correlationId, - MessageHeaders::TIMESTAMP => $timestamp, - ]); - } catch (InvalidArgumentException) { - } - - /** @var ImmutableSpan[] $spans */ - $spans = $exporter->getSpans(); - /** Command Bus and Command Handler */ - $this->assertCount(2, $spans); - - /** Command Handler */ - $this->assertSame(StatusCode::STATUS_ERROR, $spans[0]->getStatus()->getCode()); - $event = current(array_filter($spans[0]->getEvents(), fn ($event): bool => $event->getName() === 'exception')); - $this->assertSame('exception', $event->getName()); - $this->assertSame('User already registered', $event->getAttributes()->get('exception.message')); - - /** Command Bus */ - $this->assertSame(StatusCode::STATUS_ERROR, $spans[1]->getStatus()->getCode()); - $event = current(array_filter($spans[1]->getEvents(), fn ($event): bool => $event->getName() === 'exception')); - $this->assertSame('exception', $event->getName()); - $this->assertSame('User already registered', $event->getAttributes()->get('exception.message')); - } - - private function assertSpanWith(string $expectedName, ImmutableSpan $span, string $messageId, string $correlationId, int $timestamp): void - { - $this->assertSame( - $expectedName, - $span->getName() - ); - $attributes = $span->getAttributes()->toArray(); - $this->assertSame($messageId, $attributes[MessageHeaders::MESSAGE_ID]); - $this->assertSame($correlationId, $attributes[MessageHeaders::MESSAGE_CORRELATION_ID]); - $this->assertSame($timestamp, $attributes[MessageHeaders::TIMESTAMP]); - $this->assertSame(StatusCode::STATUS_OK, $span->getStatus()->getCode()); - } - - private function assertChildSpan(string $expectedName, ImmutableSpan $span, string $messageId, string $correlationId): void - { - $this->assertSame( - $expectedName, - $span->getName() - ); - $attributes = $span->getAttributes()->toArray(); - $this->assertNotSame($messageId, $attributes[MessageHeaders::MESSAGE_ID]); - $this->assertSame($messageId, $attributes[MessageHeaders::PARENT_MESSAGE_ID]); - $this->assertSame($correlationId, $attributes[MessageHeaders::MESSAGE_CORRELATION_ID]); - } -} diff --git a/packages/OpenTelemetry/tests/Integration/DynamicConsistencyBoundaryTracingTest.php b/packages/OpenTelemetry/tests/Integration/DynamicConsistencyBoundaryTracingTest.php deleted file mode 100644 index af1323a2a7..0000000000 --- a/packages/OpenTelemetry/tests/Integration/DynamicConsistencyBoundaryTracingTest.php +++ /dev/null @@ -1,250 +0,0 @@ -enrolments(); - $ecotone = $this->bootstrapWithDynamicConsistencyBoundary($exporter, $enrolments); - $ecotone->withEvents([new CourseDefined('course-1', 5)]); - - $ecotone->sendCommand(new EnrolStudent('course-1', 'student-1')); - - $span = $this->spanNamed($exporter, 'Decision Models: ' . $enrolments::class . '::enrol'); - - self::assertSame(SpanKind::KIND_INTERNAL, $span->getKind()); - self::assertSame(CourseCapacity::class, $span->getAttributes()->get(DynamicConsistencyBoundarySpanAttributes::MODELS)); - self::assertSame('course:course-1', $span->getAttributes()->get(DynamicConsistencyBoundarySpanAttributes::TAGS)); - self::assertSame('course:course-1=1', $span->getAttributes()->get(DynamicConsistencyBoundarySpanAttributes::CAPTURED_VERSIONS)); - self::assertSame('', $span->getAttributes()->get(DynamicConsistencyBoundarySpanAttributes::AGGREGATES)); - self::assertSame(1, $span->getAttributes()->get(DynamicConsistencyBoundarySpanAttributes::EVENTS_FOLDED)); - } - - public function test_the_conditional_append_is_a_span_naming_the_stream_and_what_it_wrote(): void - { - $exporter = new InMemoryExporter(); - $ecotone = $this->bootstrapWithDynamicConsistencyBoundary($exporter, $this->enrolments()); - - $ecotone->sendCommand(new EnrolStudent('course-1', 'student-1')); - - $span = $this->spanNamed($exporter, 'Conditional Append: ecotone_event_stream'); - - self::assertSame('course:course-1', $span->getAttributes()->get(DynamicConsistencyBoundarySpanAttributes::TAGS)); - self::assertSame(1, $span->getAttributes()->get(DynamicConsistencyBoundarySpanAttributes::EVENTS_APPENDED)); - self::assertSame(StatusCode::STATUS_OK, $span->getStatus()->getCode()); - } - - public function test_saving_an_in_memory_event_sourced_aggregate_is_a_conditional_append_span(): void - { - $exporter = new InMemoryExporter(); - $ecotone = $this->bootstrapWithAnEventSourcedAggregate($exporter); - - $ecotone->sendCommand(new DefineCourse('course-1', 5)); - - $span = $this->spanNamed($exporter, 'Conditional Append: ecotone_event_stream'); - - self::assertSame('course:course-1', $span->getAttributes()->get(DynamicConsistencyBoundarySpanAttributes::TAGS)); - self::assertSame(1, $span->getAttributes()->get(DynamicConsistencyBoundarySpanAttributes::EVENTS_APPENDED)); - self::assertSame(StatusCode::STATUS_OK, $span->getStatus()->getCode()); - } - - public function test_a_conflict_is_recorded_on_the_append_span_as_an_error_with_a_dcb_conflict_event(): void - { - $exporter = new InMemoryExporter(); - $ecotone = $this->bootstrapWithDynamicConsistencyBoundary($exporter, $this->enrolments()); - - try { - $ecotone->sendCommand(new EnrolStudentTwice('course-1', 'student-1')); - $this->fail('Expected a DecisionModelConcurrencyException'); - } catch (DecisionModelConcurrencyException) { - } - - $span = $this->spanCarryingTheConflictEvent($exporter); - - self::assertSame('Conditional Append: ecotone_event_stream', $span->getName()); - self::assertSame(StatusCode::STATUS_ERROR, $span->getStatus()->getCode()); - - $conflictEvent = $this->eventNamed($span, DynamicConsistencyBoundarySpanAttributes::CONFLICT_EVENT_NAME); - - self::assertSame('course:course-1', $conflictEvent->getAttributes()->get(DecisionModelConcurrencyException::CONFLICT_TAG_FIELD)); - self::assertSame(0, $conflictEvent->getAttributes()->get(DecisionModelConcurrencyException::CONFLICT_EXPECTED_VERSION_FIELD)); - self::assertSame(1, $conflictEvent->getAttributes()->get(DecisionModelConcurrencyException::CONFLICT_CURRENT_VERSION_FIELD)); - self::assertSame(CourseCapacity::class, $conflictEvent->getAttributes()->get(DecisionModelConcurrencyException::CONFLICT_MODEL_FIELD)); - } - - public function test_no_boundary_spans_are_produced_when_the_boundary_is_switched_off(): void - { - $exporter = new InMemoryExporter(); - $ecotone = $this->bootstrapWithoutDynamicConsistencyBoundary($exporter); - - $ecotone->sendCommandWithRouting('enrolment.withoutBoundary', 'course-1'); - - foreach ($this->spanNames($exporter) as $spanName) { - self::assertStringNotContainsString('Decision Models: ', $spanName); - self::assertStringNotContainsString('Conditional Append: ', $spanName); - } - } - - private function spanNamed(InMemoryExporter $exporter, string $spanName): SpanDataInterface - { - /** @var SpanDataInterface $span */ - foreach ($exporter->getSpans() as $span) { - if ($span->getName() === $spanName) { - return $span; - } - } - - self::fail('No span named ' . $spanName . '. Spans: ' . implode(', ', $this->spanNames($exporter))); - } - - private function spanCarryingTheConflictEvent(InMemoryExporter $exporter): SpanDataInterface - { - /** @var SpanDataInterface $span */ - foreach ($exporter->getSpans() as $span) { - foreach ($span->getEvents() as $event) { - if ($event->getName() === DynamicConsistencyBoundarySpanAttributes::CONFLICT_EVENT_NAME) { - return $span; - } - } - } - - self::fail('No span carries a ' . DynamicConsistencyBoundarySpanAttributes::CONFLICT_EVENT_NAME . ' event. Spans: ' . implode(', ', $this->spanNames($exporter))); - } - - private function eventNamed(SpanDataInterface $span, string $eventName): EventInterface - { - foreach ($span->getEvents() as $event) { - if ($event->getName() === $eventName) { - return $event; - } - } - - self::fail('No span event named ' . $eventName . ' on ' . $span->getName()); - } - - /** - * @return string[] - */ - private function spanNames(InMemoryExporter $exporter): array - { - return array_map(static fn (SpanDataInterface $span): string => $span->getName(), $exporter->getSpans()); - } - - private function enrolments(): object - { - return new class () { - #[CommandHandler] - public function enrol(EnrolStudent $command, CourseCapacity $course): array - { - return [new StudentEnrolled($command->courseId, $command->studentId)]; - } - - #[CommandHandler] - public function enrolTwice(EnrolStudentTwice $command, CourseCapacity $course, CommandBus $commandBus): array - { - $commandBus->send(new EnrolStudent($command->courseId, $command->studentId)); - - return [new StudentEnrolled($command->courseId, $command->studentId)]; - } - }; - } - - private function bootstrapWithDynamicConsistencyBoundary(InMemoryExporter $exporter, object $enrolments): FlowTestSupport - { - return EcotoneLite::bootstrapFlowTesting( - classesToResolve: [$enrolments::class, CourseCapacity::class, CourseDefined::class, StudentEnrolled::class], - containerOrAvailableServices: [$enrolments, TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter)], - extensionObjects: [DynamicConsistencyBoundaryConfiguration::createWithDefaults()], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - licenceKey: LicenceTesting::VALID_LICENCE, - ); - } - - private function bootstrapWithAnEventSourcedAggregate(InMemoryExporter $exporter): FlowTestSupport - { - $course = new #[EventSourcingAggregate] #[AggregateType('Course')] class () { - use WithAggregateVersioning; - - #[Identifier] - private string $courseId; - - #[CommandHandler] - public static function define(DefineCourse $command, CourseCapacity $capacity): array - { - return [new CourseDefined($command->courseId, $command->capacity)]; - } - - #[Apply] - public function applyDefined(CourseDefined $event): void - { - $this->courseId = $event->courseId; - } - }; - - return EcotoneLite::bootstrapFlowTesting( - classesToResolve: [$course::class, CourseCapacity::class, CourseDefined::class, StudentEnrolled::class], - containerOrAvailableServices: [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter)], - extensionObjects: [DynamicConsistencyBoundaryConfiguration::createWithDefaults()], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - licenceKey: LicenceTesting::VALID_LICENCE, - ); - } - - private function bootstrapWithoutDynamicConsistencyBoundary(InMemoryExporter $exporter): FlowTestSupport - { - $enrolmentsWithoutBoundary = new class () { - #[CommandHandler('enrolment.withoutBoundary')] - public function enrol(string $courseId): void - { - } - }; - - return EcotoneLite::bootstrapFlowTesting( - classesToResolve: [$enrolmentsWithoutBoundary::class], - containerOrAvailableServices: [$enrolmentsWithoutBoundary, TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter)], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - licenceKey: LicenceTesting::VALID_LICENCE, - ); - } -} diff --git a/packages/OpenTelemetry/tests/Integration/OpenTelemetryFlowConformanceTest.php b/packages/OpenTelemetry/tests/Integration/OpenTelemetryFlowConformanceTest.php new file mode 100644 index 0000000000..6c52cdeed9 --- /dev/null +++ b/packages/OpenTelemetry/tests/Integration/OpenTelemetryFlowConformanceTest.php @@ -0,0 +1,52 @@ +classesToResolve, + [...$scenario->services, TracerProviderInterface::class => self::prepareTracer($exporter)], + $scenario->extensionObjects, + enabledModules: [ModulePackageList::TRACING_PACKAGE], + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + $scopeNotices = []; + set_error_handler(static function (int $errorNumber, string $errorMessage) use (&$scopeNotices): bool { + $scopeNotices[] = $errorMessage; + + return true; + }, E_USER_NOTICE); + try { + foreach (array_keys($scenario->stages) as $index) { + self::assertNull($scenario->runStage($index, $ecotone)); + + self::assertNull($scenario->mismatchAfterStage($index, ExportedSpans::of($exporter))); + } + } finally { + restore_error_handler(); + } + self::assertSame([], $scopeNotices); + } +} diff --git a/packages/OpenTelemetry/tests/Integration/SwallowedFailureTracingTest.php b/packages/OpenTelemetry/tests/Integration/SwallowedFailureTracingTest.php deleted file mode 100644 index b3d5a79858..0000000000 --- a/packages/OpenTelemetry/tests/Integration/SwallowedFailureTracingTest.php +++ /dev/null @@ -1,54 +0,0 @@ - self::prepareTracer($exporter)], - [SimpleMessageChannelBuilder::createQueueChannel('async_channel'), ServiceConfiguration::createWithDefaults()->withDefaultErrorChannel('errorChannel')], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - ) - ->sendCommandWithRouting('order.place', '1') - ->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup(stopOnError: false)); - - $statuses = []; - foreach ($exporter->getSpans() as $span) { - $statuses[$span->getName()] = $span->getStatus()->getCode(); - } - $this->assertSame(StatusCode::STATUS_ERROR, $statuses['receive async_channel']); - $this->assertSame(StatusCode::STATUS_ERROR, $statuses['process ' . substr($orders::class, 0, strpos($orders::class, "\0")) . '::place']); - } -} diff --git a/packages/OpenTelemetry/tests/Integration/TracingScopeCleanupTest.php b/packages/OpenTelemetry/tests/Integration/TracingScopeCleanupTest.php deleted file mode 100644 index 630e198fc6..0000000000 --- a/packages/OpenTelemetry/tests/Integration/TracingScopeCleanupTest.php +++ /dev/null @@ -1,310 +0,0 @@ -bootstrapWithEventForwardedToSecondChannel( - channelsToRegister: [ - SimpleMessageChannelBuilder::createQueueChannel('async_channel'), - ExceptionalQueueChannel::createWithExceptionOnSend('failing_channel'), - ], - ); - - $ecotoneLite->sendCommandWithRouting('user.register', '1'); - - [$caughtException, $scopeNotices] = $this->invokeCapturingScopeNotices( - fn () => $ecotoneLite->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()) - ); - - $this->assertNotNull($caughtException); - $this->assertStringContainsString('Exception on send', $caughtException->getMessage()); - $this->assertSame([], $scopeNotices); - } - - public function test_no_scope_detach_notices_when_channel_interceptor_fails_before_send(): void - { - $throwingChannelInterceptor = new class () extends AbstractChannelInterceptor { - public function preSend(Message $message, MessageChannel $messageChannel): ?Message - { - throw new RuntimeException('Exception before send'); - } - }; - - $ecotoneLite = $this->bootstrapWithEventForwardedToSecondChannel( - channelsToRegister: [ - SimpleMessageChannelBuilder::createQueueChannel('async_channel'), - SimpleMessageChannelBuilder::createQueueChannel('failing_channel'), - SimpleChannelInterceptorBuilder::create('failing_channel', 'throwingChannelInterceptor'), - ], - servicesToRegister: ['throwingChannelInterceptor' => $throwingChannelInterceptor], - ); - - $ecotoneLite->sendCommandWithRouting('user.register', '1'); - - [$caughtException, $scopeNotices] = $this->invokeCapturingScopeNotices( - fn () => $ecotoneLite->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()) - ); - - $this->assertNotNull($caughtException); - $this->assertStringContainsString('Exception before send', $caughtException->getMessage()); - $this->assertSame([], $scopeNotices); - } - - public function test_no_scope_detach_notices_when_message_is_filtered_out_after_tracing_started(): void - { - $filteringChannelInterceptor = new class () extends AbstractChannelInterceptor { - public function preSend(Message $message, MessageChannel $messageChannel): ?Message - { - return null; - } - }; - - $ecotoneLite = $this->bootstrapWithEventForwardedToSecondChannel( - channelsToRegister: [ - SimpleMessageChannelBuilder::createQueueChannel('async_channel'), - SimpleMessageChannelBuilder::createQueueChannel('failing_channel'), - SimpleChannelInterceptorBuilder::create('failing_channel', 'filteringChannelInterceptor'), - ], - servicesToRegister: ['filteringChannelInterceptor' => $filteringChannelInterceptor], - ); - - $ecotoneLite->sendCommandWithRouting('user.register', '1'); - - [$caughtException, $scopeNotices] = $this->invokeCapturingScopeNotices( - fn () => $ecotoneLite->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()) - ); - - $this->assertNull($caughtException); - $this->assertSame([], $scopeNotices); - } - - public function test_no_scope_detach_notices_when_cleanup_of_another_interceptor_fails_after_send_failure(): void - { - $throwingCleanupInterceptor = new class () extends AbstractChannelInterceptor { - public function afterSendCompletion(Message $message, MessageChannel $messageChannel, ?Throwable $exception): bool - { - if ($exception !== null) { - throw new RuntimeException('Exception during cleanup'); - } - - return false; - } - }; - - $ecotoneLite = $this->bootstrapWithEventForwardedToSecondChannel( - channelsToRegister: [ - SimpleMessageChannelBuilder::createQueueChannel('async_channel'), - ExceptionalQueueChannel::createWithExceptionOnSend('failing_channel'), - SimpleChannelInterceptorBuilder::create('failing_channel', 'throwingCleanupInterceptor')->withPrecedence(500), - ], - servicesToRegister: ['throwingCleanupInterceptor' => $throwingCleanupInterceptor], - ); - - $ecotoneLite->sendCommandWithRouting('user.register', '1'); - - [$caughtException, $scopeNotices] = $this->invokeCapturingScopeNotices( - fn () => $ecotoneLite->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()) - ); - - $this->assertNotNull($caughtException); - $this->assertSame([], $scopeNotices); - } - - public function test_no_scope_detach_notices_when_post_send_of_another_interceptor_fails(): void - { - $throwingPostSendInterceptor = new class () extends AbstractChannelInterceptor { - public function postSend(Message $message, MessageChannel $messageChannel): void - { - throw new RuntimeException('Exception after send'); - } - }; - - $ecotoneLite = $this->bootstrapWithEventForwardedToSecondChannel( - channelsToRegister: [ - SimpleMessageChannelBuilder::createQueueChannel('async_channel'), - SimpleMessageChannelBuilder::createQueueChannel('failing_channel'), - SimpleChannelInterceptorBuilder::create('failing_channel', 'throwingPostSendInterceptor')->withPrecedence(500), - ], - servicesToRegister: ['throwingPostSendInterceptor' => $throwingPostSendInterceptor], - ); - - $ecotoneLite->sendCommandWithRouting('user.register', '1'); - - [$caughtException, $scopeNotices] = $this->invokeCapturingScopeNotices( - fn () => $ecotoneLite->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()) - ); - - $this->assertNotNull($caughtException); - $this->assertStringContainsString('Exception after send', $caughtException->getMessage()); - $this->assertSame([], $scopeNotices); - } - - public function test_no_scope_detach_notices_when_interceptor_replaces_message_dropping_framework_headers(): void - { - $rebuildingChannelInterceptor = new class () extends AbstractChannelInterceptor { - public function preSend(Message $message, MessageChannel $messageChannel): ?Message - { - return StaticMessageBuilder::withPayload($message->getPayload())->build(); - } - }; - - $ecotoneLite = $this->bootstrapWithEventForwardedToSecondChannel( - channelsToRegister: [ - SimpleMessageChannelBuilder::createQueueChannel('async_channel'), - SimpleMessageChannelBuilder::createQueueChannel('failing_channel'), - SimpleChannelInterceptorBuilder::create('failing_channel', 'rebuildingChannelInterceptor'), - ], - servicesToRegister: ['rebuildingChannelInterceptor' => $rebuildingChannelInterceptor], - ); - - $ecotoneLite->sendCommandWithRouting('user.register', '1'); - - [$caughtException, $scopeNotices] = $this->invokeCapturingScopeNotices( - fn () => $ecotoneLite->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()) - ); - - $this->assertNull($caughtException); - $this->assertSame([], $scopeNotices); - } - - public function test_no_scope_detach_notices_when_error_channel_send_fails(): void - { - $userService = new class () { - #[Asynchronous('async_channel')] - #[CommandHandler('user.register', endpointId: 'userRegisterEndpoint')] - public function register(string $userId): void - { - throw new RuntimeException('Handler failure'); - } - }; - - $ecotoneLite = EcotoneLite::bootstrapFlowTesting( - [$userService::class], - [$userService, TracerProviderInterface::class => TracingTestCase::prepareTracer(new InMemoryExporter())], - extensionObjects: [ - SimpleMessageChannelBuilder::createQueueChannel('async_channel'), - ExceptionalQueueChannel::createWithExceptionOnSend('error_channel'), - ServiceConfiguration::createWithDefaults()->withDefaultErrorChannel('error_channel'), - ], - enabledModules: [ModulePackageList::TRACING_PACKAGE, ], - ); - - $ecotoneLite->sendCommandWithRouting('user.register', '1'); - - [, $scopeNotices] = $this->invokeCapturingScopeNotices( - fn () => $ecotoneLite->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup(stopOnError: false)) - ); - - $this->assertSame([], $scopeNotices); - } - - public function test_no_scope_detach_notices_when_distributed_bus_send_fails(): void - { - $ecotoneLite = EcotoneLite::bootstrapFlowTesting( - [], - [TracerProviderInterface::class => TracingTestCase::prepareTracer(new InMemoryExporter())], - extensionObjects: [ - ExceptionalQueueChannel::createWithExceptionOnSend('distributed_channel'), - DistributedServiceMap::initialize()->withCommandMapping(targetServiceName:'ticket_service', channelName:'distributed_channel'), - ServiceConfiguration::createWithDefaults()->withServiceName('user_service'), - ], - enabledModules: [ModulePackageList::TRACING_PACKAGE, ], - licenceKey: LicenceTesting::VALID_LICENCE, - ); - - [$caughtException, $scopeNotices] = $this->invokeCapturingScopeNotices( - fn () => $ecotoneLite->getDistributedBus()->sendCommand('ticket_service', 'ticket.create', 'User changed billing address') - ); - - $this->assertNotNull($caughtException); - $this->assertStringContainsString('Exception on send', $caughtException->getMessage()); - $this->assertSame([], $scopeNotices); - } - - private function bootstrapWithEventForwardedToSecondChannel(array $channelsToRegister, array $servicesToRegister = []): FlowTestSupport - { - $userService = new class () { - #[Asynchronous('async_channel')] - #[CommandHandler('user.register', endpointId: 'userRegisterEndpoint')] - public function register(string $userId, EventBus $eventBus): void - { - $eventBus->publishWithRouting('user.registered', $userId); - } - }; - $userNotifier = new class () { - #[Asynchronous('failing_channel')] - #[EventHandler('user.registered', endpointId: 'userNotifierEndpoint')] - public function notify(string $userId): void - { - } - }; - - return EcotoneLite::bootstrapFlowTesting( - [$userService::class, $userNotifier::class], - array_merge( - [$userService, $userNotifier, TracerProviderInterface::class => TracingTestCase::prepareTracer(new InMemoryExporter())], - $servicesToRegister, - ), - extensionObjects: $channelsToRegister, - enabledModules: [ModulePackageList::TRACING_PACKAGE, ], - ); - } - - /** - * @return array{0: ?Throwable, 1: string[]} - */ - private function invokeCapturingScopeNotices(callable $action): array - { - $scopeNotices = []; - set_error_handler(static function (int $errorNumber, string $errorMessage) use (&$scopeNotices): bool { - $scopeNotices[] = $errorMessage; - - return true; - }, E_USER_NOTICE); - - $caughtException = null; - try { - $action(); - } catch (Throwable $exception) { - $caughtException = $exception; - } finally { - restore_error_handler(); - } - - return [$caughtException, $scopeNotices]; - } -} diff --git a/packages/OpenTelemetry/tests/Integration/TracingTreeTest.php b/packages/OpenTelemetry/tests/Integration/TracingTreeTest.php index 867ef2c20b..48f69f8055 100644 --- a/packages/OpenTelemetry/tests/Integration/TracingTreeTest.php +++ b/packages/OpenTelemetry/tests/Integration/TracingTreeTest.php @@ -17,22 +17,12 @@ use OpenTelemetry\API\Trace\TracerProviderInterface; use OpenTelemetry\SDK\Common\Log\LoggerHolder; use OpenTelemetry\SDK\Common\Time\ClockFactory; -use OpenTelemetry\SDK\Trace\Event; use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; use OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor; use OpenTelemetry\SDK\Trace\TracerProvider; -use stdClass; use Test\Ecotone\OpenTelemetry\Fixture\AsynchronousFlow\UserNotifier; -use Test\Ecotone\OpenTelemetry\Fixture\CommandEventFlow\CreateMerchant; -use Test\Ecotone\OpenTelemetry\Fixture\CommandEventFlow\Merchant; -use Test\Ecotone\OpenTelemetry\Fixture\CommandEventFlow\MerchantCreated; -use Test\Ecotone\OpenTelemetry\Fixture\CommandEventFlow\MerchantSubscriberOne; -use Test\Ecotone\OpenTelemetry\Fixture\CommandEventFlow\MerchantSubscriberTwo; use Test\Ecotone\OpenTelemetry\Fixture\CommandEventFlow\RegisterUser; use Test\Ecotone\OpenTelemetry\Fixture\CommandEventFlow\User; -use Test\Ecotone\OpenTelemetry\Fixture\MessageHandlerFlow\ExampleMessageHandler; -use Test\Ecotone\OpenTelemetry\Fixture\ScheduledHandler\ScheduledHandler; -use Test\Ecotone\OpenTelemetry\Fixture\ScheduledHandler\WorkflowScheduledHandler; /** * @internal @@ -43,143 +33,6 @@ */ final class TracingTreeTest extends TracingTestCase { - public function test_tracing_tree_with_single_levels_of_nesting() - { - $exporter = new InMemoryExporter(new ArrayObject()); - - EcotoneLite::bootstrapFlowTesting( - [User::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter)], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - ) - ->sendCommand(new RegisterUser('1')); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send command'], - 'children' => [ - [ - 'details' => ['name' => 'process User::register'], - 'children' => [], - ], - ], - ], - ], - self::buildTree($exporter) - ); - } - - public function test_tracing_tree_with_two_levels_of_nesting_and_message_handler() - { - $exporter = new InMemoryExporter(new ArrayObject()); - - EcotoneLite::bootstrapFlowTesting( - [ExampleMessageHandler::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter), ExampleMessageHandler::class => new ExampleMessageHandler()], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - ) - ->sendCommandWithRouting('handleCommand'); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send command'], - 'children' => [ - [ - 'details' => ['name' => 'process ExampleMessageHandler::handleCommand'], - 'children' => [], - ], - [ - 'details' => ['name' => 'process ExampleMessageHandler::handle'], - 'children' => [], - ], - ], - ], - ], - self::buildTree($exporter) - ); - } - - public function test_tracing_scheduled_handler() - { - $exporter = new InMemoryExporter(new ArrayObject()); - - EcotoneLite::bootstrapFlowTesting( - [ScheduledHandler::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter), ScheduledHandler::class => new ScheduledHandler()], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - ) - ->run('scheduled_handler', ExecutionPollingMetadata::createWithDefaults()->withExecutionAmountLimit(2)); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'receive nullChannel'], - 'children' => [], - ], - [ - 'details' => ['name' => 'receive nullChannel'], - 'children' => [], - ], - ], - self::buildTree($exporter) - ); - } - - public function test_tracing_workflow_scheduled_handler() - { - $exporter = new InMemoryExporter(new ArrayObject()); - - EcotoneLite::bootstrapFlowTesting( - [WorkflowScheduledHandler::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter), WorkflowScheduledHandler::class => new WorkflowScheduledHandler()], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - ) - ->run('scheduled_handler', ExecutionPollingMetadata::createWithDefaults()->withExecutionAmountLimit(2)); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'receive add'], - 'children' => [ - [ - 'details' => ['name' => 'process WorkflowScheduledHandler::add'], - 'children' => [], - ], - ], - ], - [ - 'details' => ['name' => 'receive add'], - 'children' => [ - [ - 'details' => ['name' => 'process WorkflowScheduledHandler::add'], - 'children' => [], - ], - ], - ], - ], - self::buildTree($exporter) - ); - } - - public function test_no_tracing_when_there_is_nothing_to_trace() - { - $exporter = new InMemoryExporter(new ArrayObject()); - - EcotoneLite::bootstrapFlowTesting( - [WorkflowScheduledHandler::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter), WorkflowScheduledHandler::class => new WorkflowScheduledHandler()], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - ) - ->run('scheduled_handler_without_result', ExecutionPollingMetadata::createWithDefaults()->withExecutionAmountLimit(1)); - - $this->assertEquals( - [], - self::buildTree($exporter) - ); - } - public function test_disabling_force_flushing_traces() { $exporter = new InMemoryExporter(new ArrayObject()); @@ -278,433 +131,6 @@ public function test_traces_are_force_flushed_when_synchronous_exception_happens $this->assertContains('exception', array_map(fn ($event): string => $event->getName(), $node['details']['events'])); } - public function test_separate_traces() - { - $exporter = new InMemoryExporter(new ArrayObject()); - - EcotoneLite::bootstrapFlowTesting( - [User::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter)], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - ) - ->sendCommand(new RegisterUser('1'), ['flowId' => '1']) - ->sendCommand(new RegisterUser('2'), ['flowId' => '2']); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send command', 'attributes' => ['flowId' => '1']], - 'children' => [ - [ - 'details' => ['name' => 'process User::register', 'attributes' => ['flowId' => '1']], - 'children' => [], - ], - ], - ], - [ - 'details' => ['name' => 'send command', 'attributes' => ['flowId' => '2']], - 'children' => [ - [ - 'details' => ['name' => 'process User::register', 'attributes' => ['flowId' => '2']], - 'children' => [], - ], - ], - ], - ], - self::buildTree($exporter) - ); - } - - public function test_tracing_with_two_levels_of_nesting() - { - $exporter = new InMemoryExporter(new ArrayObject()); - - EcotoneLite::bootstrapFlowTesting( - [User::class, MerchantSubscriberOne::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter), new MerchantSubscriberOne()], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - ) - ->publishEvent(new MerchantCreated('1')); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send event'], - 'children' => [ - [ - 'details' => ['name' => 'route merchant.subscriber.one'], - 'children' => [ - [ - 'details' => ['name' => 'process MerchantSubscriberOne::merchantToUser'], - 'children' => [ - [ - 'details' => ['name' => 'send command'], - 'children' => [ - [ - 'details' => ['name' => 'process User::register'], - 'children' => [], - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - self::buildTree($exporter) - ); - } - - public function test_tracing_with_two_levels_of_nesting_and_two_branches_on_same_level() - { - $exporter = new InMemoryExporter(new ArrayObject()); - - EcotoneLite::bootstrapFlowTesting( - [User::class, MerchantSubscriberOne::class, MerchantSubscriberTwo::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter), new MerchantSubscriberOne(), new MerchantSubscriberTwo()], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - ) - ->publishEvent(new MerchantCreated('1')); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send event'], - 'children' => [ - [ - 'details' => ['name' => 'route merchant.subscriber.one'], - 'children' => [ - [ - 'details' => ['name' => 'process MerchantSubscriberOne::merchantToUser'], - 'children' => [ - [ - 'details' => ['name' => 'send command'], - 'children' => [ - [ - 'details' => ['name' => 'process User::register'], - 'children' => [], - ], - ], - ], - ], - ], - ], - ], - [ - 'details' => ['name' => 'route merchant.subscriber.two'], - 'children' => [ - [ - 'details' => ['name' => 'process MerchantSubscriberTwo::merchantToUser'], - 'children' => [ - [ - 'details' => ['name' => 'send command'], - 'children' => [ - [ - 'details' => ['name' => 'process User::register'], - 'children' => [], - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - self::buildTree($exporter) - ); - } - - public function test_tracing_with_three_levels_of_nesting() - { - $exporter = new InMemoryExporter(new ArrayObject()); - - EcotoneLite::bootstrapFlowTesting( - [Merchant::class, User::class, MerchantSubscriberOne::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter), new MerchantSubscriberOne()], - enabledModules: [ModulePackageList::TRACING_PACKAGE], - ) - ->sendCommand(new CreateMerchant('1')); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send command'], - 'children' => [ - [ - 'details' => ['name' => 'process Merchant::create'], - 'children' => [ - [ - 'details' => ['name' => 'send event'], - 'children' => [ - [ - 'details' => ['name' => 'route merchant.subscriber.one'], - 'children' => [ - [ - 'details' => ['name' => 'process MerchantSubscriberOne::merchantToUser'], - 'children' => [ - [ - 'details' => ['name' => 'send command'], - 'children' => [ - [ - 'details' => ['name' => 'process User::register'], - 'children' => [], - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - ], - self::buildTree($exporter) - ); - } - - public function test_tracing_with_asynchronous_handler() - { - $exporter = new InMemoryExporter(); - - $ecotoneLite = EcotoneLite::bootstrapFlowTesting( - [\Test\Ecotone\OpenTelemetry\Fixture\AsynchronousFlow\User::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter)], - extensionObjects: [SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - enabledModules: [ModulePackageList::TRACING_PACKAGE, ], - ) - ->sendCommand(new RegisterUser('1')); - - $ecotoneLite->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()); - - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send command'], - 'children' => [ - [ - 'details' => ['name' => 'send async_channel'], - 'children' => [ - [ - 'details' => ['name' => 'receive async_channel'], - 'children' => [ - [ - 'details' => ['name' => 'process User::register'], - 'children' => [], - ], - ], - ], - ], - ], - ], - ], - ], - self::buildTree($exporter) - ); - } - - public function test_adding_logs_as_events_in_span() - { - $exporter = new InMemoryExporter(); - - EcotoneLite::bootstrapFlowTesting( - [ - \Test\Ecotone\OpenTelemetry\Fixture\AsynchronousFlow\User::class, - UserNotifier::class, - ], - [ - new UserNotifier(), - TracerProviderInterface::class => self::prepareTracer($exporter), - ], - extensionObjects: [SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - enabledModules: [ModulePackageList::TRACING_PACKAGE, ], - ) - ->sendCommand(new RegisterUser('1')) - ->run('async_channel'); - - $result = self::getNodeAtTargetedSpan( - [ - 'details' => ['name' => 'send command'], - 'child' => [ - 'details' => ['name' => 'send async_channel'], - 'child' => [ - 'details' => ['name' => 'receive async_channel'], - 'child' => [ - 'details' => ['name' => 'send event'], - ], - ], - ], - ], - self::buildTree($exporter) - ); - - $eventBusEvents = array_map(fn (Event $event): string => $event->getName(), $result['details']['events']); - $this->assertNotEmpty(array_filter($eventBusEvents, fn (string $name): bool => str_starts_with($name, 'Publishing Event Message'))); - - $route = $result['children'][array_key_first($result['children'])]; - $this->assertSame('route user.registered', $route['details']['name']); - $routeEvents = array_map(fn (Event $event): string => $event->getName(), $route['details']['events']); - $this->assertNotEmpty(array_filter($routeEvents, fn (string $name): bool => str_starts_with($name, 'Collecting message with id'))); - } - - public function test_two_traces_with_asynchronous_handlers() - { - $exporter = new InMemoryExporter(); - - $ecotoneLite = EcotoneLite::bootstrapFlowTesting( - [\Test\Ecotone\OpenTelemetry\Fixture\AsynchronousFlow\User::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter)], - extensionObjects: [SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - enabledModules: [ModulePackageList::TRACING_PACKAGE, ], - ) - ->sendCommand(new RegisterUser('1'), ['flowId' => '1']) - ->sendCommand(new RegisterUser('2'), ['flowId' => '2']); - - $ecotoneLite->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup(handledMessageLimit: 2)); - - $collectedTree = self::buildTree($exporter); - self::compareTreesByDetails( - [ - [ - 'details' => ['name' => 'send command', 'attributes' => ['flowId' => '1']], - 'children' => [ - [ - 'details' => ['name' => 'send async_channel', 'attributes' => ['flowId' => '1']], - 'children' => [ - [ - 'details' => ['name' => 'receive async_channel', 'attributes' => ['flowId' => '1']], - 'children' => [ - [ - 'details' => ['name' => 'process User::register', 'attributes' => ['flowId' => '1']], - 'children' => [], - ], - ], - ], - ], - ], - ], - ], - [ - 'details' => ['name' => 'send command', 'attributes' => ['flowId' => '2']], - 'children' => [ - [ - 'details' => ['name' => 'send async_channel', 'attributes' => ['flowId' => '2']], - 'children' => [ - [ - 'details' => ['name' => 'receive async_channel', 'attributes' => ['flowId' => '2']], - 'children' => [ - [ - 'details' => ['name' => 'process User::register', 'attributes' => ['flowId' => '2']], - 'children' => [], - ], - ], - ], - ], - ], - ], - ], - ], - $collectedTree - ); - } - - public function test_passing_user_specific_headers() - { - $exporter = new InMemoryExporter(); - - $ecotoneLite = EcotoneLite::bootstrapFlowTesting( - [\Test\Ecotone\OpenTelemetry\Fixture\AsynchronousFlow\User::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter)], - extensionObjects: [SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - enabledModules: [ModulePackageList::TRACING_PACKAGE, ], - ) - ->sendCommand( - new RegisterUser('1'), - [ - 'user_id' => '123', - ] - ); - - $ecotoneLite->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()); - - self::compareTreesByDetails( - [ - [ - 'details' => [ - 'name' => 'send command', - 'attributes' => ['user_id' => '123'], - ], - 'children' => [ - [ - 'details' => [ - 'name' => 'send async_channel', - 'attributes' => ['user_id' => '123'], - ], - 'children' => [ - [ - 'details' => [ - 'name' => 'receive async_channel', - 'attributes' => ['user_id' => '123'], - ], - 'children' => [ - [ - 'details' => [ - 'name' => 'process User::register', - 'attributes' => ['user_id' => '123'], - ], - 'children' => [], - ], - ], - ], - ], - ], - ], - ], - ], - self::buildTree($exporter) - ); - } - - public function test_user_land_metadata_should_be_skipped_in_case_is_not_scalar(): void - { - $exporter = new InMemoryExporter(new ArrayObject()); - - EcotoneLite::bootstrapFlowTesting( - [\Test\Ecotone\OpenTelemetry\Fixture\AsynchronousFlow\User::class], - [TracerProviderInterface::class => TracingTestCase::prepareTracer($exporter)], - extensionObjects: [SimpleMessageChannelBuilder::createQueueChannel('async_channel')], - enabledModules: [ModulePackageList::TRACING_PACKAGE, ], - ) - ->sendCommand(new RegisterUser('1'), [ - 'tokens' => ['123'], - 'user' => new stdClass(), - ]) - ->run('async_channel', ExecutionPollingMetadata::createWithTestingSetup()); - - $node = $this->getNodeAtTargetedSpan( - [ - 'details' => ['name' => 'send command'], - ], - self::buildTree($exporter) - ); - - $this->assertArrayNotHasKey( - 'user', - $node['details']['attributes'] - ); - $this->assertArrayNotHasKey( - 'tokens', - $node['details']['attributes'] - ); - } - public function MANUAL_test_jaeger_command_event_command_flow() { // LoggerHolder::set(new Logger('otlp-example', [new StreamHandler('php://stderr')]));