diff --git a/.github/workflows/benchmark-pr.yml b/.github/workflows/benchmark-pr.yml index fa7a831b5..68f4a334c 100644 --- a/.github/workflows/benchmark-pr.yml +++ b/.github/workflows/benchmark-pr.yml @@ -19,6 +19,23 @@ jobs: php-versions: [ 8.5 ] stability: [prefer-stable] services: + kafka: + image: apache/kafka:3.9.0 + options: >- + --env KAFKA_NODE_ID=0 + --env KAFKA_PROCESS_ROLES=broker,controller + --env KAFKA_CONTROLLER_QUORUM_VOTERS=0@127.0.0.1:9093 + --env KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER + --env KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092,CONTROLLER://:9093 + --env KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://127.0.0.1:9092 + --env KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT + --env KAFKA_AUTO_CREATE_TOPICS_ENABLE=true + --env KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 + --env KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 + --env KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1 + --env KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS=0 + ports: + - 9092:9092 rabbitmq: image: rabbitmq:4.1.4-management-alpine env: @@ -54,9 +71,11 @@ jobs: ports: - 5432:5432 localstack: - image: localstack/localstack:3.0.0 + image: localstack/localstack:3.8 env: SERVICES: 'sqs,sns' + SQS_DISABLE_CLOUDWATCH_METRICS: '1' + EAGER_SERVICE_LOADING: '1' ports: - "4566:4566" - "4510-4559:4510-4559" @@ -66,6 +85,7 @@ jobs: - '6379:6379' env: RABBIT_HOST: amqp://127.0.0.1:5672 + KAFKA_DSN: 127.0.0.1:9092 SQS_DSN: sqs:?key=key&secret=secret®ion=us-east-1&endpoint=http://127.0.0.1:4566&version=latest REDIS_DSN: redis://127.0.0.1:6379 DATABASE_DSN: pgsql://ecotone:secret@127.0.0.1:5432/ecotone diff --git a/.github/workflows/quickstart-examples.yml b/.github/workflows/quickstart-examples.yml index e7a21a18a..573774c38 100644 --- a/.github/workflows/quickstart-examples.yml +++ b/.github/workflows/quickstart-examples.yml @@ -75,9 +75,11 @@ jobs: ports: - 5432:5432 localstack: - image: localstack/localstack:3.0.0 + image: localstack/localstack:3.8 env: SERVICES: 'sqs,sns' + SQS_DISABLE_CLOUDWATCH_METRICS: '1' + EAGER_SERVICE_LOADING: '1' ports: - "4566:4566" - "4510-4559:4510-4559" diff --git a/.github/workflows/split-testing.yml b/.github/workflows/split-testing.yml index 856127d0f..92cc954ea 100644 --- a/.github/workflows/split-testing.yml +++ b/.github/workflows/split-testing.yml @@ -78,9 +78,11 @@ jobs: ports: - 5432:5432 localstack: - image: localstack/localstack:3.0.0 + image: localstack/localstack:3.8 env: SERVICES: 'sqs,sns' + SQS_DISABLE_CLOUDWATCH_METRICS: '1' + EAGER_SERVICE_LOADING: '1' ports: - "4566:4566" - "4510-4559:4510-4559" diff --git a/.github/workflows/test-monorepo.yml b/.github/workflows/test-monorepo.yml index f7e0f243d..765c3615d 100644 --- a/.github/workflows/test-monorepo.yml +++ b/.github/workflows/test-monorepo.yml @@ -71,9 +71,11 @@ jobs: ports: - 5432:5432 localstack: - image: localstack/localstack:3.0.0 + image: localstack/localstack:3.8 env: SERVICES: 'sqs,sns' + SQS_DISABLE_CLOUDWATCH_METRICS: '1' + EAGER_SERVICE_LOADING: '1' ports: - "4566:4566" - "4510-4559:4510-4559" diff --git a/Monorepo/Benchmark/AsyncPublishingBenchmark.php b/Monorepo/Benchmark/AsyncPublishingBenchmark.php new file mode 100644 index 000000000..63a00ca13 --- /dev/null +++ b/Monorepo/Benchmark/AsyncPublishingBenchmark.php @@ -0,0 +1,479 @@ +publisher = $this->bootstrapAmqpPublisher(asyncPublishing: false); + $this->warmUpPublisher(); + } + + public function setUpAmqpAsyncPublishing(): void + { + $this->publisher = $this->bootstrapAmqpPublisher(asyncPublishing: true); + $this->warmUpPublisher(); + } + + public function setUpAmqpBatchChannel(): void + { + $this->batchChannel = $this->bootstrapBatchChannel( + ModulePackageList::AMQP_PACKAGE, + AmqpBackedMessageChannelBuilder::create(uniqid('benchmark_orders_'))->withAsyncPublishing(), + [AmqpConnectionFactory::class => new AmqpConnectionFactory(['dsn' => getenv('RABBIT_HOST') ?: 'amqp://guest:guest@localhost:5672/%2f'])], + ); + $this->warmUpBatchChannel(); + } + + public function setUpKafkaSynchronousPublishing(): void + { + $this->publisher = $this->bootstrapKafkaPublisher(asyncPublishing: false); + $this->warmUpPublisher(); + } + + public function setUpKafkaAsyncPublishing(): void + { + $this->publisher = $this->bootstrapKafkaPublisher(asyncPublishing: true); + $this->warmUpPublisher(); + } + + public function setUpKafkaBatchChannel(): void + { + $uniqueId = uniqid('benchmark_orders_'); + $this->batchChannel = $this->bootstrapBatchChannel( + ModulePackageList::KAFKA_PACKAGE, + KafkaMessageChannelBuilder::create($uniqueId, topicName: $uniqueId, messageGroupId: $uniqueId)->withAsyncPublishing(), + [KafkaBrokerConfiguration::class => KafkaBrokerConfiguration::createWithDefaults([getenv('KAFKA_DSN') ?: 'localhost:9094'])], + ); + $this->warmUpBatchChannel(); + } + + public function setUpDbalSynchronousPublishing(): void + { + $this->publisher = $this->bootstrapDbalPublisher(); + $this->warmUpPublisher(); + } + + public function setUpDbalBatchChannel(): void + { + $this->batchChannel = $this->bootstrapBatchChannel( + ModulePackageList::DBAL_PACKAGE, + DbalBackedMessageChannelBuilder::create(uniqid('benchmark_orders_'))->withAsyncPublishing(), + [DbalConnectionFactory::class => new DbalConnectionFactory(getenv('DATABASE_DSN') ?: 'pgsql://ecotone:secret@localhost:5432/ecotone')], + ); + $this->warmUpBatchChannel(); + } + + public function setUpRedisSynchronousPublishing(): void + { + $this->publisher = $this->bootstrapRedisPublisher(); + $this->warmUpPublisher(); + } + + public function setUpRedisBatchChannel(): void + { + $this->batchChannel = $this->bootstrapBatchChannel( + ModulePackageList::REDIS_PACKAGE, + RedisBackedMessageChannelBuilder::create(uniqid('benchmark_orders_'))->withAsyncPublishing(), + [RedisConnectionFactory::class => new RedisConnectionFactory(getenv('REDIS_DSN') ?: 'redis://localhost:6379')], + ); + $this->warmUpBatchChannel(); + } + + public function setUpSqsSynchronousPublishing(): void + { + $this->publisher = $this->bootstrapSqsPublisher(asyncPublishing: false); + $this->warmUpPublisher(); + } + + public function setUpSqsAsyncPublishing(): void + { + $this->publisher = $this->bootstrapSqsPublisher(asyncPublishing: true); + $this->warmUpPublisher(); + } + + public function setUpSqsBatchChannel(): void + { + $this->batchChannel = $this->bootstrapBatchChannel( + ModulePackageList::SQS_PACKAGE, + SqsBackedMessageChannelBuilder::create(uniqid('benchmark_orders_'))->withAsyncPublishing(), + [SqsConnectionFactory::class => new SqsConnectionFactory(getenv('SQS_DSN') ?: 'sqs:?key=key&secret=secret®ion=us-east-1&endpoint=http://localhost:4566&version=latest')], + ); + $this->warmUpBatchChannel(); + } + + #[BeforeMethods('setUpAmqpSynchronousPublishing')] + public function bench_amqp_single_message_synchronous(): void + { + $this->publishSynchronouslyOneByOne(); + } + + #[BeforeMethods('setUpAmqpAsyncPublishing')] + public function bench_amqp_single_message_asynchronous(): void + { + $this->publishAsynchronouslyOneByOne(); + } + + #[BeforeMethods('setUpAmqpBatchChannel')] + public function bench_amqp_batch_message_synchronous(): void + { + $this->publishBatchSynchronously(); + } + + #[BeforeMethods('setUpAmqpAsyncPublishing')] + public function bench_amqp_batch_message_asynchronous(): void + { + $this->publishBatchAsynchronously(); + } + + #[BeforeMethods('setUpAmqpBatchChannel')] + public function bench_amqp_multiple_batches_synchronous(): void + { + $this->publishMultipleBatchesSynchronously(); + } + + #[BeforeMethods('setUpAmqpAsyncPublishing')] + public function bench_amqp_multiple_batches_asynchronous(): void + { + $this->publishMultipleBatchesAsynchronously(); + } + + #[BeforeMethods('setUpKafkaSynchronousPublishing')] + public function bench_kafka_single_message_synchronous(): void + { + $this->publishSynchronouslyOneByOne(); + } + + #[BeforeMethods('setUpKafkaAsyncPublishing')] + public function bench_kafka_single_message_asynchronous(): void + { + $this->publishAsynchronouslyOneByOne(); + } + + #[BeforeMethods('setUpKafkaBatchChannel')] + public function bench_kafka_batch_message_synchronous(): void + { + $this->publishBatchSynchronously(); + } + + #[BeforeMethods('setUpKafkaAsyncPublishing')] + public function bench_kafka_batch_message_asynchronous(): void + { + $this->publishBatchAsynchronously(); + } + + #[BeforeMethods('setUpKafkaBatchChannel')] + public function bench_kafka_multiple_batches_synchronous(): void + { + $this->publishMultipleBatchesSynchronously(); + } + + #[BeforeMethods('setUpKafkaAsyncPublishing')] + public function bench_kafka_multiple_batches_asynchronous(): void + { + $this->publishMultipleBatchesAsynchronously(); + } + + #[BeforeMethods('setUpDbalSynchronousPublishing')] + public function bench_dbal_single_message_synchronous(): void + { + $this->publishSynchronouslyOneByOne(); + } + + #[BeforeMethods('setUpDbalBatchChannel')] + public function bench_dbal_batch_message_synchronous(): void + { + $this->publishBatchSynchronously(); + } + + #[BeforeMethods('setUpRedisSynchronousPublishing')] + public function bench_redis_single_message_synchronous(): void + { + $this->publishSynchronouslyOneByOne(); + } + + #[BeforeMethods('setUpRedisBatchChannel')] + public function bench_redis_batch_message_synchronous(): void + { + $this->publishBatchSynchronously(); + } + + #[BeforeMethods('setUpSqsSynchronousPublishing')] + public function bench_sqs_single_message_synchronous(): void + { + $this->publishSynchronouslyOneByOne(); + } + + #[BeforeMethods('setUpSqsAsyncPublishing')] + public function bench_sqs_single_message_asynchronous(): void + { + $this->publishAsynchronouslyOneByOne(); + } + + #[BeforeMethods('setUpSqsBatchChannel')] + public function bench_sqs_batch_message_synchronous(): void + { + $this->publishBatchSynchronously(); + } + + #[BeforeMethods('setUpSqsAsyncPublishing')] + public function bench_sqs_batch_message_asynchronous(): void + { + $this->publishBatchAsynchronously(); + } + + #[BeforeMethods('setUpSqsBatchChannel')] + public function bench_sqs_multiple_batches_synchronous(): void + { + $this->publishMultipleBatchesSynchronously(); + } + + #[BeforeMethods('setUpSqsAsyncPublishing')] + public function bench_sqs_multiple_batches_asynchronous(): void + { + $this->publishMultipleBatchesAsynchronously(); + } + + private function publishSynchronouslyOneByOne(): void + { + for ($messageNumber = 0; $messageNumber < self::AMOUNT_OF_PUBLISHED_MESSAGES; $messageNumber++) { + $this->publisher->send(self::MESSAGE_PAYLOAD); + } + } + + private function publishAsynchronouslyOneByOne(): void + { + $futures = []; + for ($messageNumber = 0; $messageNumber < self::AMOUNT_OF_PUBLISHED_MESSAGES; $messageNumber++) { + $futures[] = $this->publisher->asyncPublish(self::MESSAGE_PAYLOAD, MediaType::TEXT_PLAIN); + } + foreach ($futures as $future) { + $future->resolve(); + } + } + + private function publishBatchSynchronously(): void + { + $this->batchChannel->send( + MessageBuilder::withPayload($this->buildBatch(self::AMOUNT_OF_PUBLISHED_MESSAGES))->build() + ); + } + + private function publishBatchAsynchronously(): void + { + $this->publisher->asyncPublish($this->buildBatch(self::AMOUNT_OF_PUBLISHED_MESSAGES), MediaType::TEXT_PLAIN)->resolve(); + } + + private function publishMultipleBatchesSynchronously(): void + { + for ($batchNumber = 0; $batchNumber < self::AMOUNT_OF_BATCHES; $batchNumber++) { + $this->batchChannel->send( + MessageBuilder::withPayload($this->buildBatch(self::MESSAGES_PER_BATCH))->build() + ); + } + } + + private function publishMultipleBatchesAsynchronously(): void + { + $futures = []; + for ($batchNumber = 0; $batchNumber < self::AMOUNT_OF_BATCHES; $batchNumber++) { + $futures[] = $this->publisher->asyncPublish($this->buildBatch(self::MESSAGES_PER_BATCH), MediaType::TEXT_PLAIN); + } + foreach ($futures as $future) { + $future->resolve(); + } + } + + private function buildBatch(int $amountOfMessages): BatchMessage + { + $batch = BatchMessage::constructEmpty(); + for ($messageNumber = 0; $messageNumber < $amountOfMessages; $messageNumber++) { + $batch = $batch->append(self::MESSAGE_PAYLOAD, ['contentType' => MediaType::TEXT_PLAIN]); + } + + return $batch; + } + + private function warmUpPublisher(): void + { + $this->publisher->send(self::MESSAGE_PAYLOAD); + } + + private function warmUpBatchChannel(): void + { + $this->batchChannel->send( + MessageBuilder::withPayload(self::MESSAGE_PAYLOAD) + ->setContentType(MediaType::createTextPlain()) + ->build() + ); + } + + private function bootstrapBatchChannel(string $modulePackage, object $channelBuilder, array $services): MessageChannel + { + $messaging = EcotoneLite::bootstrapFlowTesting( + [], + $services, + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, $modulePackage])) + ->withExtensionObjects([$channelBuilder]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + return $messaging->getMessageChannel($channelBuilder->getMessageChannelName()); + } + + private function bootstrapAmqpPublisher(bool $asyncPublishing): MessagePublisher + { + $queueName = uniqid('benchmark_orders_'); + $connectionFactory = new AmqpConnectionFactory(['dsn' => getenv('RABBIT_HOST') ?: 'amqp://guest:guest@localhost:5672/%2f']); + $context = $connectionFactory->createContext(); + $context->declareQueue($context->createQueue($queueName)); + + $publisherConfiguration = AmqpMessagePublisherConfiguration::create() + ->withAutoDeclareQueueOnSend(true) + ->withDefaultRoutingKey($queueName); + if ($asyncPublishing) { + $publisherConfiguration = $publisherConfiguration->withAsyncPublishing(); + } + + $messaging = EcotoneLite::bootstrapFlowTesting( + [], + [ + AmqpConnectionFactory::class => $connectionFactory, + ], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::AMQP_PACKAGE])) + ->withExtensionObjects([$publisherConfiguration]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + return $messaging->getGateway(MessagePublisher::class); + } + + private function bootstrapDbalPublisher(): MessagePublisher + { + $messaging = EcotoneLite::bootstrapFlowTesting( + [], + [ + DbalConnectionFactory::class => new DbalConnectionFactory(getenv('DATABASE_DSN') ?: 'pgsql://ecotone:secret@localhost:5432/ecotone'), + ], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([DbalMessagePublisherConfiguration::create(MessagePublisher::class, uniqid('benchmark_orders_'))]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + return $messaging->getGateway(MessagePublisher::class); + } + + private function bootstrapRedisPublisher(): MessagePublisher + { + $messaging = EcotoneLite::bootstrapFlowTesting( + [], + [ + RedisConnectionFactory::class => new RedisConnectionFactory(getenv('REDIS_DSN') ?: 'redis://localhost:6379'), + ], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::REDIS_PACKAGE])) + ->withExtensionObjects([RedisMessagePublisherConfiguration::create(queueName: uniqid('benchmark_orders_'))]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + return $messaging->getGateway(MessagePublisher::class); + } + + private function bootstrapSqsPublisher(bool $asyncPublishing): MessagePublisher + { + $publisherConfiguration = SqsMessagePublisherConfiguration::create(queueName: uniqid('benchmark_orders_')); + if ($asyncPublishing) { + $publisherConfiguration = $publisherConfiguration->withAsyncPublishing(); + } + + $messaging = EcotoneLite::bootstrapFlowTesting( + [], + [ + SqsConnectionFactory::class => new SqsConnectionFactory(getenv('SQS_DSN') ?: 'sqs:?key=key&secret=secret®ion=us-east-1&endpoint=http://localhost:4566&version=latest'), + ], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::SQS_PACKAGE])) + ->withExtensionObjects([$publisherConfiguration]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + return $messaging->getGateway(MessagePublisher::class); + } + + private function bootstrapKafkaPublisher(bool $asyncPublishing): MessagePublisher + { + $publisherConfiguration = KafkaPublisherConfiguration::createWithDefaults(topicName: uniqid('benchmark_orders_')); + if ($asyncPublishing) { + $publisherConfiguration = $publisherConfiguration->withAsyncPublishing(); + } + + $messaging = EcotoneLite::bootstrapFlowTesting( + [], + [ + KafkaBrokerConfiguration::class => KafkaBrokerConfiguration::createWithDefaults([getenv('KAFKA_DSN') ?: 'localhost:9094']), + ], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::KAFKA_PACKAGE])) + ->withExtensionObjects([$publisherConfiguration]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + return $messaging->getGateway(MessagePublisher::class); + } +} diff --git a/Monorepo/Benchmark/AsynchronousStackBenchmark.php b/Monorepo/Benchmark/AsynchronousStackBenchmark.php index a27c17257..e33203412 100644 --- a/Monorepo/Benchmark/AsynchronousStackBenchmark.php +++ b/Monorepo/Benchmark/AsynchronousStackBenchmark.php @@ -100,4 +100,4 @@ private function placeOrder(mixed $commandBus, mixed $configuration): void ) ); } -} \ No newline at end of file +} diff --git a/Monorepo/Benchmark/BootingEcotoneBenchmark.php b/Monorepo/Benchmark/BootingEcotoneBenchmark.php index b56db67ac..f5a6e2a9b 100644 --- a/Monorepo/Benchmark/BootingEcotoneBenchmark.php +++ b/Monorepo/Benchmark/BootingEcotoneBenchmark.php @@ -2,11 +2,9 @@ namespace Monorepo\Benchmark; -use Ecotone\Lite\EcotoneLiteApplication; use Ecotone\Messaging\Config\ConfiguredMessagingSystem; use Illuminate\Foundation\Http\Kernel as LaravelKernel; use Monorepo\ExampleApp\ExampleAppCaseTrait; -use Monorepo\ExampleApp\Symfony\Kernel; use PhpBench\Attributes\Iterations; use PhpBench\Attributes\Revs; use PhpBench\Attributes\Warmup; @@ -36,4 +34,4 @@ public function executeForLite(ConfiguredMessagingSystem $messagingSystem): void { $messagingSystem->list(); } -} \ No newline at end of file +} diff --git a/Monorepo/Benchmark/DbConnectBenchmark.php b/Monorepo/Benchmark/DbConnectBenchmark.php index 027841cf4..0146ec874 100644 --- a/Monorepo/Benchmark/DbConnectBenchmark.php +++ b/Monorepo/Benchmark/DbConnectBenchmark.php @@ -15,4 +15,4 @@ public function bench_db_connect(): void $connection->executeQuery('SELECT 1'); } -} \ No newline at end of file +} diff --git a/Monorepo/Benchmark/EventSourcingBenchmark.php b/Monorepo/Benchmark/EventSourcingBenchmark.php index 4269f3a84..16d1b3b03 100644 --- a/Monorepo/Benchmark/EventSourcingBenchmark.php +++ b/Monorepo/Benchmark/EventSourcingBenchmark.php @@ -27,7 +27,7 @@ public static function skippedPackages(): array { return ModulePackageList::allPackagesExcept([ ModulePackageList::EVENT_SOURCING_PACKAGE, - ModulePackageList::JMS_CONVERTER_PACKAGE + ModulePackageList::JMS_CONVERTER_PACKAGE, ]); } @@ -74,4 +74,4 @@ public function executeForLite(ConfiguredMessagingSystem $messagingSystem): void $messagingSystem->getQueryBus() ); } -} \ No newline at end of file +} diff --git a/Monorepo/Benchmark/FullAppBenchmarkCase.php b/Monorepo/Benchmark/FullAppBenchmarkCase.php index d426c51eb..afa6b2598 100644 --- a/Monorepo/Benchmark/FullAppBenchmarkCase.php +++ b/Monorepo/Benchmark/FullAppBenchmarkCase.php @@ -8,9 +8,15 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Http\Kernel as LaravelKernel; use Illuminate\Support\Facades\Artisan; + +use function json_encode; + use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; + +use function putenv; + use Symfony\Bundle\FrameworkBundle\Console\Application as SymfonyConsoleApplication; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; @@ -168,39 +174,39 @@ private static function deleteFiles(string $target, bool $deleteDirectory): void } } - public abstract function executeForSymfony( + abstract public function executeForSymfony( ContainerInterface $container, SymfonyKernel $kernel ): void; - public abstract function executeForLaravel( + abstract public function executeForLaravel( ContainerInterface $container, LaravelKernel $kernel ): void; - public abstract function executeForLiteApplication( + abstract public function executeForLiteApplication( ContainerInterface $container ): void; - public abstract function executeForLite( + abstract public function executeForLite( ConfiguredMessagingSystem $messagingSystem ): void; - protected abstract static function getSymfonyKernelClass(): string; - protected abstract static function getProjectDir(): string; + abstract protected static function getSymfonyKernelClass(): string; + abstract protected static function getProjectDir(): string; private static function productionEnvironments(): void { - \putenv('APP_ENV=prod'); - \putenv('APP_DEBUG=false'); - \putenv(sprintf('APP_SKIPPED_PACKAGES=%s', \json_encode(static::skippedPackages(), JSON_THROW_ON_ERROR))); + putenv('APP_ENV=prod'); + putenv('APP_DEBUG=false'); + putenv(sprintf('APP_SKIPPED_PACKAGES=%s', json_encode(static::skippedPackages(), JSON_THROW_ON_ERROR))); } private static function developmentEnvironments(): void { - \putenv('APP_ENV=dev'); - \putenv('APP_DEBUG=true'); - \putenv(sprintf("APP_SKIPPED_PACKAGES=%s", \json_encode(static::skippedPackages(), JSON_THROW_ON_ERROR))); + putenv('APP_ENV=dev'); + putenv('APP_DEBUG=true'); + putenv(sprintf('APP_SKIPPED_PACKAGES=%s', json_encode(static::skippedPackages(), JSON_THROW_ON_ERROR))); } public static function skippedPackages(): array @@ -261,4 +267,4 @@ public static function runConsumerForMessaging(string $consumerName, ConfiguredM ->withExecutionTimeLimitInMilliseconds(2000) ); } -} \ No newline at end of file +} diff --git a/Monorepo/Benchmark/HttpStackBenchmark.php b/Monorepo/Benchmark/HttpStackBenchmark.php index b94cca685..f83689fc6 100644 --- a/Monorepo/Benchmark/HttpStackBenchmark.php +++ b/Monorepo/Benchmark/HttpStackBenchmark.php @@ -10,7 +10,6 @@ use Monorepo\ExampleApp\Common\Infrastructure\Configuration; use Monorepo\ExampleApp\Common\UI\OrderController; use Monorepo\ExampleApp\ExampleAppCaseTrait; -use Monorepo\ExampleApp\Symfony\Kernel as SymfonyKernel; use PhpBench\Attributes\Iterations; use PhpBench\Attributes\Revs; use PhpBench\Attributes\Warmup; @@ -27,7 +26,8 @@ public function executeForSymfony(ContainerInterface $container, \Symfony\Compon { $configuration = $container->get(Configuration::class); $response = $kernel->handle( - SymfonyRequest::create('/place-order', + SymfonyRequest::create( + '/place-order', 'POST', content: json_encode([ 'orderId' => Uuid::uuid4()->toString(), @@ -35,7 +35,7 @@ public function executeForSymfony(ContainerInterface $container, \Symfony\Compon 'street' => 'Washington', 'houseNumber' => '15', 'postCode' => '81-221', - 'country' => 'Netherlands' + 'country' => 'Netherlands', ], 'productId' => $configuration->productId(), ]) @@ -58,7 +58,7 @@ public function executeForLaravel(ContainerInterface $container, LaravelKernel $ 'street' => 'Washington', 'houseNumber' => '15', 'postCode' => '81-221', - 'country' => 'Netherlands' + 'country' => 'Netherlands', ], 'productId' => $configuration->productId(), ]) @@ -79,7 +79,7 @@ public function executeForLiteApplication(ContainerInterface $container): void 'street' => 'Washington', 'houseNumber' => '15', 'postCode' => '81-221', - 'country' => 'Netherlands' + 'country' => 'Netherlands', ], 'productId' => $configuration->productId(), ]))); @@ -103,4 +103,4 @@ public function executeForLite(ConfiguredMessagingSystem $messagingSystem): void ) ); } -} \ No newline at end of file +} diff --git a/Monorepo/Benchmark/KernelBootBenchmark.php b/Monorepo/Benchmark/KernelBootBenchmark.php index 088c3bc29..81953069a 100644 --- a/Monorepo/Benchmark/KernelBootBenchmark.php +++ b/Monorepo/Benchmark/KernelBootBenchmark.php @@ -5,7 +5,6 @@ use Ecotone\Messaging\Config\ConfiguredMessagingSystem; use Illuminate\Foundation\Http\Kernel as LaravelKernel; use Monorepo\ExampleApp\ExampleAppCaseTrait; -use Monorepo\ExampleApp\Symfony\Kernel; use PhpBench\Attributes\Iterations; use PhpBench\Attributes\Revs; use PhpBench\Attributes\Warmup; @@ -35,4 +34,4 @@ public function executeForLite(ConfiguredMessagingSystem $messagingSystem): void { // do nothing } -} \ No newline at end of file +} diff --git a/Monorepo/Benchmark/LiteContainerAccessor.php b/Monorepo/Benchmark/LiteContainerAccessor.php index 5c37dd2cc..909586ecd 100644 --- a/Monorepo/Benchmark/LiteContainerAccessor.php +++ b/Monorepo/Benchmark/LiteContainerAccessor.php @@ -3,6 +3,7 @@ namespace Monorepo\Benchmark; use Ecotone\Messaging\Config\ConfiguredMessagingSystem; +use Exception; use Psr\Container\ContainerInterface; class LiteContainerAccessor implements ContainerInterface @@ -18,6 +19,6 @@ public function get(string $id) public function has(string $id): bool { - throw new \Exception("Not implemented"); + throw new Exception('Not implemented'); } -} \ No newline at end of file +} diff --git a/Monorepo/Benchmark/ProjectingBenchmark.php b/Monorepo/Benchmark/ProjectingBenchmark.php index 46137c926..fb1be9630 100644 --- a/Monorepo/Benchmark/ProjectingBenchmark.php +++ b/Monorepo/Benchmark/ProjectingBenchmark.php @@ -2,13 +2,13 @@ namespace Monorepo\Benchmark; +use Closure; use Ecotone\EventSourcing\EventStore; use Ecotone\EventSourcing\ProjectionManager; use Ecotone\Lite\EcotoneLite; use Ecotone\Messaging\Config\ConfiguredMessagingSystem; use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Messaging\Config\ServiceConfiguration; -use Ecotone\Modelling\CommandBus; use Ecotone\Projecting\ProjectionRegistry; use Ecotone\Test\LicenceTesting; use Enqueue\Dbal\DbalConnectionFactory; @@ -81,25 +81,25 @@ public function setUp(): void self::deleteProophProjection(); } - #[BeforeMethods("setUp")] + #[BeforeMethods('setUp')] public function bench_ecotone_projection(): void { self::execute(self::$ecotone); } - #[BeforeMethods("setUp")] + #[BeforeMethods('setUp')] public function bench_prooph_projection(): void { self::execute(self::$prooph); } - #[BeforeMethods("setUp")] + #[BeforeMethods('setUp')] public function bench_ecotone_projection_with_deletion(): void { self::executeWithDeletion(self::$ecotone, self::deleteEcotoneProjection(...)); } - #[BeforeMethods("setUp")] + #[BeforeMethods('setUp')] public function bench_prooph_projection_with_deletion(): void { self::executeWithDeletion(self::$prooph, self::deleteProophProjection(...)); @@ -132,7 +132,7 @@ public static function execute(ConfiguredMessagingSystem $messagingSystem): void Assert::assertEquals([new PriceChange(100, 0), new PriceChange(120, 20)], $queryBus->sendWithRouting('product.getPriceChange', $productId), 'Price change should equal to 0 after registration'); } - private static function executeWithDeletion(ConfiguredMessagingSystem $messagingSystem, \Closure $deleteProjection): void + private static function executeWithDeletion(ConfiguredMessagingSystem $messagingSystem, Closure $deleteProjection): void { $commandBus = $messagingSystem->getCommandBus(); $queryBus = $messagingSystem->getQueryBus(); @@ -150,11 +150,12 @@ private static function executeWithDeletion(ConfiguredMessagingSystem $messaging Assert::assertEquals([ new PriceChange(100, 0), new PriceChange(120, 20), - new PriceChange(130, 10) + new PriceChange(130, 10), ], $queryBus->sendWithRouting('product.getPriceChange', $productId), 'Price changes should be projected again after deletion'); } - public function fill(): void { + public function fill(): void + { $commandBus = self::$ecotone->getCommandBus(); self::$expectedProductIds = []; for ($i = 0; $i < 100; $i++) { @@ -165,41 +166,45 @@ public function fill(): void { } } - #[BeforeMethods(["setUp", "fill"])] + #[BeforeMethods(['setUp', 'fill'])] #[Iterations(1), Warmup(0)] public function bench_ecotone_projection_backfill(): void { $projectionManager = self::$ecotone->getServiceFromContainer(ProjectionRegistry::class)->get(PriceChangeOverTimeProjectionWithEcotoneProjection::NAME); $projectionManager->delete(); - Assert::assertEquals([], + Assert::assertEquals( + [], self::$ecotone->getQueryBus()->sendWithRouting('product.getPriceChange', self::$expectedProductIds[0]) ); $projectionManager->prepareBackfill(); - Assert::assertEquals([ - new PriceChange(100, 0), - new PriceChange(120, 20), - new PriceChange(130, 10), - ], + Assert::assertEquals( + [ + new PriceChange(100, 0), + new PriceChange(120, 20), + new PriceChange(130, 10), + ], self::$ecotone->getQueryBus()->sendWithRouting('product.getPriceChange', self::$expectedProductIds[0]) ); } - #[BeforeMethods(["setUp", "fill"])] + #[BeforeMethods(['setUp', 'fill'])] #[Iterations(1), Warmup(0)] public function bench_prooph_projection_backfill(): void { $projectionManager = self::$prooph->getServiceFromContainer(ProjectionManager::class); $projectionManager->deleteProjection(PriceChangeOverTimeProjection::NAME); - Assert::assertEquals([], + Assert::assertEquals( + [], self::$prooph->getQueryBus()->sendWithRouting('product.getPriceChange', self::$expectedProductIds[0]) ); $projectionManager->triggerProjection(PriceChangeOverTimeProjection::NAME); - Assert::assertEquals([ - new PriceChange(100, 0), - new PriceChange(120, 20), - new PriceChange(130, 10), - ], + Assert::assertEquals( + [ + new PriceChange(100, 0), + new PriceChange(120, 20), + new PriceChange(130, 10), + ], self::$prooph->getQueryBus()->sendWithRouting('product.getPriceChange', self::$expectedProductIds[0]) ); } -} \ No newline at end of file +} diff --git a/Monorepo/CrossModuleTests/Tests/MessageConsumer/KafkaConsumerDeduplicationTest.php b/Monorepo/CrossModuleTests/Tests/MessageConsumer/KafkaConsumerDeduplicationTest.php index 822e2f6ee..a657718eb 100644 --- a/Monorepo/CrossModuleTests/Tests/MessageConsumer/KafkaConsumerDeduplicationTest.php +++ b/Monorepo/CrossModuleTests/Tests/MessageConsumer/KafkaConsumerDeduplicationTest.php @@ -150,7 +150,7 @@ public function test_deduplicating_with_default_message_id_kafka_consumer(): voi ); // Run consumer - $ecotoneLite->run('kafka_default_deduplication_consumer', ExecutionPollingMetadata::createWithTestingSetup()); + $ecotoneLite->run('kafka_default_deduplication_consumer', ExecutionPollingMetadata::createWithTestingSetup()->withExecutionTimeLimitInMilliseconds(10_000)); // Verify message processed $this->assertEquals(['test-payload-1'], $ecotoneLite->sendQueryWithRouting('kafka.getDefaultProcessedMessages')); @@ -163,7 +163,7 @@ public function test_deduplicating_with_default_message_id_kafka_consumer(): voi ); // Run consumer again - $ecotoneLite->run('kafka_default_deduplication_consumer', ExecutionPollingMetadata::createWithTestingSetup()); + $ecotoneLite->run('kafka_default_deduplication_consumer', ExecutionPollingMetadata::createWithTestingSetup()->withExecutionTimeLimitInMilliseconds(10_000)); // Verify message NOT processed again (still only one message) $this->assertEquals(['test-payload-1'], $ecotoneLite->sendQueryWithRouting('kafka.getDefaultProcessedMessages')); @@ -176,7 +176,7 @@ public function test_deduplicating_with_default_message_id_kafka_consumer(): voi ); // Run consumer - $ecotoneLite->run('kafka_default_deduplication_consumer', ExecutionPollingMetadata::createWithTestingSetup()); + $ecotoneLite->run('kafka_default_deduplication_consumer', ExecutionPollingMetadata::createWithTestingSetup()->withExecutionTimeLimitInMilliseconds(10_000)); // Verify new message IS processed $this->assertEquals(['test-payload-1', 'test-payload-2'], $ecotoneLite->sendQueryWithRouting('kafka.getDefaultProcessedMessages')); @@ -227,13 +227,13 @@ public function test_deduplication_works_independently_across_different_consumer ); // Run consumer to process first message - $ecotoneLite->run('kafka_deduplication_consumer', ExecutionPollingMetadata::createWithTestingSetup()->withHandledMessageLimit(1)); + $ecotoneLite->run('kafka_deduplication_consumer', ExecutionPollingMetadata::createWithTestingSetup()->withExecutionTimeLimitInMilliseconds(10_000)->withHandledMessageLimit(1)); // Verify first message processed $this->assertEquals(['test-payload-1'], $ecotoneLite->sendQueryWithRouting('kafka.getProcessedMessages')); // Run consumer to process second message - $ecotoneLite->run('kafka_deduplication_consumer', ExecutionPollingMetadata::createWithTestingSetup()->withHandledMessageLimit(1)); + $ecotoneLite->run('kafka_deduplication_consumer', ExecutionPollingMetadata::createWithTestingSetup()->withExecutionTimeLimitInMilliseconds(10_000)->withHandledMessageLimit(1)); // Verify both messages processed (different custom header values) $this->assertEquals(['test-payload-1', 'test-payload-2'], $ecotoneLite->sendQueryWithRouting('kafka.getProcessedMessages')); @@ -252,7 +252,7 @@ public function test_deduplication_works_independently_across_different_consumer ); // Run consumer again - $ecotoneLite->run('kafka_deduplication_consumer', ExecutionPollingMetadata::createWithTestingSetup()->withHandledMessageLimit(2)); + $ecotoneLite->run('kafka_deduplication_consumer', ExecutionPollingMetadata::createWithTestingSetup()->withExecutionTimeLimitInMilliseconds(10_000)->withHandledMessageLimit(2)); // Verify no duplicate processing occurred (still only 2 messages) $this->assertEquals(['test-payload-1', 'test-payload-2'], $ecotoneLite->sendQueryWithRouting('kafka.getProcessedMessages')); diff --git a/Monorepo/ExampleApp/Symfony/config/reference.php b/Monorepo/ExampleApp/Symfony/config/reference.php index fe5ba5be8..d0b355025 100644 --- a/Monorepo/ExampleApp/Symfony/config/reference.php +++ b/Monorepo/ExampleApp/Symfony/config/reference.php @@ -4,6 +4,8 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\Config\Loader\ParamConfigurator as Param; + /** * This class provides array-shapes for configuring the services and bundles of an application. * @@ -31,7 +33,7 @@ * type?: string|null, * ignore_errors?: bool, * }> - * @psalm-type ParametersConfig = array|null>|null> + * @psalm-type ParametersConfig = array|Param|null>|Param|null> * @psalm-type ArgumentsType = list|array * @psalm-type CallType = array|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool} * @psalm-type TagsType = list>> // arrays inside the list must have only one element, with the tag name as the key @@ -119,592 +121,592 @@ * } * @psalm-type ServicesConfig = array{ * _defaults?: DefaultsType, - * _instanceof?: InstanceofType, + * _instanceof?: array, * ... * } * @psalm-type ExtensionType = array * @psalm-type FrameworkConfig = array{ - * secret?: scalar|null, - * http_method_override?: bool, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false - * allowed_http_method_override?: list|null, - * trust_x_sendfile_type_header?: scalar|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" - * ide?: scalar|null, // Default: "%env(default::SYMFONY_IDE)%" - * test?: bool, - * default_locale?: scalar|null, // Default: "en" - * set_locale_from_accept_language?: bool, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false - * set_content_language_from_locale?: bool, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false - * enabled_locales?: list, - * trusted_hosts?: list, + * secret?: scalar|Param|null, + * http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false + * allowed_http_method_override?: null|list, + * trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" + * ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%" + * test?: bool|Param, + * default_locale?: scalar|Param|null, // Default: "en" + * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false + * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false + * enabled_locales?: list, + * trusted_hosts?: string|list, * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] - * trusted_headers?: list, - * error_controller?: scalar|null, // Default: "error_controller" - * handle_all_throwables?: bool, // HttpKernel will handle all kinds of \Throwable. // Default: true + * trusted_headers?: string|list, + * error_controller?: scalar|Param|null, // Default: "error_controller" + * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true * csrf_protection?: bool|array{ - * enabled?: scalar|null, // Default: null - * stateless_token_ids?: list, - * check_header?: scalar|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false - * cookie_name?: scalar|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token" + * enabled?: scalar|Param|null, // Default: null + * stateless_token_ids?: list, + * check_header?: scalar|Param|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false + * cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token" * }, * form?: bool|array{ // Form configuration - * enabled?: bool, // Default: false - * csrf_protection?: array{ - * enabled?: scalar|null, // Default: null - * token_id?: scalar|null, // Default: null - * field_name?: scalar|null, // Default: "_token" - * field_attr?: array, + * enabled?: bool|Param, // Default: false + * csrf_protection?: bool|array{ + * enabled?: scalar|Param|null, // Default: null + * token_id?: scalar|Param|null, // Default: null + * field_name?: scalar|Param|null, // Default: "_token" + * field_attr?: array, * }, * }, * http_cache?: bool|array{ // HTTP cache configuration - * enabled?: bool, // Default: false - * debug?: bool, // Default: "%kernel.debug%" - * trace_level?: "none"|"short"|"full", - * trace_header?: scalar|null, - * default_ttl?: int, - * private_headers?: list, - * skip_response_headers?: list, - * allow_reload?: bool, - * allow_revalidate?: bool, - * stale_while_revalidate?: int, - * stale_if_error?: int, - * terminate_on_cache_hit?: bool, + * enabled?: bool|Param, // Default: false + * debug?: bool|Param, // Default: "%kernel.debug%" + * trace_level?: "none"|"short"|"full"|Param, + * trace_header?: scalar|Param|null, + * default_ttl?: int|Param, + * private_headers?: list, + * skip_response_headers?: list, + * allow_reload?: bool|Param, + * allow_revalidate?: bool|Param, + * stale_while_revalidate?: int|Param, + * stale_if_error?: int|Param, + * terminate_on_cache_hit?: bool|Param, * }, * esi?: bool|array{ // ESI configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * }, * ssi?: bool|array{ // SSI configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * }, * fragments?: bool|array{ // Fragments configuration - * enabled?: bool, // Default: false - * hinclude_default_template?: scalar|null, // Default: null - * path?: scalar|null, // Default: "/_fragment" + * enabled?: bool|Param, // Default: false + * hinclude_default_template?: scalar|Param|null, // Default: null + * path?: scalar|Param|null, // Default: "/_fragment" * }, * profiler?: bool|array{ // Profiler configuration - * enabled?: bool, // Default: false - * collect?: bool, // Default: true - * collect_parameter?: scalar|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null - * only_exceptions?: bool, // Default: false - * only_main_requests?: bool, // Default: false - * dsn?: scalar|null, // Default: "file:%kernel.cache_dir%/profiler" - * collect_serializer_data?: bool, // Enables the serializer data collector and profiler panel. // Default: false + * enabled?: bool|Param, // Default: false + * collect?: bool|Param, // Default: true + * collect_parameter?: scalar|Param|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null + * only_exceptions?: bool|Param, // Default: false + * only_main_requests?: bool|Param, // Default: false + * dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler" + * collect_serializer_data?: bool|Param, // Enables the serializer data collector and profiler panel. // Default: false * }, * workflows?: bool|array{ - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * workflows?: array, - * definition_validators?: list, - * support_strategy?: scalar|null, - * initial_marking?: list, - * events_to_dispatch?: list|null, - * places?: list, + * supports?: string|list, + * definition_validators?: list, + * support_strategy?: scalar|Param|null, + * initial_marking?: \BackedEnum|string|list, + * events_to_dispatch?: null|list, + * places?: string|list, * }>, - * transitions: list, - * to?: list, - * weight?: int, // Default: 1 - * metadata?: list, + * weight?: int|Param, // Default: 1 + * metadata?: array, * }>, - * metadata?: list, + * metadata?: array, * }>, * }, * router?: bool|array{ // Router configuration - * enabled?: bool, // Default: false - * resource: scalar|null, - * type?: scalar|null, - * cache_dir?: scalar|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%" - * default_uri?: scalar|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null - * http_port?: scalar|null, // Default: 80 - * https_port?: scalar|null, // Default: 443 - * strict_requirements?: scalar|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true - * utf8?: bool, // Default: true + * enabled?: bool|Param, // Default: false + * resource?: scalar|Param|null, + * type?: scalar|Param|null, + * cache_dir?: scalar|Param|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%" + * default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null + * http_port?: scalar|Param|null, // Default: 80 + * https_port?: scalar|Param|null, // Default: 443 + * strict_requirements?: scalar|Param|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true + * utf8?: bool|Param, // Default: true * }, * session?: bool|array{ // Session configuration - * enabled?: bool, // Default: false - * storage_factory_id?: scalar|null, // Default: "session.storage.factory.native" - * handler_id?: scalar|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null. - * name?: scalar|null, - * cookie_lifetime?: scalar|null, - * cookie_path?: scalar|null, - * cookie_domain?: scalar|null, - * cookie_secure?: true|false|"auto", // Default: "auto" - * cookie_httponly?: bool, // Default: true - * cookie_samesite?: null|"lax"|"strict"|"none", // Default: "lax" - * use_cookies?: bool, - * gc_divisor?: scalar|null, - * gc_probability?: scalar|null, - * gc_maxlifetime?: scalar|null, - * save_path?: scalar|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null. - * metadata_update_threshold?: int, // Seconds to wait between 2 session metadata updates. // Default: 0 - * sid_length?: int, // Deprecated: Setting the "framework.session.sid_length.sid_length" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. - * sid_bits_per_character?: int, // Deprecated: Setting the "framework.session.sid_bits_per_character.sid_bits_per_character" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. + * enabled?: bool|Param, // Default: false + * storage_factory_id?: scalar|Param|null, // Default: "session.storage.factory.native" + * handler_id?: scalar|Param|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null. + * name?: scalar|Param|null, + * cookie_lifetime?: scalar|Param|null, + * cookie_path?: scalar|Param|null, + * cookie_domain?: scalar|Param|null, + * cookie_secure?: true|false|"auto"|Param, // Default: "auto" + * cookie_httponly?: bool|Param, // Default: true + * cookie_samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax" + * use_cookies?: bool|Param, + * gc_divisor?: scalar|Param|null, + * gc_probability?: scalar|Param|null, + * gc_maxlifetime?: scalar|Param|null, + * save_path?: scalar|Param|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null. + * metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0 + * sid_length?: int|Param, // Deprecated: Setting the "framework.session.sid_length.sid_length" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. + * sid_bits_per_character?: int|Param, // Deprecated: Setting the "framework.session.sid_bits_per_character.sid_bits_per_character" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. * }, * request?: bool|array{ // Request configuration - * enabled?: bool, // Default: false - * formats?: array>, + * enabled?: bool|Param, // Default: false + * formats?: array>, * }, * assets?: bool|array{ // Assets configuration - * enabled?: bool, // Default: false - * strict_mode?: bool, // Throw an exception if an entry is missing from the manifest.json. // Default: false - * version_strategy?: scalar|null, // Default: null - * version?: scalar|null, // Default: null - * version_format?: scalar|null, // Default: "%%s?%%s" - * json_manifest_path?: scalar|null, // Default: null - * base_path?: scalar|null, // Default: "" - * base_urls?: list, + * enabled?: bool|Param, // Default: false + * strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false + * version_strategy?: scalar|Param|null, // Default: null + * version?: scalar|Param|null, // Default: null + * version_format?: scalar|Param|null, // Default: "%%s?%%s" + * json_manifest_path?: scalar|Param|null, // Default: null + * base_path?: scalar|Param|null, // Default: "" + * base_urls?: string|list, * packages?: array, + * strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false + * version_strategy?: scalar|Param|null, // Default: null + * version?: scalar|Param|null, + * version_format?: scalar|Param|null, // Default: null + * json_manifest_path?: scalar|Param|null, // Default: null + * base_path?: scalar|Param|null, // Default: "" + * base_urls?: string|list, * }>, * }, * asset_mapper?: bool|array{ // Asset Mapper configuration - * enabled?: bool, // Default: false - * paths?: array, - * excluded_patterns?: list, - * exclude_dotfiles?: bool, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true - * server?: bool, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true - * public_prefix?: scalar|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/" - * missing_import_mode?: "strict"|"warn"|"ignore", // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn" - * extensions?: array, - * importmap_path?: scalar|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php" - * importmap_polyfill?: scalar|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims" - * importmap_script_attributes?: array, - * vendor_dir?: scalar|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor" + * enabled?: bool|Param, // Default: false + * paths?: string|array, + * excluded_patterns?: list, + * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true + * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true + * public_prefix?: scalar|Param|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/" + * missing_import_mode?: "strict"|"warn"|"ignore"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn" + * extensions?: array, + * importmap_path?: scalar|Param|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php" + * importmap_polyfill?: scalar|Param|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims" + * importmap_script_attributes?: array, + * vendor_dir?: scalar|Param|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor" * precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip. - * enabled?: bool, // Default: false - * formats?: list, - * extensions?: list, + * enabled?: bool|Param, // Default: false + * formats?: list, + * extensions?: list, * }, * }, * translator?: bool|array{ // Translator configuration - * enabled?: bool, // Default: true - * fallbacks?: list, - * logging?: bool, // Default: false - * formatter?: scalar|null, // Default: "translator.formatter.default" - * cache_dir?: scalar|null, // Default: "%kernel.cache_dir%/translations" - * default_path?: scalar|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations" - * paths?: list, + * enabled?: bool|Param, // Default: true + * fallbacks?: string|list, + * logging?: bool|Param, // Default: false + * formatter?: scalar|Param|null, // Default: "translator.formatter.default" + * cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations" + * default_path?: scalar|Param|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations" + * paths?: list, * pseudo_localization?: bool|array{ - * enabled?: bool, // Default: false - * accents?: bool, // Default: true - * expansion_factor?: float, // Default: 1.0 - * brackets?: bool, // Default: true - * parse_html?: bool, // Default: false - * localizable_html_attributes?: list, + * enabled?: bool|Param, // Default: false + * accents?: bool|Param, // Default: true + * expansion_factor?: float|Param, // Default: 1.0 + * brackets?: bool|Param, // Default: true + * parse_html?: bool|Param, // Default: false + * localizable_html_attributes?: list, * }, * providers?: array, - * locales?: list, + * dsn?: scalar|Param|null, + * domains?: list, + * locales?: list, * }>, * globals?: array, - * domain?: string, + * message?: string|Param, + * parameters?: array, + * domain?: string|Param, * }>, * }, * validation?: bool|array{ // Validation configuration - * enabled?: bool, // Default: false - * cache?: scalar|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0. - * enable_attributes?: bool, // Default: true - * static_method?: list, - * translation_domain?: scalar|null, // Default: "validators" - * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose", // Default: "html5" + * enabled?: bool|Param, // Default: false + * cache?: scalar|Param|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0. + * enable_attributes?: bool|Param, // Default: true + * static_method?: string|list, + * translation_domain?: scalar|Param|null, // Default: "validators" + * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5" * mapping?: array{ - * paths?: list, + * paths?: list, * }, * not_compromised_password?: bool|array{ - * enabled?: bool, // When disabled, compromised passwords will be accepted as valid. // Default: true - * endpoint?: scalar|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null + * enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true + * endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null * }, - * disable_translation?: bool, // Default: false + * disable_translation?: bool|Param, // Default: false * auto_mapping?: array, + * services?: list, * }>, * }, * annotations?: bool|array{ - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * }, * serializer?: bool|array{ // Serializer configuration - * enabled?: bool, // Default: false - * enable_attributes?: bool, // Default: true - * name_converter?: scalar|null, - * circular_reference_handler?: scalar|null, - * max_depth_handler?: scalar|null, + * enabled?: bool|Param, // Default: false + * enable_attributes?: bool|Param, // Default: true + * name_converter?: scalar|Param|null, + * circular_reference_handler?: scalar|Param|null, + * max_depth_handler?: scalar|Param|null, * mapping?: array{ - * paths?: list, + * paths?: list, * }, - * default_context?: list, + * default_context?: array, * named_serializers?: array, - * include_built_in_normalizers?: bool, // Whether to include the built-in normalizers // Default: true - * include_built_in_encoders?: bool, // Whether to include the built-in encoders // Default: true + * name_converter?: scalar|Param|null, + * default_context?: array, + * include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true + * include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true * }>, * }, * property_access?: bool|array{ // Property access configuration - * enabled?: bool, // Default: false - * magic_call?: bool, // Default: false - * magic_get?: bool, // Default: true - * magic_set?: bool, // Default: true - * throw_exception_on_invalid_index?: bool, // Default: false - * throw_exception_on_invalid_property_path?: bool, // Default: true + * enabled?: bool|Param, // Default: false + * magic_call?: bool|Param, // Default: false + * magic_get?: bool|Param, // Default: true + * magic_set?: bool|Param, // Default: true + * throw_exception_on_invalid_index?: bool|Param, // Default: false + * throw_exception_on_invalid_property_path?: bool|Param, // Default: true * }, * type_info?: bool|array{ // Type info configuration - * enabled?: bool, // Default: false - * aliases?: array, + * enabled?: bool|Param, // Default: false + * aliases?: array, * }, * property_info?: bool|array{ // Property info configuration - * enabled?: bool, // Default: false - * with_constructor_extractor?: bool, // Registers the constructor extractor. + * enabled?: bool|Param, // Default: false + * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. * }, * cache?: array{ // Cache configuration - * prefix_seed?: scalar|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%" - * app?: scalar|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem" - * system?: scalar|null, // System related cache pools configuration. // Default: "cache.adapter.system" - * directory?: scalar|null, // Default: "%kernel.share_dir%/pools/app" - * default_psr6_provider?: scalar|null, - * default_redis_provider?: scalar|null, // Default: "redis://localhost" - * default_valkey_provider?: scalar|null, // Default: "valkey://localhost" - * default_memcached_provider?: scalar|null, // Default: "memcached://localhost" - * default_doctrine_dbal_provider?: scalar|null, // Default: "database_connection" - * default_pdo_provider?: scalar|null, // Default: null + * prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%" + * app?: scalar|Param|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem" + * system?: scalar|Param|null, // System related cache pools configuration. // Default: "cache.adapter.system" + * directory?: scalar|Param|null, // Default: "%kernel.share_dir%/pools/app" + * default_psr6_provider?: scalar|Param|null, + * default_redis_provider?: scalar|Param|null, // Default: "redis://localhost" + * default_valkey_provider?: scalar|Param|null, // Default: "valkey://localhost" + * default_memcached_provider?: scalar|Param|null, // Default: "memcached://localhost" + * default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection" + * default_pdo_provider?: scalar|Param|null, // Default: null * pools?: array, - * tags?: scalar|null, // Default: null - * public?: bool, // Default: false - * default_lifetime?: scalar|null, // Default lifetime of the pool. - * provider?: scalar|null, // Overwrite the setting from the default provider for this adapter. - * early_expiration_message_bus?: scalar|null, - * clearer?: scalar|null, + * adapters?: string|list, + * tags?: scalar|Param|null, // Default: null + * public?: bool|Param, // Default: false + * default_lifetime?: scalar|Param|null, // Default lifetime of the pool. + * provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter. + * early_expiration_message_bus?: scalar|Param|null, + * clearer?: scalar|Param|null, * }>, * }, * php_errors?: array{ // PHP errors handling configuration * log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true - * throw?: bool, // Throw PHP errors as \ErrorException instances. // Default: true + * throw?: bool|Param, // Throw PHP errors as \ErrorException instances. // Default: true * }, * exceptions?: array, * web_link?: bool|array{ // Web links configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * }, * lock?: bool|string|array{ // Lock configuration - * enabled?: bool, // Default: false - * resources?: array>, + * enabled?: bool|Param, // Default: false + * resources?: string|array>, * }, * semaphore?: bool|string|array{ // Semaphore configuration - * enabled?: bool, // Default: false - * resources?: array, + * enabled?: bool|Param, // Default: false + * resources?: string|array, * }, * messenger?: bool|array{ // Messenger configuration - * enabled?: bool, // Default: false - * routing?: array, + * enabled?: bool|Param, // Default: false + * routing?: array, * }>, * serializer?: array{ - * default_serializer?: scalar|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" + * default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" * symfony_serializer?: array{ - * format?: scalar|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json" + * format?: scalar|Param|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json" * context?: array, * }, * }, * transports?: array, - * failure_transport?: scalar|null, // Transport name to send failed messages to (after all retries have failed). // Default: null + * dsn?: scalar|Param|null, + * serializer?: scalar|Param|null, // Service id of a custom serializer to use. // Default: null + * options?: array, + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null * retry_strategy?: string|array{ - * service?: scalar|null, // Service id to override the retry strategy entirely. // Default: null - * max_retries?: int, // Default: 3 - * delay?: int, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 - * multiplier?: float, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2 - * max_delay?: int, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 - * jitter?: float, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1 + * service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1 * }, - * rate_limiter?: scalar|null, // Rate limiter name to use when processing messages. // Default: null + * rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null * }>, - * failure_transport?: scalar|null, // Transport name to send failed messages to (after all retries have failed). // Default: null - * stop_worker_on_signals?: list, - * default_bus?: scalar|null, // Default: null + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null + * stop_worker_on_signals?: int|string|list, + * default_bus?: scalar|Param|null, // Default: null * buses?: array, * }>, * }>, * }, * scheduler?: bool|array{ // Scheduler configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * }, - * disallow_search_engine_index?: bool, // Enabled by default when debug is enabled. // Default: true + * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true * http_client?: bool|array{ // HTTP Client configuration - * enabled?: bool, // Default: false - * max_host_connections?: int, // The maximum number of connections to a single host. + * enabled?: bool|Param, // Default: false + * max_host_connections?: int|Param, // The maximum number of connections to a single host. * default_options?: array{ * headers?: array, * vars?: array, - * max_redirects?: int, // The maximum number of redirects to follow. - * http_version?: scalar|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. - * resolve?: array, - * proxy?: scalar|null, // The URL of the proxy to pass requests through or null for automatic detection. - * no_proxy?: scalar|null, // A comma separated list of hosts that do not require a proxy to be reached. - * timeout?: float, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. - * max_duration?: float, // The maximum execution time for the request+response as a whole. - * bindto?: scalar|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. - * verify_peer?: bool, // Indicates if the peer should be verified in a TLS context. - * verify_host?: bool, // Indicates if the host should exist as a certificate common name. - * cafile?: scalar|null, // A certificate authority file. - * capath?: scalar|null, // A directory that contains multiple certificate authority files. - * local_cert?: scalar|null, // A PEM formatted certificate file. - * local_pk?: scalar|null, // A private key file. - * passphrase?: scalar|null, // The passphrase used to encrypt the "local_pk" file. - * ciphers?: scalar|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...) + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...) * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). * sha1?: mixed, * pin-sha256?: mixed, * md5?: mixed, * }, - * crypto_method?: scalar|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. * extra?: array, - * rate_limiter?: scalar|null, // Rate limiter name to use for throttling requests. // Default: null + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null * caching?: bool|array{ // Caching configuration. - * enabled?: bool, // Default: false - * cache_pool?: string, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" - * shared?: bool, // Indicates whether the cache is shared (public) or private. // Default: true - * max_ttl?: int, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null * }, * retry_failed?: bool|array{ - * enabled?: bool, // Default: false - * retry_strategy?: scalar|null, // service id to override the retry strategy. // Default: null - * http_codes?: array, + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null + * http_codes?: int|string|array, * }>, - * max_retries?: int, // Default: 3 - * delay?: int, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 - * multiplier?: float, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 - * max_delay?: int, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 - * jitter?: float, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 * }, * }, - * mock_response_factory?: scalar|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable. + * mock_response_factory?: scalar|Param|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable. * scoped_clients?: array, + * scope?: scalar|Param|null, // The regular expression that the request URL must match before adding the other options. When none is provided, the base URI is used instead. + * base_uri?: scalar|Param|null, // The URI to resolve relative URLs, following rules in RFC 3985, section 2. + * auth_basic?: scalar|Param|null, // An HTTP Basic authentication "username:password". + * auth_bearer?: scalar|Param|null, // A token enabling HTTP Bearer authorization. + * auth_ntlm?: scalar|Param|null, // A "username:password" pair to use Microsoft NTLM authentication (requires the cURL extension). + * query?: array, * headers?: array, - * max_redirects?: int, // The maximum number of redirects to follow. - * http_version?: scalar|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. - * resolve?: array, - * proxy?: scalar|null, // The URL of the proxy to pass requests through or null for automatic detection. - * no_proxy?: scalar|null, // A comma separated list of hosts that do not require a proxy to be reached. - * timeout?: float, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. - * max_duration?: float, // The maximum execution time for the request+response as a whole. - * bindto?: scalar|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. - * verify_peer?: bool, // Indicates if the peer should be verified in a TLS context. - * verify_host?: bool, // Indicates if the host should exist as a certificate common name. - * cafile?: scalar|null, // A certificate authority file. - * capath?: scalar|null, // A directory that contains multiple certificate authority files. - * local_cert?: scalar|null, // A PEM formatted certificate file. - * local_pk?: scalar|null, // A private key file. - * passphrase?: scalar|null, // The passphrase used to encrypt the "local_pk" file. - * ciphers?: scalar|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...). + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...). * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). * sha1?: mixed, * pin-sha256?: mixed, * md5?: mixed, * }, - * crypto_method?: scalar|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. * extra?: array, - * rate_limiter?: scalar|null, // Rate limiter name to use for throttling requests. // Default: null + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null * caching?: bool|array{ // Caching configuration. - * enabled?: bool, // Default: false - * cache_pool?: string, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" - * shared?: bool, // Indicates whether the cache is shared (public) or private. // Default: true - * max_ttl?: int, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null * }, * retry_failed?: bool|array{ - * enabled?: bool, // Default: false - * retry_strategy?: scalar|null, // service id to override the retry strategy. // Default: null - * http_codes?: array, + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null + * http_codes?: int|string|array, * }>, - * max_retries?: int, // Default: 3 - * delay?: int, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 - * multiplier?: float, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 - * max_delay?: int, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 - * jitter?: float, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 * }, * }>, * }, * mailer?: bool|array{ // Mailer configuration - * enabled?: bool, // Default: true - * message_bus?: scalar|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null - * dsn?: scalar|null, // Default: null - * transports?: array, + * enabled?: bool|Param, // Default: true + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * dsn?: scalar|Param|null, // Default: null + * transports?: array, * envelope?: array{ // Mailer Envelope configuration - * sender?: scalar|null, - * recipients?: list, - * allowed_recipients?: list, + * sender?: scalar|Param|null, + * recipients?: string|list, + * allowed_recipients?: string|list, * }, * headers?: array, * dkim_signer?: bool|array{ // DKIM signer configuration - * enabled?: bool, // Default: false - * key?: scalar|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: "" - * domain?: scalar|null, // Default: "" - * select?: scalar|null, // Default: "" - * passphrase?: scalar|null, // The private key passphrase // Default: "" + * enabled?: bool|Param, // Default: false + * key?: scalar|Param|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: "" + * domain?: scalar|Param|null, // Default: "" + * select?: scalar|Param|null, // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: "" * options?: array, * }, * smime_signer?: bool|array{ // S/MIME signer configuration - * enabled?: bool, // Default: false - * key?: scalar|null, // Path to key (in PEM format) // Default: "" - * certificate?: scalar|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: "" - * passphrase?: scalar|null, // The private key passphrase // Default: null - * extra_certificates?: scalar|null, // Default: null - * sign_options?: int, // Default: null + * enabled?: bool|Param, // Default: false + * key?: scalar|Param|null, // Path to key (in PEM format) // Default: "" + * certificate?: scalar|Param|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: null + * extra_certificates?: scalar|Param|null, // Default: null + * sign_options?: int|Param, // Default: null * }, * smime_encrypter?: bool|array{ // S/MIME encrypter configuration - * enabled?: bool, // Default: false - * repository?: scalar|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: "" - * cipher?: int, // A set of algorithms used to encrypt the message // Default: null + * enabled?: bool|Param, // Default: false + * repository?: scalar|Param|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: "" + * cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null * }, * }, * secrets?: bool|array{ - * enabled?: bool, // Default: true - * vault_directory?: scalar|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%" - * local_dotenv_file?: scalar|null, // Default: "%kernel.project_dir%/.env.%kernel.runtime_environment%.local" - * decryption_env_var?: scalar|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET" + * enabled?: bool|Param, // Default: true + * vault_directory?: scalar|Param|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%" + * local_dotenv_file?: scalar|Param|null, // Default: "%kernel.project_dir%/.env.%kernel.environment%.local" + * decryption_env_var?: scalar|Param|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET" * }, * notifier?: bool|array{ // Notifier configuration - * enabled?: bool, // Default: false - * message_bus?: scalar|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null - * chatter_transports?: array, - * texter_transports?: array, - * notification_on_failed_messages?: bool, // Default: false - * channel_policy?: array>, + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * chatter_transports?: array, + * texter_transports?: array, + * notification_on_failed_messages?: bool|Param, // Default: false + * channel_policy?: array>, * admin_recipients?: list, * }, * rate_limiter?: bool|array{ // Rate limiter configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * limiters?: array, - * limit?: int, // The maximum allowed hits in a fixed interval or burst. - * interval?: scalar|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto" + * cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter" + * storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null + * policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter. + * limiters?: string|list, + * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. + * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". - * interval?: scalar|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). - * amount?: int, // Amount of tokens to add each interval. // Default: 1 + * interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * amount?: int|Param, // Amount of tokens to add each interval. // Default: 1 * }, * }>, * }, * uid?: bool|array{ // Uid configuration - * enabled?: bool, // Default: true - * default_uuid_version?: 7|6|4|1, // Default: 7 - * name_based_uuid_version?: 5|3, // Default: 5 - * name_based_uuid_namespace?: scalar|null, - * time_based_uuid_version?: 7|6|1, // Default: 7 - * time_based_uuid_node?: scalar|null, + * enabled?: bool|Param, // Default: true + * default_uuid_version?: 7|6|4|1|Param, // Default: 7 + * name_based_uuid_version?: 5|3|Param, // Default: 5 + * name_based_uuid_namespace?: scalar|Param|null, + * time_based_uuid_version?: 7|6|1|Param, // Default: 7 + * time_based_uuid_node?: scalar|Param|null, * }, * html_sanitizer?: bool|array{ // HtmlSanitizer configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * sanitizers?: array, - * block_elements?: list, - * drop_elements?: list, + * block_elements?: string|list, + * drop_elements?: string|list, * allow_attributes?: array, * drop_attributes?: array, - * force_attributes?: array>, - * force_https_urls?: bool, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false - * allowed_link_schemes?: list, - * allowed_link_hosts?: list|null, - * allow_relative_links?: bool, // Allows relative URLs to be used in links href attributes. // Default: false - * allowed_media_schemes?: list, - * allowed_media_hosts?: list|null, - * allow_relative_medias?: bool, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false - * with_attribute_sanitizers?: list, - * without_attribute_sanitizers?: list, - * max_input_length?: int, // The maximum length allowed for the sanitized input. // Default: 0 + * force_attributes?: array>, + * force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false + * allowed_link_schemes?: string|list, + * allowed_link_hosts?: null|string|list, + * allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false + * allowed_media_schemes?: string|list, + * allowed_media_hosts?: null|string|list, + * allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false + * with_attribute_sanitizers?: string|list, + * without_attribute_sanitizers?: string|list, + * max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0 * }>, * }, * webhook?: bool|array{ // Webhook configuration - * enabled?: bool, // Default: false - * message_bus?: scalar|null, // The message bus to use. // Default: "messenger.default_bus" + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus" * routing?: array, * }, * remote-event?: bool|array{ // RemoteEvent configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * }, * json_streamer?: bool|array{ // JSON streamer configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * }, * } * @psalm-type EcotoneConfig = array{ - * serviceName?: scalar|null, // Default: null - * cacheConfiguration?: bool, // Default: false - * failFast?: bool, // Default: false - * test?: bool, // Default: false - * loadSrcNamespaces?: bool, // Default: true - * defaultSerializationMediaType?: scalar|null, // Default: null - * defaultErrorChannel?: scalar|null, // Default: null - * namespaces?: list, - * defaultMemoryLimit?: int, // Default: null + * serviceName?: scalar|Param|null, // Default: null + * cacheConfiguration?: bool|Param, // Default: false + * failFast?: bool|Param, // Default: false + * test?: bool|Param, // Default: false + * loadSrcNamespaces?: bool|Param, // Default: true + * defaultSerializationMediaType?: scalar|Param|null, // Default: null + * defaultErrorChannel?: scalar|Param|null, // Default: null + * namespaces?: list, + * defaultMemoryLimit?: int|Param, // Default: null * defaultConnectionExceptionRetry?: array{ - * initialDelay: int, - * maxAttempts: int, - * multiplier: int, + * initialDelay?: int|Param, + * maxAttempts?: int|Param, + * multiplier?: int|Param, * }, - * licenceKey?: scalar|null, // Default: null - * skippedModulePackageNames?: list, + * licenceKey?: scalar|Param|null, // Default: null + * skippedModulePackageNames?: list, * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, @@ -736,7 +738,10 @@ final class App */ public static function config(array $config): array { - return AppReference::config($config); + /** @var ConfigType $config */ + $config = AppReference::config($config); + + return $config; } } diff --git a/Monorepo/ExampleAppEventSourcing/Symfony/config/reference.php b/Monorepo/ExampleAppEventSourcing/Symfony/config/reference.php index fe5ba5be8..d0b355025 100644 --- a/Monorepo/ExampleAppEventSourcing/Symfony/config/reference.php +++ b/Monorepo/ExampleAppEventSourcing/Symfony/config/reference.php @@ -4,6 +4,8 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\Config\Loader\ParamConfigurator as Param; + /** * This class provides array-shapes for configuring the services and bundles of an application. * @@ -31,7 +33,7 @@ * type?: string|null, * ignore_errors?: bool, * }> - * @psalm-type ParametersConfig = array|null>|null> + * @psalm-type ParametersConfig = array|Param|null>|Param|null> * @psalm-type ArgumentsType = list|array * @psalm-type CallType = array|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool} * @psalm-type TagsType = list>> // arrays inside the list must have only one element, with the tag name as the key @@ -119,592 +121,592 @@ * } * @psalm-type ServicesConfig = array{ * _defaults?: DefaultsType, - * _instanceof?: InstanceofType, + * _instanceof?: array, * ... * } * @psalm-type ExtensionType = array * @psalm-type FrameworkConfig = array{ - * secret?: scalar|null, - * http_method_override?: bool, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false - * allowed_http_method_override?: list|null, - * trust_x_sendfile_type_header?: scalar|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" - * ide?: scalar|null, // Default: "%env(default::SYMFONY_IDE)%" - * test?: bool, - * default_locale?: scalar|null, // Default: "en" - * set_locale_from_accept_language?: bool, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false - * set_content_language_from_locale?: bool, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false - * enabled_locales?: list, - * trusted_hosts?: list, + * secret?: scalar|Param|null, + * http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false + * allowed_http_method_override?: null|list, + * trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" + * ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%" + * test?: bool|Param, + * default_locale?: scalar|Param|null, // Default: "en" + * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false + * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false + * enabled_locales?: list, + * trusted_hosts?: string|list, * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] - * trusted_headers?: list, - * error_controller?: scalar|null, // Default: "error_controller" - * handle_all_throwables?: bool, // HttpKernel will handle all kinds of \Throwable. // Default: true + * trusted_headers?: string|list, + * error_controller?: scalar|Param|null, // Default: "error_controller" + * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true * csrf_protection?: bool|array{ - * enabled?: scalar|null, // Default: null - * stateless_token_ids?: list, - * check_header?: scalar|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false - * cookie_name?: scalar|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token" + * enabled?: scalar|Param|null, // Default: null + * stateless_token_ids?: list, + * check_header?: scalar|Param|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false + * cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token" * }, * form?: bool|array{ // Form configuration - * enabled?: bool, // Default: false - * csrf_protection?: array{ - * enabled?: scalar|null, // Default: null - * token_id?: scalar|null, // Default: null - * field_name?: scalar|null, // Default: "_token" - * field_attr?: array, + * enabled?: bool|Param, // Default: false + * csrf_protection?: bool|array{ + * enabled?: scalar|Param|null, // Default: null + * token_id?: scalar|Param|null, // Default: null + * field_name?: scalar|Param|null, // Default: "_token" + * field_attr?: array, * }, * }, * http_cache?: bool|array{ // HTTP cache configuration - * enabled?: bool, // Default: false - * debug?: bool, // Default: "%kernel.debug%" - * trace_level?: "none"|"short"|"full", - * trace_header?: scalar|null, - * default_ttl?: int, - * private_headers?: list, - * skip_response_headers?: list, - * allow_reload?: bool, - * allow_revalidate?: bool, - * stale_while_revalidate?: int, - * stale_if_error?: int, - * terminate_on_cache_hit?: bool, + * enabled?: bool|Param, // Default: false + * debug?: bool|Param, // Default: "%kernel.debug%" + * trace_level?: "none"|"short"|"full"|Param, + * trace_header?: scalar|Param|null, + * default_ttl?: int|Param, + * private_headers?: list, + * skip_response_headers?: list, + * allow_reload?: bool|Param, + * allow_revalidate?: bool|Param, + * stale_while_revalidate?: int|Param, + * stale_if_error?: int|Param, + * terminate_on_cache_hit?: bool|Param, * }, * esi?: bool|array{ // ESI configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * }, * ssi?: bool|array{ // SSI configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * }, * fragments?: bool|array{ // Fragments configuration - * enabled?: bool, // Default: false - * hinclude_default_template?: scalar|null, // Default: null - * path?: scalar|null, // Default: "/_fragment" + * enabled?: bool|Param, // Default: false + * hinclude_default_template?: scalar|Param|null, // Default: null + * path?: scalar|Param|null, // Default: "/_fragment" * }, * profiler?: bool|array{ // Profiler configuration - * enabled?: bool, // Default: false - * collect?: bool, // Default: true - * collect_parameter?: scalar|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null - * only_exceptions?: bool, // Default: false - * only_main_requests?: bool, // Default: false - * dsn?: scalar|null, // Default: "file:%kernel.cache_dir%/profiler" - * collect_serializer_data?: bool, // Enables the serializer data collector and profiler panel. // Default: false + * enabled?: bool|Param, // Default: false + * collect?: bool|Param, // Default: true + * collect_parameter?: scalar|Param|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null + * only_exceptions?: bool|Param, // Default: false + * only_main_requests?: bool|Param, // Default: false + * dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler" + * collect_serializer_data?: bool|Param, // Enables the serializer data collector and profiler panel. // Default: false * }, * workflows?: bool|array{ - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * workflows?: array, - * definition_validators?: list, - * support_strategy?: scalar|null, - * initial_marking?: list, - * events_to_dispatch?: list|null, - * places?: list, + * supports?: string|list, + * definition_validators?: list, + * support_strategy?: scalar|Param|null, + * initial_marking?: \BackedEnum|string|list, + * events_to_dispatch?: null|list, + * places?: string|list, * }>, - * transitions: list, - * to?: list, - * weight?: int, // Default: 1 - * metadata?: list, + * weight?: int|Param, // Default: 1 + * metadata?: array, * }>, - * metadata?: list, + * metadata?: array, * }>, * }, * router?: bool|array{ // Router configuration - * enabled?: bool, // Default: false - * resource: scalar|null, - * type?: scalar|null, - * cache_dir?: scalar|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%" - * default_uri?: scalar|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null - * http_port?: scalar|null, // Default: 80 - * https_port?: scalar|null, // Default: 443 - * strict_requirements?: scalar|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true - * utf8?: bool, // Default: true + * enabled?: bool|Param, // Default: false + * resource?: scalar|Param|null, + * type?: scalar|Param|null, + * cache_dir?: scalar|Param|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%" + * default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null + * http_port?: scalar|Param|null, // Default: 80 + * https_port?: scalar|Param|null, // Default: 443 + * strict_requirements?: scalar|Param|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true + * utf8?: bool|Param, // Default: true * }, * session?: bool|array{ // Session configuration - * enabled?: bool, // Default: false - * storage_factory_id?: scalar|null, // Default: "session.storage.factory.native" - * handler_id?: scalar|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null. - * name?: scalar|null, - * cookie_lifetime?: scalar|null, - * cookie_path?: scalar|null, - * cookie_domain?: scalar|null, - * cookie_secure?: true|false|"auto", // Default: "auto" - * cookie_httponly?: bool, // Default: true - * cookie_samesite?: null|"lax"|"strict"|"none", // Default: "lax" - * use_cookies?: bool, - * gc_divisor?: scalar|null, - * gc_probability?: scalar|null, - * gc_maxlifetime?: scalar|null, - * save_path?: scalar|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null. - * metadata_update_threshold?: int, // Seconds to wait between 2 session metadata updates. // Default: 0 - * sid_length?: int, // Deprecated: Setting the "framework.session.sid_length.sid_length" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. - * sid_bits_per_character?: int, // Deprecated: Setting the "framework.session.sid_bits_per_character.sid_bits_per_character" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. + * enabled?: bool|Param, // Default: false + * storage_factory_id?: scalar|Param|null, // Default: "session.storage.factory.native" + * handler_id?: scalar|Param|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null. + * name?: scalar|Param|null, + * cookie_lifetime?: scalar|Param|null, + * cookie_path?: scalar|Param|null, + * cookie_domain?: scalar|Param|null, + * cookie_secure?: true|false|"auto"|Param, // Default: "auto" + * cookie_httponly?: bool|Param, // Default: true + * cookie_samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax" + * use_cookies?: bool|Param, + * gc_divisor?: scalar|Param|null, + * gc_probability?: scalar|Param|null, + * gc_maxlifetime?: scalar|Param|null, + * save_path?: scalar|Param|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null. + * metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0 + * sid_length?: int|Param, // Deprecated: Setting the "framework.session.sid_length.sid_length" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. + * sid_bits_per_character?: int|Param, // Deprecated: Setting the "framework.session.sid_bits_per_character.sid_bits_per_character" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. * }, * request?: bool|array{ // Request configuration - * enabled?: bool, // Default: false - * formats?: array>, + * enabled?: bool|Param, // Default: false + * formats?: array>, * }, * assets?: bool|array{ // Assets configuration - * enabled?: bool, // Default: false - * strict_mode?: bool, // Throw an exception if an entry is missing from the manifest.json. // Default: false - * version_strategy?: scalar|null, // Default: null - * version?: scalar|null, // Default: null - * version_format?: scalar|null, // Default: "%%s?%%s" - * json_manifest_path?: scalar|null, // Default: null - * base_path?: scalar|null, // Default: "" - * base_urls?: list, + * enabled?: bool|Param, // Default: false + * strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false + * version_strategy?: scalar|Param|null, // Default: null + * version?: scalar|Param|null, // Default: null + * version_format?: scalar|Param|null, // Default: "%%s?%%s" + * json_manifest_path?: scalar|Param|null, // Default: null + * base_path?: scalar|Param|null, // Default: "" + * base_urls?: string|list, * packages?: array, + * strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false + * version_strategy?: scalar|Param|null, // Default: null + * version?: scalar|Param|null, + * version_format?: scalar|Param|null, // Default: null + * json_manifest_path?: scalar|Param|null, // Default: null + * base_path?: scalar|Param|null, // Default: "" + * base_urls?: string|list, * }>, * }, * asset_mapper?: bool|array{ // Asset Mapper configuration - * enabled?: bool, // Default: false - * paths?: array, - * excluded_patterns?: list, - * exclude_dotfiles?: bool, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true - * server?: bool, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true - * public_prefix?: scalar|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/" - * missing_import_mode?: "strict"|"warn"|"ignore", // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn" - * extensions?: array, - * importmap_path?: scalar|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php" - * importmap_polyfill?: scalar|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims" - * importmap_script_attributes?: array, - * vendor_dir?: scalar|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor" + * enabled?: bool|Param, // Default: false + * paths?: string|array, + * excluded_patterns?: list, + * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true + * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true + * public_prefix?: scalar|Param|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/" + * missing_import_mode?: "strict"|"warn"|"ignore"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn" + * extensions?: array, + * importmap_path?: scalar|Param|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php" + * importmap_polyfill?: scalar|Param|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims" + * importmap_script_attributes?: array, + * vendor_dir?: scalar|Param|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor" * precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip. - * enabled?: bool, // Default: false - * formats?: list, - * extensions?: list, + * enabled?: bool|Param, // Default: false + * formats?: list, + * extensions?: list, * }, * }, * translator?: bool|array{ // Translator configuration - * enabled?: bool, // Default: true - * fallbacks?: list, - * logging?: bool, // Default: false - * formatter?: scalar|null, // Default: "translator.formatter.default" - * cache_dir?: scalar|null, // Default: "%kernel.cache_dir%/translations" - * default_path?: scalar|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations" - * paths?: list, + * enabled?: bool|Param, // Default: true + * fallbacks?: string|list, + * logging?: bool|Param, // Default: false + * formatter?: scalar|Param|null, // Default: "translator.formatter.default" + * cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations" + * default_path?: scalar|Param|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations" + * paths?: list, * pseudo_localization?: bool|array{ - * enabled?: bool, // Default: false - * accents?: bool, // Default: true - * expansion_factor?: float, // Default: 1.0 - * brackets?: bool, // Default: true - * parse_html?: bool, // Default: false - * localizable_html_attributes?: list, + * enabled?: bool|Param, // Default: false + * accents?: bool|Param, // Default: true + * expansion_factor?: float|Param, // Default: 1.0 + * brackets?: bool|Param, // Default: true + * parse_html?: bool|Param, // Default: false + * localizable_html_attributes?: list, * }, * providers?: array, - * locales?: list, + * dsn?: scalar|Param|null, + * domains?: list, + * locales?: list, * }>, * globals?: array, - * domain?: string, + * message?: string|Param, + * parameters?: array, + * domain?: string|Param, * }>, * }, * validation?: bool|array{ // Validation configuration - * enabled?: bool, // Default: false - * cache?: scalar|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0. - * enable_attributes?: bool, // Default: true - * static_method?: list, - * translation_domain?: scalar|null, // Default: "validators" - * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose", // Default: "html5" + * enabled?: bool|Param, // Default: false + * cache?: scalar|Param|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0. + * enable_attributes?: bool|Param, // Default: true + * static_method?: string|list, + * translation_domain?: scalar|Param|null, // Default: "validators" + * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5" * mapping?: array{ - * paths?: list, + * paths?: list, * }, * not_compromised_password?: bool|array{ - * enabled?: bool, // When disabled, compromised passwords will be accepted as valid. // Default: true - * endpoint?: scalar|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null + * enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true + * endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null * }, - * disable_translation?: bool, // Default: false + * disable_translation?: bool|Param, // Default: false * auto_mapping?: array, + * services?: list, * }>, * }, * annotations?: bool|array{ - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * }, * serializer?: bool|array{ // Serializer configuration - * enabled?: bool, // Default: false - * enable_attributes?: bool, // Default: true - * name_converter?: scalar|null, - * circular_reference_handler?: scalar|null, - * max_depth_handler?: scalar|null, + * enabled?: bool|Param, // Default: false + * enable_attributes?: bool|Param, // Default: true + * name_converter?: scalar|Param|null, + * circular_reference_handler?: scalar|Param|null, + * max_depth_handler?: scalar|Param|null, * mapping?: array{ - * paths?: list, + * paths?: list, * }, - * default_context?: list, + * default_context?: array, * named_serializers?: array, - * include_built_in_normalizers?: bool, // Whether to include the built-in normalizers // Default: true - * include_built_in_encoders?: bool, // Whether to include the built-in encoders // Default: true + * name_converter?: scalar|Param|null, + * default_context?: array, + * include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true + * include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true * }>, * }, * property_access?: bool|array{ // Property access configuration - * enabled?: bool, // Default: false - * magic_call?: bool, // Default: false - * magic_get?: bool, // Default: true - * magic_set?: bool, // Default: true - * throw_exception_on_invalid_index?: bool, // Default: false - * throw_exception_on_invalid_property_path?: bool, // Default: true + * enabled?: bool|Param, // Default: false + * magic_call?: bool|Param, // Default: false + * magic_get?: bool|Param, // Default: true + * magic_set?: bool|Param, // Default: true + * throw_exception_on_invalid_index?: bool|Param, // Default: false + * throw_exception_on_invalid_property_path?: bool|Param, // Default: true * }, * type_info?: bool|array{ // Type info configuration - * enabled?: bool, // Default: false - * aliases?: array, + * enabled?: bool|Param, // Default: false + * aliases?: array, * }, * property_info?: bool|array{ // Property info configuration - * enabled?: bool, // Default: false - * with_constructor_extractor?: bool, // Registers the constructor extractor. + * enabled?: bool|Param, // Default: false + * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. * }, * cache?: array{ // Cache configuration - * prefix_seed?: scalar|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%" - * app?: scalar|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem" - * system?: scalar|null, // System related cache pools configuration. // Default: "cache.adapter.system" - * directory?: scalar|null, // Default: "%kernel.share_dir%/pools/app" - * default_psr6_provider?: scalar|null, - * default_redis_provider?: scalar|null, // Default: "redis://localhost" - * default_valkey_provider?: scalar|null, // Default: "valkey://localhost" - * default_memcached_provider?: scalar|null, // Default: "memcached://localhost" - * default_doctrine_dbal_provider?: scalar|null, // Default: "database_connection" - * default_pdo_provider?: scalar|null, // Default: null + * prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%" + * app?: scalar|Param|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem" + * system?: scalar|Param|null, // System related cache pools configuration. // Default: "cache.adapter.system" + * directory?: scalar|Param|null, // Default: "%kernel.share_dir%/pools/app" + * default_psr6_provider?: scalar|Param|null, + * default_redis_provider?: scalar|Param|null, // Default: "redis://localhost" + * default_valkey_provider?: scalar|Param|null, // Default: "valkey://localhost" + * default_memcached_provider?: scalar|Param|null, // Default: "memcached://localhost" + * default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection" + * default_pdo_provider?: scalar|Param|null, // Default: null * pools?: array, - * tags?: scalar|null, // Default: null - * public?: bool, // Default: false - * default_lifetime?: scalar|null, // Default lifetime of the pool. - * provider?: scalar|null, // Overwrite the setting from the default provider for this adapter. - * early_expiration_message_bus?: scalar|null, - * clearer?: scalar|null, + * adapters?: string|list, + * tags?: scalar|Param|null, // Default: null + * public?: bool|Param, // Default: false + * default_lifetime?: scalar|Param|null, // Default lifetime of the pool. + * provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter. + * early_expiration_message_bus?: scalar|Param|null, + * clearer?: scalar|Param|null, * }>, * }, * php_errors?: array{ // PHP errors handling configuration * log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true - * throw?: bool, // Throw PHP errors as \ErrorException instances. // Default: true + * throw?: bool|Param, // Throw PHP errors as \ErrorException instances. // Default: true * }, * exceptions?: array, * web_link?: bool|array{ // Web links configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * }, * lock?: bool|string|array{ // Lock configuration - * enabled?: bool, // Default: false - * resources?: array>, + * enabled?: bool|Param, // Default: false + * resources?: string|array>, * }, * semaphore?: bool|string|array{ // Semaphore configuration - * enabled?: bool, // Default: false - * resources?: array, + * enabled?: bool|Param, // Default: false + * resources?: string|array, * }, * messenger?: bool|array{ // Messenger configuration - * enabled?: bool, // Default: false - * routing?: array, + * enabled?: bool|Param, // Default: false + * routing?: array, * }>, * serializer?: array{ - * default_serializer?: scalar|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" + * default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" * symfony_serializer?: array{ - * format?: scalar|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json" + * format?: scalar|Param|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json" * context?: array, * }, * }, * transports?: array, - * failure_transport?: scalar|null, // Transport name to send failed messages to (after all retries have failed). // Default: null + * dsn?: scalar|Param|null, + * serializer?: scalar|Param|null, // Service id of a custom serializer to use. // Default: null + * options?: array, + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null * retry_strategy?: string|array{ - * service?: scalar|null, // Service id to override the retry strategy entirely. // Default: null - * max_retries?: int, // Default: 3 - * delay?: int, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 - * multiplier?: float, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2 - * max_delay?: int, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 - * jitter?: float, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1 + * service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1 * }, - * rate_limiter?: scalar|null, // Rate limiter name to use when processing messages. // Default: null + * rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null * }>, - * failure_transport?: scalar|null, // Transport name to send failed messages to (after all retries have failed). // Default: null - * stop_worker_on_signals?: list, - * default_bus?: scalar|null, // Default: null + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null + * stop_worker_on_signals?: int|string|list, + * default_bus?: scalar|Param|null, // Default: null * buses?: array, * }>, * }>, * }, * scheduler?: bool|array{ // Scheduler configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * }, - * disallow_search_engine_index?: bool, // Enabled by default when debug is enabled. // Default: true + * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true * http_client?: bool|array{ // HTTP Client configuration - * enabled?: bool, // Default: false - * max_host_connections?: int, // The maximum number of connections to a single host. + * enabled?: bool|Param, // Default: false + * max_host_connections?: int|Param, // The maximum number of connections to a single host. * default_options?: array{ * headers?: array, * vars?: array, - * max_redirects?: int, // The maximum number of redirects to follow. - * http_version?: scalar|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. - * resolve?: array, - * proxy?: scalar|null, // The URL of the proxy to pass requests through or null for automatic detection. - * no_proxy?: scalar|null, // A comma separated list of hosts that do not require a proxy to be reached. - * timeout?: float, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. - * max_duration?: float, // The maximum execution time for the request+response as a whole. - * bindto?: scalar|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. - * verify_peer?: bool, // Indicates if the peer should be verified in a TLS context. - * verify_host?: bool, // Indicates if the host should exist as a certificate common name. - * cafile?: scalar|null, // A certificate authority file. - * capath?: scalar|null, // A directory that contains multiple certificate authority files. - * local_cert?: scalar|null, // A PEM formatted certificate file. - * local_pk?: scalar|null, // A private key file. - * passphrase?: scalar|null, // The passphrase used to encrypt the "local_pk" file. - * ciphers?: scalar|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...) + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...) * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). * sha1?: mixed, * pin-sha256?: mixed, * md5?: mixed, * }, - * crypto_method?: scalar|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. * extra?: array, - * rate_limiter?: scalar|null, // Rate limiter name to use for throttling requests. // Default: null + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null * caching?: bool|array{ // Caching configuration. - * enabled?: bool, // Default: false - * cache_pool?: string, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" - * shared?: bool, // Indicates whether the cache is shared (public) or private. // Default: true - * max_ttl?: int, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null * }, * retry_failed?: bool|array{ - * enabled?: bool, // Default: false - * retry_strategy?: scalar|null, // service id to override the retry strategy. // Default: null - * http_codes?: array, + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null + * http_codes?: int|string|array, * }>, - * max_retries?: int, // Default: 3 - * delay?: int, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 - * multiplier?: float, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 - * max_delay?: int, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 - * jitter?: float, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 * }, * }, - * mock_response_factory?: scalar|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable. + * mock_response_factory?: scalar|Param|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable. * scoped_clients?: array, + * scope?: scalar|Param|null, // The regular expression that the request URL must match before adding the other options. When none is provided, the base URI is used instead. + * base_uri?: scalar|Param|null, // The URI to resolve relative URLs, following rules in RFC 3985, section 2. + * auth_basic?: scalar|Param|null, // An HTTP Basic authentication "username:password". + * auth_bearer?: scalar|Param|null, // A token enabling HTTP Bearer authorization. + * auth_ntlm?: scalar|Param|null, // A "username:password" pair to use Microsoft NTLM authentication (requires the cURL extension). + * query?: array, * headers?: array, - * max_redirects?: int, // The maximum number of redirects to follow. - * http_version?: scalar|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. - * resolve?: array, - * proxy?: scalar|null, // The URL of the proxy to pass requests through or null for automatic detection. - * no_proxy?: scalar|null, // A comma separated list of hosts that do not require a proxy to be reached. - * timeout?: float, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. - * max_duration?: float, // The maximum execution time for the request+response as a whole. - * bindto?: scalar|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. - * verify_peer?: bool, // Indicates if the peer should be verified in a TLS context. - * verify_host?: bool, // Indicates if the host should exist as a certificate common name. - * cafile?: scalar|null, // A certificate authority file. - * capath?: scalar|null, // A directory that contains multiple certificate authority files. - * local_cert?: scalar|null, // A PEM formatted certificate file. - * local_pk?: scalar|null, // A private key file. - * passphrase?: scalar|null, // The passphrase used to encrypt the "local_pk" file. - * ciphers?: scalar|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...). + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...). * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). * sha1?: mixed, * pin-sha256?: mixed, * md5?: mixed, * }, - * crypto_method?: scalar|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. * extra?: array, - * rate_limiter?: scalar|null, // Rate limiter name to use for throttling requests. // Default: null + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null * caching?: bool|array{ // Caching configuration. - * enabled?: bool, // Default: false - * cache_pool?: string, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" - * shared?: bool, // Indicates whether the cache is shared (public) or private. // Default: true - * max_ttl?: int, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null * }, * retry_failed?: bool|array{ - * enabled?: bool, // Default: false - * retry_strategy?: scalar|null, // service id to override the retry strategy. // Default: null - * http_codes?: array, + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null + * http_codes?: int|string|array, * }>, - * max_retries?: int, // Default: 3 - * delay?: int, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 - * multiplier?: float, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 - * max_delay?: int, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 - * jitter?: float, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 * }, * }>, * }, * mailer?: bool|array{ // Mailer configuration - * enabled?: bool, // Default: true - * message_bus?: scalar|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null - * dsn?: scalar|null, // Default: null - * transports?: array, + * enabled?: bool|Param, // Default: true + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * dsn?: scalar|Param|null, // Default: null + * transports?: array, * envelope?: array{ // Mailer Envelope configuration - * sender?: scalar|null, - * recipients?: list, - * allowed_recipients?: list, + * sender?: scalar|Param|null, + * recipients?: string|list, + * allowed_recipients?: string|list, * }, * headers?: array, * dkim_signer?: bool|array{ // DKIM signer configuration - * enabled?: bool, // Default: false - * key?: scalar|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: "" - * domain?: scalar|null, // Default: "" - * select?: scalar|null, // Default: "" - * passphrase?: scalar|null, // The private key passphrase // Default: "" + * enabled?: bool|Param, // Default: false + * key?: scalar|Param|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: "" + * domain?: scalar|Param|null, // Default: "" + * select?: scalar|Param|null, // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: "" * options?: array, * }, * smime_signer?: bool|array{ // S/MIME signer configuration - * enabled?: bool, // Default: false - * key?: scalar|null, // Path to key (in PEM format) // Default: "" - * certificate?: scalar|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: "" - * passphrase?: scalar|null, // The private key passphrase // Default: null - * extra_certificates?: scalar|null, // Default: null - * sign_options?: int, // Default: null + * enabled?: bool|Param, // Default: false + * key?: scalar|Param|null, // Path to key (in PEM format) // Default: "" + * certificate?: scalar|Param|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: null + * extra_certificates?: scalar|Param|null, // Default: null + * sign_options?: int|Param, // Default: null * }, * smime_encrypter?: bool|array{ // S/MIME encrypter configuration - * enabled?: bool, // Default: false - * repository?: scalar|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: "" - * cipher?: int, // A set of algorithms used to encrypt the message // Default: null + * enabled?: bool|Param, // Default: false + * repository?: scalar|Param|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: "" + * cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null * }, * }, * secrets?: bool|array{ - * enabled?: bool, // Default: true - * vault_directory?: scalar|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%" - * local_dotenv_file?: scalar|null, // Default: "%kernel.project_dir%/.env.%kernel.runtime_environment%.local" - * decryption_env_var?: scalar|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET" + * enabled?: bool|Param, // Default: true + * vault_directory?: scalar|Param|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%" + * local_dotenv_file?: scalar|Param|null, // Default: "%kernel.project_dir%/.env.%kernel.environment%.local" + * decryption_env_var?: scalar|Param|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET" * }, * notifier?: bool|array{ // Notifier configuration - * enabled?: bool, // Default: false - * message_bus?: scalar|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null - * chatter_transports?: array, - * texter_transports?: array, - * notification_on_failed_messages?: bool, // Default: false - * channel_policy?: array>, + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * chatter_transports?: array, + * texter_transports?: array, + * notification_on_failed_messages?: bool|Param, // Default: false + * channel_policy?: array>, * admin_recipients?: list, * }, * rate_limiter?: bool|array{ // Rate limiter configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * limiters?: array, - * limit?: int, // The maximum allowed hits in a fixed interval or burst. - * interval?: scalar|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto" + * cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter" + * storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null + * policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter. + * limiters?: string|list, + * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. + * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". - * interval?: scalar|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). - * amount?: int, // Amount of tokens to add each interval. // Default: 1 + * interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * amount?: int|Param, // Amount of tokens to add each interval. // Default: 1 * }, * }>, * }, * uid?: bool|array{ // Uid configuration - * enabled?: bool, // Default: true - * default_uuid_version?: 7|6|4|1, // Default: 7 - * name_based_uuid_version?: 5|3, // Default: 5 - * name_based_uuid_namespace?: scalar|null, - * time_based_uuid_version?: 7|6|1, // Default: 7 - * time_based_uuid_node?: scalar|null, + * enabled?: bool|Param, // Default: true + * default_uuid_version?: 7|6|4|1|Param, // Default: 7 + * name_based_uuid_version?: 5|3|Param, // Default: 5 + * name_based_uuid_namespace?: scalar|Param|null, + * time_based_uuid_version?: 7|6|1|Param, // Default: 7 + * time_based_uuid_node?: scalar|Param|null, * }, * html_sanitizer?: bool|array{ // HtmlSanitizer configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * sanitizers?: array, - * block_elements?: list, - * drop_elements?: list, + * block_elements?: string|list, + * drop_elements?: string|list, * allow_attributes?: array, * drop_attributes?: array, - * force_attributes?: array>, - * force_https_urls?: bool, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false - * allowed_link_schemes?: list, - * allowed_link_hosts?: list|null, - * allow_relative_links?: bool, // Allows relative URLs to be used in links href attributes. // Default: false - * allowed_media_schemes?: list, - * allowed_media_hosts?: list|null, - * allow_relative_medias?: bool, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false - * with_attribute_sanitizers?: list, - * without_attribute_sanitizers?: list, - * max_input_length?: int, // The maximum length allowed for the sanitized input. // Default: 0 + * force_attributes?: array>, + * force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false + * allowed_link_schemes?: string|list, + * allowed_link_hosts?: null|string|list, + * allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false + * allowed_media_schemes?: string|list, + * allowed_media_hosts?: null|string|list, + * allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false + * with_attribute_sanitizers?: string|list, + * without_attribute_sanitizers?: string|list, + * max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0 * }>, * }, * webhook?: bool|array{ // Webhook configuration - * enabled?: bool, // Default: false - * message_bus?: scalar|null, // The message bus to use. // Default: "messenger.default_bus" + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus" * routing?: array, * }, * remote-event?: bool|array{ // RemoteEvent configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * }, * json_streamer?: bool|array{ // JSON streamer configuration - * enabled?: bool, // Default: false + * enabled?: bool|Param, // Default: false * }, * } * @psalm-type EcotoneConfig = array{ - * serviceName?: scalar|null, // Default: null - * cacheConfiguration?: bool, // Default: false - * failFast?: bool, // Default: false - * test?: bool, // Default: false - * loadSrcNamespaces?: bool, // Default: true - * defaultSerializationMediaType?: scalar|null, // Default: null - * defaultErrorChannel?: scalar|null, // Default: null - * namespaces?: list, - * defaultMemoryLimit?: int, // Default: null + * serviceName?: scalar|Param|null, // Default: null + * cacheConfiguration?: bool|Param, // Default: false + * failFast?: bool|Param, // Default: false + * test?: bool|Param, // Default: false + * loadSrcNamespaces?: bool|Param, // Default: true + * defaultSerializationMediaType?: scalar|Param|null, // Default: null + * defaultErrorChannel?: scalar|Param|null, // Default: null + * namespaces?: list, + * defaultMemoryLimit?: int|Param, // Default: null * defaultConnectionExceptionRetry?: array{ - * initialDelay: int, - * maxAttempts: int, - * multiplier: int, + * initialDelay?: int|Param, + * maxAttempts?: int|Param, + * multiplier?: int|Param, * }, - * licenceKey?: scalar|null, // Default: null - * skippedModulePackageNames?: list, + * licenceKey?: scalar|Param|null, // Default: null + * skippedModulePackageNames?: list, * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, @@ -736,7 +738,10 @@ final class App */ public static function config(array $config): array { - return AppReference::config($config); + /** @var ConfigType $config */ + $config = AppReference::config($config); + + return $config; } } diff --git a/docker-compose.yml b/docker-compose.yml index 1b0132b73..0347271df 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,10 +81,12 @@ services: - '${RABBITMQ_PORT:-0}:5672' - '${RABBITMQ_MGMT_PORT:-0}:15672' localstack: - image: localstack/localstack:3.0.0 + image: localstack/localstack:3.8 environment: LOCALSTACK_HOST: 'localstack' SERVICES: 'sqs,sns' + SQS_DISABLE_CLOUDWATCH_METRICS: '1' + EAGER_SERVICE_LOADING: '1' ports: - "${LOCALSTACK_PORT:-0}:4566" # LocalStack Gateway # - "4510-4559:4510-4559" # external services port range diff --git a/packages/Amqp/src/AmqpBackedMessageChannelBuilder.php b/packages/Amqp/src/AmqpBackedMessageChannelBuilder.php index ec7a392e7..3aa4a822b 100644 --- a/packages/Amqp/src/AmqpBackedMessageChannelBuilder.php +++ b/packages/Amqp/src/AmqpBackedMessageChannelBuilder.php @@ -30,6 +30,7 @@ private function __construct( ->withDefaultRoutingKey($queueName) ->withAutoDeclareOnSend(true) ->withDefaultPersistentMode(true) + ->withAsyncPublishingChannelName($channelName) ); } @@ -71,6 +72,13 @@ public function withPublisherConfirms(bool $enabled): self return $this; } + public function withAsyncPublishing(bool $enabled = true, ?int $timeoutInMilliseconds = null): self + { + $this->getAmqpOutboundChannelAdapter()->withAsyncPublishing($enabled, $timeoutInMilliseconds); + + return $this; + } + public function withDelayStrategy(string $delayStrategyReferenceName): self { $this->getAmqpOutboundChannelAdapter()->withDelayStrategy($delayStrategyReferenceName); @@ -83,6 +91,11 @@ public function getMessageChannelName(): string return $this->channelName; } + protected function supportsBatchMessages(): bool + { + return $this->getAmqpOutboundChannelAdapter()->isAsyncPublishingEnabled(); + } + public function getQueueName() { return $this->getInboundChannelAdapter()->getMessageChannelName(); diff --git a/packages/Amqp/src/AmqpOutboundChannelAdapter.php b/packages/Amqp/src/AmqpOutboundChannelAdapter.php index 0639fa791..5bbb01105 100644 --- a/packages/Amqp/src/AmqpOutboundChannelAdapter.php +++ b/packages/Amqp/src/AmqpOutboundChannelAdapter.php @@ -6,16 +6,26 @@ use Ecotone\Amqp\Transaction\AmqpTransactionInterceptor; use Ecotone\Enqueue\CachedConnectionFactory; +use Ecotone\Messaging\BatchMessage; +use Ecotone\Messaging\Channel\AsyncPublishing\AsyncPublishingRegistry; +use Ecotone\Messaging\Channel\AsyncPublishing\FailedDelivery; +use Ecotone\Messaging\Channel\AsyncPublishing\PublishingFailedException; use Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessageConverter; +use Ecotone\Messaging\Config\ConfigurationException; use Ecotone\Messaging\Conversion\ConversionService; use Ecotone\Messaging\Message; use Ecotone\Messaging\MessageHandler; use Ecotone\Messaging\Support\Assert; +use Ecotone\Messaging\Support\MessageBuilder; use Enqueue\AmqpExt\AmqpContext as AmqpExtContext; use Enqueue\AmqpLib\AmqpContext as AmqpLibContext; use Enqueue\AmqpTools\DelayStrategy; +use Interop\Amqp\AmqpContext as InteropAmqpContext; use Interop\Amqp\AmqpMessage; use Interop\Amqp\Impl\AmqpTopic; +use PhpAmqpLib\Message\AMQPMessage as LibAMQPMessage; +use PhpAmqpLib\Wire\AMQPTable; +use RuntimeException; /** * @author Dariusz Gafka @@ -44,7 +54,11 @@ public function __construct( private OutboundMessageConverter $outboundMessageConverter, private ConversionService $conversionService, private AmqpTransactionInterceptor $amqpTransactionInterceptor, - private ?DelayStrategy $delayStrategy = null + private AsyncPublishingRegistry $asyncPublishingRegistry, + private ?DelayStrategy $delayStrategy = null, + private bool $asyncPublishing = false, + private int $asyncPublishingTimeout = AmqpOutboundChannelAdapterBuilder::DEFAULT_ASYNC_PUBLISHING_TIMEOUT, + private string $channelName = '', ) { } @@ -52,6 +66,163 @@ public function __construct( * @inheritDoc */ public function handle(Message $message): void + { + $payload = $message->getPayload(); + if ($payload instanceof BatchMessage && ! $this->asyncPublishing) { + throw ConfigurationException::create(sprintf('Sending BatchMessage over `%s` requires async publishing to be enabled. Enable it with withAsyncPublishing(), available as part of Ecotone Enterprise.', $this->channelName !== '' ? $this->channelName : $this->exchangeName)); + } + + $messagesToPublish = $payload instanceof BatchMessage + ? array_map( + fn (array $entry): Message => MessageBuilder::withPayload($entry['payload'])->setMultipleHeaders($entry['headers'])->build(), + $payload->getEntries(), + ) + : [$message]; + + if ($messagesToPublish === []) { + return; + } + + $context = $this->connectionFactory->createContext(); + $confirmations = $this->getPublisherConfirmations(); + $prePublishConfirmationsEpoch = $confirmations?->getEpoch() ?? 0; + + $publishRecords = $this->publishMessages($messagesToPublish, $context, $confirmations); + + if ($publishRecords !== [] && $confirmations !== null && $this->canPublishAsynchronously()) { + $this->registerPendingDelivery($publishRecords, $context, $confirmations, $prePublishConfirmationsEpoch); + + return; + } + + $this->awaitPublisherConfirmsSynchronously($publishRecords, $context, $confirmations, $prePublishConfirmationsEpoch); + } + + public function isAsyncPublishingEnabled(): bool + { + return $this->asyncPublishing; + } + + /** + * @param Message[] $messages + * @return array + */ + private function publishMessages(array $messages, InteropAmqpContext $context, ?AmqpPublisherConfirmations $confirmations): array + { + if ($context instanceof AmqpLibContext) { + return $this->publishThroughSingleBatchWrite($messages, $context, $confirmations); + } + + $publishRecords = []; + foreach ($messages as $message) { + $publishRecords[] = $this->publish($message, $context, $confirmations); + } + + return array_values(array_filter($publishRecords)); + } + + /** + * @param Message[] $messages + * @return array + */ + private function publishThroughSingleBatchWrite(array $messages, AmqpLibContext $context, ?AmqpPublisherConfirmations $confirmations): array + { + $preparedEntries = []; + $delayedMessages = []; + foreach ($messages as $message) { + [$interopMessage, $exchangeName, $deliveryDelay] = $this->prepareInteropMessage($message); + + if ($deliveryDelay) { + $delayedMessages[] = $message; + + continue; + } + + $preparedEntries[] = [$message, $interopMessage, $exchangeName]; + } + + $publishRecords = []; + foreach ($delayedMessages as $delayedMessage) { + $publishRecords[] = $this->publish($delayedMessage, $context, $confirmations); + } + + $libChannel = $context->getLibChannel(); + foreach ($preparedEntries as [$message, $interopMessage, $exchangeName]) { + $libChannel->batch_basic_publish( + $this->convertToLibMessage($interopMessage), + $exchangeName, + $interopMessage->getRoutingKey() ?? '', + mandatory: (bool) ($interopMessage->getFlags() & AmqpMessage::FLAG_MANDATORY), + ); + $publishRecords[] = $this->recordPublishedMessage($message, $interopMessage, $context, $confirmations); + } + + if ($preparedEntries !== []) { + $libChannel->publish_batch(); + } + + return array_values(array_filter($publishRecords)); + } + + private function convertToLibMessage(AmqpMessage $interopMessage): LibAMQPMessage + { + $amqpProperties = $interopMessage->getHeaders(); + if ($applicationProperties = $interopMessage->getProperties()) { + $amqpProperties['application_headers'] = new AMQPTable($applicationProperties); + } + + return new LibAMQPMessage($interopMessage->getBody(), $amqpProperties); + } + + /** + * @return array{message: Message, deliveryTag: int, correlationId: string}|null + */ + private function publish(Message $message, InteropAmqpContext $context, ?AmqpPublisherConfirmations $confirmations): ?array + { + [$messageToSend, $exchangeName, $deliveryDelay, $timeToLive] = $this->prepareInteropMessage($message); + + $this->connectionFactory->getProducer() + ->setTimeToLive($timeToLive) + ->setDelayStrategy($this->delayStrategy ??= new HeadersExchangeDelayStrategy()) + ->setDeliveryDelay($deliveryDelay) +// this allow for having queue per delay instead of queue per delay + exchangeName + ->send(new AmqpTopic($exchangeName), $messageToSend); + + return $this->recordPublishedMessage($message, $messageToSend, $context, $confirmations); + } + + /** + * @return array{message: Message, deliveryTag: int, correlationId: string}|null + */ + private function recordPublishedMessage(Message $message, AmqpMessage $interopMessage, InteropAmqpContext $context, ?AmqpPublisherConfirmations $confirmations): ?array + { + if (! $this->publisherConfirms || $confirmations === null) { + return null; + } + + $correlationId = (string) $interopMessage->getProperty(AmqpPublisherConfirmations::PUBLISH_BATCH_ID_PROPERTY, ''); + $resolveTagThroughCorrelation = $context instanceof AmqpLibContext; + + return [ + 'message' => $message, + 'deliveryTag' => $confirmations->recordPublishedMessage($resolveTagThroughCorrelation ? $correlationId : ''), + 'correlationId' => $correlationId, + ]; + } + + private function getPublisherConfirmations(): ?AmqpPublisherConfirmations + { + $innerConnectionFactory = $this->connectionFactory->getInnerConnectionFactory(); + + return $innerConnectionFactory instanceof AmqpReconnectableConnectionFactory + ? $innerConnectionFactory->getPublisherConfirmations() + : null; + } + + /** + * @return array{0: \Interop\Amqp\Impl\AmqpMessage, 1: string, 2: int|null, 3: int|null} + */ + private function prepareInteropMessage(Message $message): array { $exchangeName = $this->exchangeName; if ($this->exchangeFromHeaderName) { @@ -79,27 +250,88 @@ public function handle(Message $message): void $messageToSend->setRoutingKey($routingKey); } + $timeToLive = $outboundMessage->getTimeToLive(); + if ($timeToLive !== null && $messageToSend->getExpiration() === null) { + $messageToSend->setExpiration($timeToLive); + } + $messageToSend ->setDeliveryMode($this->defaultPersistentDelivery ? AmqpMessage::DELIVERY_MODE_PERSISTENT : AmqpMessage::DELIVERY_MODE_NON_PERSISTENT); if ($this->publisherConfirms) { Assert::isFalse($this->amqpTransactionInterceptor->isRunningInTransaction(), 'Cannot use publisher acknowledgments together with transactions. Please disable one of them.'); + $messageToSend->addFlag(AmqpMessage::FLAG_MANDATORY); + $messageToSend->setProperty(AmqpPublisherConfirmations::PUBLISH_BATCH_ID_PROPERTY, bin2hex(random_bytes(8))); } - $context = $this->connectionFactory->createContext(); - $this->connectionFactory->getProducer() - ->setTimeToLive($outboundMessage->getTimeToLive()) - ->setDelayStrategy($this->delayStrategy ?? new HeadersExchangeDelayStrategy()) - ->setDeliveryDelay($outboundMessage->getDeliveryDelay()) -// this allow for having queue per delay instead of queue per delay + exchangeName - ->send(new AmqpTopic($exchangeName), $messageToSend); + return [$messageToSend, $exchangeName, $outboundMessage->getDeliveryDelay(), $timeToLive]; + } + + private function canPublishAsynchronously(): bool + { + return $this->asyncPublishing + && $this->publisherConfirms + && $this->asyncPublishingRegistry->isScopeActive(); + } + + /** + * @param array $publishRecords + */ + private function registerPendingDelivery(array $publishRecords, InteropAmqpContext $context, AmqpPublisherConfirmations $confirmations, int $prePublishConfirmationsEpoch): void + { + $this->asyncPublishingRegistry->register( + $this->channelName, + new AmqpPendingDelivery( + $context, + $publishRecords, + $this->asyncPublishingTimeout, + $this->channelName, + $confirmations, + $prePublishConfirmationsEpoch, + ), + ); + } - if ($this->publisherConfirms && ! $this->amqpTransactionInterceptor->isRunningInTransaction()) { + /** + * @param array $publishRecords + */ + private function awaitPublisherConfirmsSynchronously(array $publishRecords, InteropAmqpContext $context, ?AmqpPublisherConfirmations $confirmations, int $prePublishConfirmationsEpoch): void + { + if (! $this->publisherConfirms || $this->amqpTransactionInterceptor->isRunningInTransaction()) { + return; + } + + if ($publishRecords === [] || $confirmations === null) { + $timeoutInSeconds = $this->asyncPublishingTimeout / 1000; if ($context instanceof AmqpLibContext) { - $context->getLibChannel()->wait_for_pending_acks(5); + $context->getLibChannel()->wait_for_pending_acks_returns($timeoutInSeconds); } elseif ($context instanceof AmqpExtContext) { - $context->getExtChannel()->waitForConfirm(5); + $context->getExtChannel()->waitForConfirm($timeoutInSeconds); } + + return; } + + $deliveryResult = (new AmqpPendingDelivery( + $context, + $publishRecords, + $this->asyncPublishingTimeout, + $this->channelName, + $confirmations, + $prePublishConfirmationsEpoch, + ))->awaitDelivery(); + + if ($deliveryResult->isSuccessful()) { + return; + } + + if ($this->asyncPublishing) { + throw PublishingFailedException::withFailedDeliveries($deliveryResult->getFailedDeliveries()); + } + + throw new RuntimeException(implode('; ', array_unique(array_map( + fn (FailedDelivery $failedDelivery): string => $failedDelivery->getFailureReason(), + $deliveryResult->getFailedDeliveries(), + )))); } } diff --git a/packages/Amqp/src/AmqpOutboundChannelAdapterBuilder.php b/packages/Amqp/src/AmqpOutboundChannelAdapterBuilder.php index fe7020749..002b5a577 100644 --- a/packages/Amqp/src/AmqpOutboundChannelAdapterBuilder.php +++ b/packages/Amqp/src/AmqpOutboundChannelAdapterBuilder.php @@ -7,11 +7,14 @@ use Ecotone\Amqp\Transaction\AmqpTransactionInterceptor; use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Enqueue\EnqueueOutboundChannelAdapterBuilder; +use Ecotone\Messaging\Channel\AsyncPublishing\AsyncPublishingRegistry; use Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessageConverter; use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\Container\MessagingContainerBuilder; use Ecotone\Messaging\Config\Container\Reference; use Ecotone\Messaging\Conversion\ConversionService; +use Ecotone\Messaging\Support\Assert; +use Ecotone\Messaging\Support\LicensingException; /** * licence Apache-2.0 @@ -20,6 +23,8 @@ class AmqpOutboundChannelAdapterBuilder extends EnqueueOutboundChannelAdapterBui { private const DEFAULT_PERSISTENT_MODE = true; + public const DEFAULT_ASYNC_PUBLISHING_TIMEOUT = 12000; + private string $amqpConnectionFactoryReferenceName; private string $defaultRoutingKey = ''; private ?string $routingKeyFromHeader = null; @@ -29,6 +34,9 @@ class AmqpOutboundChannelAdapterBuilder extends EnqueueOutboundChannelAdapterBui private array $staticHeadersToAdd = []; private bool $publisherConfirms = true; private ?string $delayStrategyReferenceName = null; + private bool $asyncPublishing = false; + private int $asyncPublishingTimeout = self::DEFAULT_ASYNC_PUBLISHING_TIMEOUT; + private ?string $asyncPublishingChannelName = null; private function __construct(string $exchangeName, string $amqpConnectionFactoryReferenceName) { @@ -66,6 +74,29 @@ public function withPublisherConfirms(bool $publisherConfirms): self return $this; } + public function withAsyncPublishing(bool $enabled = true, ?int $timeoutInMilliseconds = null): self + { + Assert::isTrue($timeoutInMilliseconds === null || $timeoutInMilliseconds > 0, 'Async publishing timeout must be a positive amount of milliseconds.'); + $this->asyncPublishing = $enabled; + if ($timeoutInMilliseconds !== null) { + $this->asyncPublishingTimeout = $timeoutInMilliseconds; + } + + return $this; + } + + public function isAsyncPublishingEnabled(): bool + { + return $this->asyncPublishing; + } + + public function withAsyncPublishingChannelName(string $channelName): self + { + $this->asyncPublishingChannelName = $channelName; + + return $this; + } + public function withDelayStrategy(string $delayStrategyReferenceName): self { $this->delayStrategyReferenceName = $delayStrategyReferenceName; @@ -118,6 +149,13 @@ public function withDefaultPersistentMode(bool $isPersistent): self public function compile(MessagingContainerBuilder $builder): Definition { + if ($this->asyncPublishing) { + if (! $builder->getServiceConfiguration()->isRunningForEnterprise()) { + throw LicensingException::create('Asynchronous publishing is available only with Ecotone Enterprise licence.'); + } + Assert::isTrue($this->publisherConfirms, 'Asynchronous publishing requires publisher confirms to be enabled.'); + } + $connectionFactory = new Definition(CachedConnectionFactory::class, [ new Definition(AmqpReconnectableConnectionFactory::class, [ new Reference($this->amqpConnectionFactoryReferenceName), @@ -148,7 +186,11 @@ public function compile(MessagingContainerBuilder $builder): Definition $outboundMessageConverter, new Reference(ConversionService::REFERENCE_NAME), Reference::to(AmqpTransactionInterceptor::class), + new Reference(AsyncPublishingRegistry::class), $this->delayStrategyReferenceName ? new Reference($this->delayStrategyReferenceName) : null, + $this->asyncPublishing, + $this->asyncPublishingTimeout, + $this->asyncPublishingChannelName ?? $this->exchangeName, ]); } } diff --git a/packages/Amqp/src/AmqpPendingDelivery.php b/packages/Amqp/src/AmqpPendingDelivery.php new file mode 100644 index 000000000..4c010a9a0 --- /dev/null +++ b/packages/Amqp/src/AmqpPendingDelivery.php @@ -0,0 +1,142 @@ + $publishRecords + */ + public function __construct( + private AmqpContext $context, + private array $publishRecords, + private int $timeoutInMilliseconds, + private string $channelName, + private AmqpPublisherConfirmations $confirmations, + private int $confirmationsEpoch, + ) { + } + + public function awaitDelivery(): DeliveryResult + { + if ($this->deliveryResult !== null) { + return $this->deliveryResult; + } + + $this->awaited = true; + $deadline = microtime(true) + $this->timeoutInMilliseconds / 1000; + $unsettledFailureReason = self::TIMED_OUT_FAILURE_REASON; + + while (true) { + if ($this->confirmations->getEpoch() !== $this->confirmationsEpoch) { + return $this->deliveryResult = $this->failAllRecords(self::CONNECTION_RESET_FAILURE_REASON); + } + + if ($this->allRecordsSettled()) { + break; + } + + $remainingSeconds = $deadline - microtime(true); + if ($remainingSeconds <= 0) { + break; + } + + try { + $this->pumpConfirmations($remainingSeconds); + } catch (AMQPTimeoutException) { + continue; + } catch (Throwable $exception) { + $unsettledFailureReason = $exception->getMessage(); + + break; + } + } + + return $this->deliveryResult = $this->collectResult($unsettledFailureReason); + } + + private function failAllRecords(string $failureReason): DeliveryResult + { + if ($this->publishRecords === []) { + return DeliveryResult::successful(); + } + + return DeliveryResult::withFailedDeliveries(array_map( + fn (array $publishRecord): FailedDelivery => new FailedDelivery($publishRecord['message'], $failureReason, $this->channelName), + $this->publishRecords, + )); + } + + public function isAwaited(): bool + { + return $this->awaited; + } + + private function allRecordsSettled(): bool + { + foreach ($this->publishRecords as $publishRecord) { + if (! $this->confirmations->isSettled($publishRecord['deliveryTag'])) { + return false; + } + } + + return true; + } + + private function pumpConfirmations(float $remainingSeconds): void + { + if ($this->context instanceof AmqpLibContext) { + $this->context->getLibChannel()->wait_for_pending_acks_returns($remainingSeconds); + } elseif ($this->context instanceof AmqpExtContext) { + $this->context->getExtChannel()->waitForConfirm($remainingSeconds); + } + } + + private function collectResult(string $unsettledFailureReason): DeliveryResult + { + $failedDeliveries = []; + foreach ($this->publishRecords as $publishRecord) { + $returnReason = $this->confirmations->takeReturnReason($publishRecord['correlationId']); + if ($returnReason !== null) { + $failedDeliveries[] = new FailedDelivery($publishRecord['message'], $returnReason, $this->channelName); + + continue; + } + + if ($this->confirmations->takeRejection($publishRecord['deliveryTag'])) { + $failedDeliveries[] = new FailedDelivery($publishRecord['message'], self::REJECTED_FAILURE_REASON, $this->channelName); + + continue; + } + + if (! $this->confirmations->isSettled($publishRecord['deliveryTag'])) { + $failedDeliveries[] = new FailedDelivery($publishRecord['message'], $unsettledFailureReason, $this->channelName); + } + } + + return $failedDeliveries === [] ? DeliveryResult::successful() : DeliveryResult::withFailedDeliveries($failedDeliveries); + } +} diff --git a/packages/Amqp/src/AmqpPublisherConfirmations.php b/packages/Amqp/src/AmqpPublisherConfirmations.php new file mode 100644 index 000000000..cc5a172e9 --- /dev/null +++ b/packages/Amqp/src/AmqpPublisherConfirmations.php @@ -0,0 +1,152 @@ + */ + private array $individuallySettledTags = []; + + /** @var array */ + private array $rejectedTags = []; + + /** @var array */ + private array $deliveryTagsByCorrelationId = []; + + /** @var array */ + private array $returnReasonsByCorrelationId = []; + + public function recordPublishedMessage(string $correlationId = ''): int + { + $deliveryTag = ++$this->lastPublishedDeliveryTag; + if ($correlationId !== '') { + $this->deliveryTagsByCorrelationId[$correlationId] = $deliveryTag; + } + + return $deliveryTag; + } + + public function recordConfirmation(int $deliveryTag, bool $multiple): void + { + $this->settle($deliveryTag, $multiple); + } + + public function recordConfirmationForCorrelation(string $correlationId): void + { + $deliveryTag = $this->takeDeliveryTagForCorrelation($correlationId); + if ($deliveryTag !== null) { + $this->settle($deliveryTag, multiple: false); + } + } + + public function recordRejection(int $deliveryTag, bool $multiple): void + { + if ($multiple) { + for ($rejectedTag = $this->settledWatermark + 1; $rejectedTag <= $deliveryTag; $rejectedTag++) { + if (! isset($this->individuallySettledTags[$rejectedTag])) { + $this->rejectedTags[$rejectedTag] = true; + } + } + } else { + $this->rejectedTags[$deliveryTag] = true; + } + + $this->settle($deliveryTag, $multiple); + } + + public function recordRejectionForCorrelation(string $correlationId): void + { + $deliveryTag = $this->takeDeliveryTagForCorrelation($correlationId); + if ($deliveryTag !== null) { + $this->recordRejection($deliveryTag, multiple: false); + } + } + + public function recordReturnedMessage(string $correlationId, string $reason): void + { + if ($correlationId !== '') { + $this->returnReasonsByCorrelationId[$correlationId] = $reason; + } + } + + public function isSettled(int $deliveryTag): bool + { + return $deliveryTag <= $this->settledWatermark || isset($this->individuallySettledTags[$deliveryTag]); + } + + public function takeRejection(int $deliveryTag): bool + { + $wasRejected = isset($this->rejectedTags[$deliveryTag]); + unset($this->rejectedTags[$deliveryTag]); + + return $wasRejected; + } + + public function takeReturnReason(string $correlationId): ?string + { + $reason = $this->returnReasonsByCorrelationId[$correlationId] ?? null; + unset($this->returnReasonsByCorrelationId[$correlationId]); + + return $reason; + } + + public function hasOutstandingConfirmations(): bool + { + return $this->lastPublishedDeliveryTag > $this->settledWatermark + count($this->individuallySettledTags); + } + + public function reset(): void + { + $this->epoch++; + $this->lastPublishedDeliveryTag = 0; + $this->settledWatermark = 0; + $this->individuallySettledTags = []; + $this->rejectedTags = []; + $this->deliveryTagsByCorrelationId = []; + $this->returnReasonsByCorrelationId = []; + } + + public function getEpoch(): int + { + return $this->epoch; + } + + private function takeDeliveryTagForCorrelation(string $correlationId): ?int + { + $deliveryTag = $this->deliveryTagsByCorrelationId[$correlationId] ?? null; + unset($this->deliveryTagsByCorrelationId[$correlationId]); + + return $deliveryTag; + } + + private function settle(int $deliveryTag, bool $multiple): void + { + if ($multiple) { + $this->settledWatermark = max($this->settledWatermark, $deliveryTag); + foreach ($this->individuallySettledTags as $settledTag => $settled) { + if ($settledTag <= $this->settledWatermark) { + unset($this->individuallySettledTags[$settledTag]); + } + } + + return; + } + + if ($deliveryTag > $this->settledWatermark) { + $this->individuallySettledTags[$deliveryTag] = true; + } + } +} diff --git a/packages/Amqp/src/AmqpReconnectableConnectionFactory.php b/packages/Amqp/src/AmqpReconnectableConnectionFactory.php index 205a4c934..0bf384182 100644 --- a/packages/Amqp/src/AmqpReconnectableConnectionFactory.php +++ b/packages/Amqp/src/AmqpReconnectableConnectionFactory.php @@ -2,6 +2,7 @@ namespace Ecotone\Amqp; +use AMQPBasicProperties; use AMQPConnection; use Ecotone\Enqueue\ReconnectableConnectionFactory; use Enqueue\AmqpExt\AmqpConnectionFactory as AmqpExtConnectionFactory; @@ -16,9 +17,10 @@ use Interop\Queue\SubscriptionConsumer; use PhpAmqpLib\Channel\AMQPChannel as LibAMQPChannel; use PhpAmqpLib\Connection\AMQPLazyConnection; +use PhpAmqpLib\Message\AMQPMessage as LibAMQPMessage; +use PhpAmqpLib\Wire\AMQPTable; use ReflectionClass; use ReflectionProperty; -use RuntimeException; /** * licence Apache-2.0 @@ -28,6 +30,7 @@ class AmqpReconnectableConnectionFactory implements ReconnectableConnectionFacto private string $connectionInstanceId; private AmqpConnectionFactory $connectionFactory; private ?SubscriptionConsumer $subscriptionConsumer = null; + private ?AmqpPublisherConfirmations $publisherConfirmations = null; public function __construct(AmqpExtConnectionFactory|AmqpLibConnectionFactory $connectionFactory, ?string $connectionInstanceId = null, private bool $publisherConfirms = false) { @@ -49,20 +52,66 @@ public function createContext(): Context $context = $this->connectionFactory->createContext(); if ($this->publisherConfirms) { + $confirmations = $this->getPublisherConfirmations(); + $confirmations->reset(); if ($context instanceof AmqpLibContext) { $context->getLibChannel()->confirm_select(); + $context->getLibChannel()->set_ack_handler(fn (LibAMQPMessage $message) => $confirmations->recordConfirmationForCorrelation(self::publishCorrelationIdFrom($message))); + $context->getLibChannel()->set_nack_handler(fn (LibAMQPMessage $message) => $confirmations->recordRejectionForCorrelation(self::publishCorrelationIdFrom($message))); + $context->getLibChannel()->set_return_listener(function (int $replyCode, string $replyText, string $exchange, string $routingKey, LibAMQPMessage $message) use ($confirmations): void { + $confirmations->recordReturnedMessage( + self::publishCorrelationIdFrom($message), + sprintf('Message was returned as unroutable by RabbitMQ instance (%d %s) for exchange `%s` and routing key `%s`.', $replyCode, $replyText, $exchange, $routingKey), + ); + }); } elseif ($context instanceof AmqpExtContext) { $context->getExtChannel()->confirmSelect(); - $context->getExtChannel()->setConfirmCallback(fn () => false, fn () => throw new RuntimeException('Message was failed to be persisted in RabbitMQ instance. Check RabbitMQ server logs.')); + $context->getExtChannel()->setReturnCallback(function (int $replyCode, string $replyText, string $exchange, string $routingKey, AMQPBasicProperties $properties) use ($confirmations): bool { + $confirmations->recordReturnedMessage( + (string) ($properties->getHeaders()[AmqpPublisherConfirmations::PUBLISH_BATCH_ID_PROPERTY] ?? ''), + sprintf('Message was returned as unroutable by RabbitMQ instance (%d %s) for exchange `%s` and routing key `%s`.', $replyCode, $replyText, $exchange, $routingKey), + ); + + return true; + }); + $context->getExtChannel()->setConfirmCallback( + function (int $deliveryTag, bool $multiple) use ($confirmations): bool { + $confirmations->recordConfirmation($deliveryTag, $multiple); + + return $confirmations->hasOutstandingConfirmations(); + }, + function (int $deliveryTag, bool $multiple) use ($confirmations): bool { + $confirmations->recordRejection($deliveryTag, $multiple); + + return $confirmations->hasOutstandingConfirmations(); + } + ); } } return $context; } + public function getPublisherConfirmations(): AmqpPublisherConfirmations + { + return $this->publisherConfirmations ??= new AmqpPublisherConfirmations(); + } + + private static function publishCorrelationIdFrom(LibAMQPMessage $message): string + { + $applicationHeaders = $message->get_properties()['application_headers'] ?? null; + if ($applicationHeaders instanceof AMQPTable) { + $applicationHeaders = $applicationHeaders->getNativeData(); + } + + $applicationHeaders = (array) $applicationHeaders; + + return (string) ($applicationHeaders[AmqpPublisherConfirmations::PUBLISH_BATCH_ID_PROPERTY] ?? ''); + } + public function getConnectionInstanceId(): string { - return get_class($this->connectionFactory) . $this->connectionInstanceId; + return get_class($this->connectionFactory) . $this->connectionInstanceId . ($this->publisherConfirms ? '.confirms' : ''); } /** diff --git a/packages/Amqp/src/Publisher/AmqpMessagePublisherConfiguration.php b/packages/Amqp/src/Publisher/AmqpMessagePublisherConfiguration.php index a846e6e9b..e3fa17a60 100644 --- a/packages/Amqp/src/Publisher/AmqpMessagePublisherConfiguration.php +++ b/packages/Amqp/src/Publisher/AmqpMessagePublisherConfiguration.php @@ -3,6 +3,7 @@ namespace Ecotone\Amqp\Publisher; use Ecotone\Messaging\MessagePublisher; +use Ecotone\Messaging\Support\Assert; use Enqueue\AmqpExt\AmqpConnectionFactory; /** @@ -52,6 +53,10 @@ class AmqpMessagePublisherConfiguration */ private $defaultPersistentDelivery = true; + private bool $asyncPublishing = false; + + private ?int $asyncPublishingTimeout = null; + private function __construct(string $connectionReference, string $exchangeName, ?string $outputDefaultConversionMediaType, string $referenceName) { $this->connectionReference = $connectionReference; @@ -150,6 +155,27 @@ public function getDefaultPersistentDelivery(): bool return $this->defaultPersistentDelivery; } + public function withAsyncPublishing(bool $enabled = true, ?int $timeoutInMilliseconds = null): AmqpMessagePublisherConfiguration + { + Assert::isTrue($timeoutInMilliseconds === null || $timeoutInMilliseconds > 0, 'Async publishing timeout must be a positive amount of milliseconds.'); + $this->asyncPublishing = $enabled; + if ($timeoutInMilliseconds !== null) { + $this->asyncPublishingTimeout = $timeoutInMilliseconds; + } + + return $this; + } + + public function isAsyncPublishingEnabled(): bool + { + return $this->asyncPublishing; + } + + public function getAsyncPublishingTimeout(): ?int + { + return $this->asyncPublishingTimeout; + } + /** * @return bool */ diff --git a/packages/Amqp/src/Publisher/AmqpMessagePublisherModule.php b/packages/Amqp/src/Publisher/AmqpMessagePublisherModule.php index 6bf12efb2..b08a4bd6d 100644 --- a/packages/Amqp/src/Publisher/AmqpMessagePublisherModule.php +++ b/packages/Amqp/src/Publisher/AmqpMessagePublisherModule.php @@ -5,6 +5,7 @@ use Ecotone\Amqp\AmqpOutboundChannelAdapterBuilder; use Ecotone\AnnotationFinder\AnnotationFinder; use Ecotone\Messaging\Attribute\ModuleAnnotation; +use Ecotone\Messaging\Channel\AsyncPublishing\Config\AsyncPublishGatewayRegistration; use Ecotone\Messaging\Config\Annotation\AnnotationModule; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ExtensionObjectResolver; use Ecotone\Messaging\Config\Configuration; @@ -88,7 +89,11 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO ->withDefaultRoutingKey($amqpPublisher->getDefaultRoutingKey()) ->withRoutingKeyFromHeader($amqpPublisher->getRoutingKeyFromHeader()) ->withDefaultConversionMediaType($mediaType) + ->withAsyncPublishing($amqpPublisher->isAsyncPublishingEnabled(), $amqpPublisher->getAsyncPublishingTimeout()) + ->withAsyncPublishingChannelName($amqpPublisher->getReferenceName()) ); + + AsyncPublishGatewayRegistration::registerFor($messagingConfiguration, $amqpPublisher->getReferenceName(), $amqpPublisher->isAsyncPublishingEnabled()); } } diff --git a/packages/Amqp/tests/Fixture/AsyncPublishing/OrderWasPlaced.php b/packages/Amqp/tests/Fixture/AsyncPublishing/OrderWasPlaced.php new file mode 100644 index 000000000..1787fab3b --- /dev/null +++ b/packages/Amqp/tests/Fixture/AsyncPublishing/OrderWasPlaced.php @@ -0,0 +1,15 @@ +getMessageChannelByName($queueName); $messageChannel->send( MessageBuilder::withPayload('some') - ->setHeader(MessageHeaders::DELIVERY_DELAY, 250) + ->setHeader(MessageHeaders::DELIVERY_DELAY, 1000) ->build() ); $this->assertNull($messageChannel->receiveWithTimeout(PollingMetadata::create('test')->setExecutionTimeLimitInMilliseconds(200))); - $this->assertNotNull($messageChannel->receiveWithTimeout(PollingMetadata::create('test')->setExecutionTimeLimitInMilliseconds(1000))); + $this->assertNotNull($messageChannel->receiveWithTimeout(PollingMetadata::create('test')->setExecutionTimeLimitInMilliseconds(5000))); } public function test_receiving_from_dead_letter_queue() diff --git a/packages/Amqp/tests/Integration/AmqpMessageChannelTest.php b/packages/Amqp/tests/Integration/AmqpMessageChannelTest.php index 870f8d228..579eba4de 100644 --- a/packages/Amqp/tests/Integration/AmqpMessageChannelTest.php +++ b/packages/Amqp/tests/Integration/AmqpMessageChannelTest.php @@ -229,7 +229,13 @@ public function test_failing_to_receive_message_when_not_declared() /** @var PollableChannel $messageChannel */ $messageChannel = $ecotoneLite->getMessageChannelByName($queueName); - $messageChannel->send(MessageBuilder::withPayload($messagePayload)->build()); + $sendFailed = false; + try { + $messageChannel->send(MessageBuilder::withPayload($messagePayload)->build()); + } catch (Throwable) { + $sendFailed = true; + } + $this->assertTrue($sendFailed); // AMQP Ext throws AMQPException, AMQP Lib throws AMQPProtocolChannelException $this->expectException(Throwable::class); diff --git a/packages/Amqp/tests/Integration/AmqpStreamChannelTest.php b/packages/Amqp/tests/Integration/AmqpStreamChannelTest.php index e7c345849..ec155e4f7 100644 --- a/packages/Amqp/tests/Integration/AmqpStreamChannelTest.php +++ b/packages/Amqp/tests/Integration/AmqpStreamChannelTest.php @@ -1363,8 +1363,8 @@ public function getConsumed(): array $publisherService->getDistributedBus()->publishEvent('distributed.event', 'event3'); // Both consumers should receive all events independently - $consumerService1->run($channelName, ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 10)); - $consumerService2->run($channelName, ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 10)); + $consumerService1->run($channelName, ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 3, maxExecutionTimeInMilliseconds: 10000)); + $consumerService2->run($channelName, ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 3, maxExecutionTimeInMilliseconds: 10000)); $this->assertEquals(['event1', 'event2', 'event3'], $consumerService1->getQueryBus()->sendWithRouting('getConsumed1')); $this->assertEquals(['event1', 'event2', 'event3'], $consumerService2->getQueryBus()->sendWithRouting('getConsumed2')); diff --git a/packages/Amqp/tests/Integration/AsyncPublishingReliabilityTest.php b/packages/Amqp/tests/Integration/AsyncPublishingReliabilityTest.php new file mode 100644 index 000000000..c566efbfa --- /dev/null +++ b/packages/Amqp/tests/Integration/AsyncPublishingReliabilityTest.php @@ -0,0 +1,283 @@ + getenv('RABBIT_HOST') ?: 'amqp://guest:guest@localhost:5672/%2f']); + $queueName = $this->declareQueueRejectingOverflow($libConnectionFactory); + $publisher = $this->bootstrapPublisher($libConnectionFactory, $queueName); + + $this->expectException(PublishingFailedException::class); + + $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('first message fills the queue') + ->append('second message overflows and gets nacked') + )->resolve(); + } + + public function test_nacked_message_fails_delivery_confirmation_over_amqp_ext(): void + { + $extConnectionFactory = new AmqpConnectionFactory(['dsn' => getenv('RABBIT_HOST') ?: 'amqp://guest:guest@localhost:5672/%2f']); + $queueName = $this->declareQueueRejectingOverflow($extConnectionFactory); + $publisher = $this->bootstrapPublisher($extConnectionFactory, $queueName); + + $this->expectException(PublishingFailedException::class); + + $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('first message fills the queue') + ->append('second message overflows and gets nacked') + )->resolve(); + } + + public function test_ext_publisher_confirmations_track_outstanding_until_all_confirmed(): void + { + $confirmations = new AmqpPublisherConfirmations(); + + $confirmations->recordPublishedMessage(); + $confirmations->recordPublishedMessage(); + $confirmations->recordPublishedMessage(); + $this->assertTrue($confirmations->hasOutstandingConfirmations()); + + $confirmations->recordConfirmation(1, multiple: false); + $this->assertTrue($confirmations->hasOutstandingConfirmations()); + + $confirmations->recordConfirmation(3, multiple: true); + $this->assertFalse($confirmations->hasOutstandingConfirmations()); + } + + public function test_ext_publisher_confirmations_handle_multiple_flag_covering_individual_confirmations(): void + { + $confirmations = new AmqpPublisherConfirmations(); + + $confirmations->recordPublishedMessage(); + $confirmations->recordPublishedMessage(); + + $confirmations->recordConfirmation(2, multiple: false); + $this->assertTrue($confirmations->hasOutstandingConfirmations()); + + $confirmations->recordConfirmation(2, multiple: true); + $this->assertFalse($confirmations->hasOutstandingConfirmations()); + } + + public function test_unroutable_message_fails_delivery_confirmation_over_amqp_lib(): void + { + $libConnectionFactory = new AmqpLibConnection(['dsn' => getenv('RABBIT_HOST') ?: 'amqp://guest:guest@localhost:5672/%2f']); + $publisher = $this->bootstrapPublisher($libConnectionFactory, Uuid::v7()->toRfc4122()); + + $this->expectException(PublishingFailedException::class); + + $publisher->asyncPublish('order that routes nowhere')->resolve(); + } + + public function test_unroutable_message_fails_delivery_confirmation_over_amqp_ext(): void + { + $extConnectionFactory = new AmqpConnectionFactory(['dsn' => getenv('RABBIT_HOST') ?: 'amqp://guest:guest@localhost:5672/%2f']); + $publisher = $this->bootstrapPublisher($extConnectionFactory, Uuid::v7()->toRfc4122()); + + $this->expectException(PublishingFailedException::class); + + $publisher->asyncPublish('order that routes nowhere')->resolve(); + } + + public function test_each_future_reports_outcome_of_its_own_message_when_sharing_channel(): void + { + $libConnectionFactory = new AmqpLibConnection(['dsn' => getenv('RABBIT_HOST') ?: 'amqp://guest:guest@localhost:5672/%2f']); + $queueName = $this->declareQueue($libConnectionFactory); + $publisher = $this->bootstrapPublisherWithRoutingKeyFromHeader($libConnectionFactory); + + $routableFuture = $publisher->asyncPublish('order that reaches the queue', metadata: ['routingKey' => $queueName]); + $unroutableFuture = $publisher->asyncPublish('order that routes nowhere', metadata: ['routingKey' => Uuid::v7()->toRfc4122()]); + + $routableFuture->resolve(); + + $this->expectException(PublishingFailedException::class); + + $unroutableFuture->resolve(); + } + + public function test_each_future_reports_outcome_of_its_own_message_when_sharing_channel_over_amqp_ext(): void + { + $extConnectionFactory = new AmqpConnectionFactory(['dsn' => getenv('RABBIT_HOST') ?: 'amqp://guest:guest@localhost:5672/%2f']); + $queueName = $this->declareQueue($extConnectionFactory); + $publisher = $this->bootstrapPublisherWithRoutingKeyFromHeader($extConnectionFactory); + + $routableFuture = $publisher->asyncPublish('order that reaches the queue', metadata: ['routingKey' => $queueName]); + $unroutableFuture = $publisher->asyncPublish('order that routes nowhere', metadata: ['routingKey' => Uuid::v7()->toRfc4122()]); + + $routableFuture->resolve(); + + $this->expectException(PublishingFailedException::class); + + $unroutableFuture->resolve(); + } + + public function test_nack_arriving_during_other_future_await_fails_only_nacked_future_over_amqp_lib(): void + { + $libConnectionFactory = new AmqpLibConnection(['dsn' => getenv('RABBIT_HOST') ?: 'amqp://guest:guest@localhost:5672/%2f']); + $normalQueue = $this->declareQueue($libConnectionFactory); + $overflowQueue = $this->declareQueueRejectingOverflow($libConnectionFactory); + $publisher = $this->bootstrapPublisherWithRoutingKeyFromHeader($libConnectionFactory); + + $publisher->asyncPublish('filler order', metadata: ['routingKey' => $overflowQueue])->resolve(); + + $deliveredFuture = $publisher->asyncPublish('delivered order', metadata: ['routingKey' => $normalQueue]); + $nackedFuture = $publisher->asyncPublish('nacked order', metadata: ['routingKey' => $overflowQueue]); + + $deliveredFuture->resolve(); + + $this->expectException(PublishingFailedException::class); + + $nackedFuture->resolve(); + } + + public function test_nack_arriving_during_other_future_await_fails_only_nacked_future_over_amqp_ext(): void + { + $extConnectionFactory = new AmqpConnectionFactory(['dsn' => getenv('RABBIT_HOST') ?: 'amqp://guest:guest@localhost:5672/%2f']); + $normalQueue = $this->declareQueue($extConnectionFactory); + $overflowQueue = $this->declareQueueRejectingOverflow($extConnectionFactory); + $publisher = $this->bootstrapPublisherWithRoutingKeyFromHeader($extConnectionFactory); + + $publisher->asyncPublish('filler order', metadata: ['routingKey' => $overflowQueue])->resolve(); + + $deliveredFuture = $publisher->asyncPublish('delivered order', metadata: ['routingKey' => $normalQueue]); + $nackedFuture = $publisher->asyncPublish('nacked order', metadata: ['routingKey' => $overflowQueue]); + + $deliveredFuture->resolve(); + + $this->expectException(PublishingFailedException::class); + + $nackedFuture->resolve(); + } + + public function test_only_failing_message_from_batch_is_reported_with_per_message_granularity(): void + { + $libConnectionFactory = new AmqpLibConnection(['dsn' => getenv('RABBIT_HOST') ?: 'amqp://guest:guest@localhost:5672/%2f']); + $queueName = $this->declareQueue($libConnectionFactory); + $publisher = $this->bootstrapPublisherWithRoutingKeyFromHeader($libConnectionFactory); + + $future = $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('first delivered order', ['routingKey' => $queueName]) + ->append('order that routes nowhere', ['routingKey' => Uuid::v7()->toRfc4122()]) + ->append('second delivered order', ['routingKey' => $queueName]) + ); + + try { + $future->resolve(); + $this->fail('Expected unroutable batch entry to fail the delivery'); + } catch (PublishingFailedException $exception) { + $failedDeliveries = $exception->getFailedDeliveries(); + $this->assertCount(1, $failedDeliveries); + $this->assertSame('order that routes nowhere', $failedDeliveries[0]->getMessage()->getPayload()); + $this->assertStringContainsString('NO_ROUTE', $failedDeliveries[0]->getFailureReason()); + } + + $context = $libConnectionFactory->createContext(); + $consumer = $context->createConsumer($context->createQueue($queueName)); + $this->assertNotNull($consumer->receive(2000)); + $this->assertNotNull($consumer->receive(2000)); + } + + public function test_ext_confirmations_reset_while_awaiting_is_detectable_through_epoch(): void + { + $confirmations = new AmqpPublisherConfirmations(); + $epochBeforeReset = $confirmations->getEpoch(); + $confirmations->recordPublishedMessage(); + + $confirmations->reset(); + + $this->assertNotSame($epochBeforeReset, $confirmations->getEpoch()); + $this->assertFalse($confirmations->hasOutstandingConfirmations()); + } + + private function declareQueue(AmqpLibConnection|AmqpConnectionFactory $connectionFactory): string + { + $queueName = Uuid::v7()->toRfc4122(); + $context = $connectionFactory->createContext(); + $queue = $context->createQueue($queueName); + $queue->addFlag(AmqpQueue::FLAG_DURABLE); + $context->declareQueue($queue); + + return $queueName; + } + + private function bootstrapPublisherWithRoutingKeyFromHeader(AmqpLibConnection|AmqpConnectionFactory $connectionFactory): MessagePublisher + { + $messaging = EcotoneLite::bootstrapFlowTesting( + [], + [ + AmqpConnectionFactory::class => $connectionFactory, + ], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::AMQP_PACKAGE])) + ->withExtensionObjects([ + AmqpMessagePublisherConfiguration::create() + ->withAutoDeclareQueueOnSend(false) + ->withRoutingKeyFromHeader('routingKey') + ->withAsyncPublishing(timeoutInMilliseconds: 3000), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + return $messaging->getGateway(MessagePublisher::class); + } + + private function declareQueueRejectingOverflow(AmqpLibConnection|AmqpConnectionFactory $connectionFactory): string + { + $queueName = Uuid::v7()->toRfc4122(); + $context = $connectionFactory->createContext(); + $queue = $context->createQueue($queueName); + $queue->addFlag(AmqpQueue::FLAG_DURABLE); + $queue->setArguments(['x-max-length' => 1, 'x-overflow' => 'reject-publish']); + $context->declareQueue($queue); + + return $queueName; + } + + private function bootstrapPublisher(AmqpLibConnection|AmqpConnectionFactory $connectionFactory, string $queueName): MessagePublisher + { + $messaging = EcotoneLite::bootstrapFlowTesting( + [], + [ + AmqpConnectionFactory::class => $connectionFactory, + ], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::AMQP_PACKAGE])) + ->withExtensionObjects([ + AmqpMessagePublisherConfiguration::create() + ->withAutoDeclareQueueOnSend(false) + ->withDefaultRoutingKey($queueName) + ->withAsyncPublishing(timeoutInMilliseconds: 3000), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + return $messaging->getGateway(MessagePublisher::class); + } +} diff --git a/packages/Amqp/tests/Integration/AsyncPublishingTest.php b/packages/Amqp/tests/Integration/AsyncPublishingTest.php new file mode 100644 index 000000000..799b4b32d --- /dev/null +++ b/packages/Amqp/tests/Integration/AsyncPublishingTest.php @@ -0,0 +1,317 @@ +toRfc4122(); + $orderService = $this->createOrderService($channelName); + $messaging = $this->bootstrapEcotone($channelName, $orderService, LicenceTesting::VALID_LICENCE); + + $messaging->sendCommandWithRoutingKey('order.place', 'espresso'); + + $this->assertSame([], $messaging->sendQueryWithRouting('order.getReceived')); + + $messaging->run('asyncOrdersChannel', ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 3, maxExecutionTimeInMilliseconds: 10000)); + + $this->assertCount(3, $messaging->sendQueryWithRouting('order.getReceived')); + } + + public function test_async_publishing_requires_enterprise_licence(): void + { + $channelName = Uuid::v7()->toRfc4122(); + $orderService = $this->createOrderService($channelName); + + $this->expectException(LicensingException::class); + + $this->bootstrapEcotone($channelName, $orderService, licenceKey: null); + } + + public function test_async_publishing_via_message_publisher_requires_enterprise_licence(): void + { + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [], + [...$this->getConnectionFactoryReferences()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::AMQP_PACKAGE])) + ->withExtensionObjects([ + AmqpMessagePublisherConfiguration::create() + ->withDefaultRoutingKey(Uuid::v7()->toRfc4122()) + ->withAsyncPublishing(), + ]), + ); + } + + public function test_async_publish_on_publisher_without_async_configuration_throws_before_publishing(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = EcotoneLite::bootstrapFlowTesting( + [], + [...$this->getConnectionFactoryReferences()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::AMQP_PACKAGE])) + ->withExtensionObjects([ + AmqpMessagePublisherConfiguration::create() + ->withDefaultRoutingKey($queueName), + AmqpBackedMessageChannelBuilder::create('verificationChannel', queueName: $queueName), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $publishFailed = false; + try { + $publisher->asyncPublish('order that must not be published'); + } catch (PublishingFailedException) { + $publishFailed = true; + } + + $this->assertTrue($publishFailed); + $this->assertNull($messaging->getMessageChannel('verificationChannel')->receiveWithTimeout(PollingMetadata::create('verification')->setFixedRateInMilliseconds(200))); + } + + public function test_message_publisher_async_publish_confirms_delivery_on_future_resolve(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $context = self::getRabbitConnectionFactory()->createContext(); + $context->declareQueue($context->createQueue($queueName)); + $messaging = EcotoneLite::bootstrapFlowTesting( + [], + [...$this->getConnectionFactoryReferences()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::AMQP_PACKAGE])) + ->withExtensionObjects([ + AmqpMessagePublisherConfiguration::create() + ->withAutoDeclareQueueOnSend(true) + ->withDefaultRoutingKey($queueName) + ->withAsyncPublishing(), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $singleFuture = $publisher->asyncPublish('single order'); + $batchFuture = $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('first order') + ->append('second order', ['priority' => '5']) + ); + + $this->assertNull($singleFuture->resolve()); + $this->assertNull($batchFuture->resolve()); + } + + public function test_batch_message_published_synchronously_from_command_handler_is_delivered(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $commandHandler = new class () { + #[CommandHandler('order.placeBatch')] + public function handle(string $order, #[Reference(MessagePublisher::class)] MessagePublisher $publisher): void + { + $publisher->convertAndSend( + BatchMessage::constructEmpty() + ->append($order . ' first order') + ->append($order . ' second order') + ); + } + }; + $messaging = $this->bootstrapPublisherWithVerificationChannel($queueName, $commandHandler); + + $messaging->sendCommandWithRoutingKey('order.placeBatch', 'espresso'); + + $verificationChannel = $messaging->getMessageChannel('verificationChannel'); + $receivedPayloads = [ + $verificationChannel->receive()->getPayload(), + $verificationChannel->receive()->getPayload(), + ]; + sort($receivedPayloads); + $this->assertSame(['espresso first order', 'espresso second order'], $receivedPayloads); + } + + public function test_delayed_entry_of_published_batch_is_delivered_after_delay(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisherWithVerificationChannel($queueName); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('immediate order') + ->append('delayed order', [MessageHeaders::DELIVERY_DELAY => 2000]) + )->resolve(); + + $verificationChannel = $messaging->getMessageChannel('verificationChannel'); + $this->assertSame('immediate order', $verificationChannel->receive()->getPayload()); + $this->assertNull($verificationChannel->receiveWithTimeout(PollingMetadata::create('assertNotYetDelivered')->setExecutionTimeLimitInMilliseconds(500))); + + $this->assertSame('delayed order', $this->receiveWithDeadline($verificationChannel, 10)?->getPayload()); + } + + public function test_expired_entry_of_published_batch_is_not_delivered(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisherWithVerificationChannel($queueName); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('expiring order', [MessageHeaders::TIME_TO_LIVE => 100]) + ->append('kept order') + )->resolve(); + + usleep(300000); + + $verificationChannel = $messaging->getMessageChannel('verificationChannel'); + $this->assertSame('kept order', $verificationChannel->receive()->getPayload()); + $this->assertNull($verificationChannel->receiveWithTimeout(PollingMetadata::create('assertExpired')->setExecutionTimeLimitInMilliseconds(500))); + } + + public function test_batch_published_over_amqp_lib_connection_is_delivered(): void + { + $channelName = Uuid::v7()->toRfc4122(); + $orderService = $this->createOrderService($channelName); + $libConnectionFactory = new AmqpLibConnection(['dsn' => getenv('RABBIT_HOST') ?: 'amqp://guest:guest@localhost:5672/%2f']); + $messaging = EcotoneLite::bootstrapFlowTesting( + [$orderService::class], + [ + AmqpConnectionFactory::class => $libConnectionFactory, + AmqpLibConnection::class => $libConnectionFactory, + $orderService, + ], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::AMQP_PACKAGE])) + ->withExtensionObjects([ + AmqpBackedMessageChannelBuilder::create('asyncOrdersChannel', queueName: $channelName) + ->withAsyncPublishing(), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + $messaging->sendCommandWithRoutingKey('order.place', 'espresso'); + + $messaging->run('asyncOrdersChannel', ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 3, maxExecutionTimeInMilliseconds: 10000)); + + $this->assertSame( + ['espresso-1', 'espresso-2', 'espresso-3'], + $messaging->sendQueryWithRouting('order.getReceived'), + ); + } + + private function createOrderService(string $channelName): object + { + return new class ($channelName) { + /** @var string[] */ + private array $receivedEvents = []; + + public function __construct(private string $channelName) + { + } + + #[CommandHandler('order.place')] + public function placeOrder(string $order, EventBus $eventBus): void + { + $eventBus->publish(new OrderWasPlaced($order . '-1')); + $eventBus->publish(new OrderWasPlaced($order . '-2')); + $eventBus->publish(new OrderWasPlaced($order . '-3')); + } + + #[Asynchronous('asyncOrdersChannel')] + #[EventHandler(endpointId: 'async_amqp_order_collector')] + public function collect(OrderWasPlaced $event): void + { + $this->receivedEvents[] = $event->order; + } + + #[QueryHandler('order.getReceived')] + public function getReceived(): array + { + return $this->receivedEvents; + } + }; + } + + private function bootstrapPublisherWithVerificationChannel(string $queueName, ?object $commandHandler = null): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + $commandHandler === null ? [] : [$commandHandler::class], + $commandHandler === null + ? [...$this->getConnectionFactoryReferences()] + : [...$this->getConnectionFactoryReferences(), $commandHandler], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::AMQP_PACKAGE])) + ->withExtensionObjects([ + AmqpMessagePublisherConfiguration::create() + ->withDefaultRoutingKey($queueName) + ->withAsyncPublishing(), + AmqpBackedMessageChannelBuilder::create('verificationChannel', queueName: $queueName), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + } + + private function receiveWithDeadline(PollableChannel $channel, int $deadlineInSeconds): ?Message + { + $deadline = microtime(true) + $deadlineInSeconds; + while (microtime(true) < $deadline) { + if ($message = $channel->receive()) { + return $message; + } + usleep(100000); + } + + return null; + } + + private function bootstrapEcotone(string $channelName, object $orderService, ?string $licenceKey): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + [$orderService::class], + [...$this->getConnectionFactoryReferences(), $orderService], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::AMQP_PACKAGE])) + ->withExtensionObjects([ + AmqpBackedMessageChannelBuilder::create('asyncOrdersChannel', queueName: $channelName) + ->withAsyncPublishing(), + ]), + licenceKey: $licenceKey, + ); + } +} diff --git a/packages/Amqp/tests/Unit/AmqpPendingDeliveryStaleEpochTest.php b/packages/Amqp/tests/Unit/AmqpPendingDeliveryStaleEpochTest.php new file mode 100644 index 000000000..9e28dc5f2 --- /dev/null +++ b/packages/Amqp/tests/Unit/AmqpPendingDeliveryStaleEpochTest.php @@ -0,0 +1,43 @@ +getEpoch(); + $deliveryTag = $confirmations->recordPublishedMessage(); + + $confirmations->reset(); + $freshChannelDeliveryTag = $confirmations->recordPublishedMessage(); + $confirmations->recordConfirmation($freshChannelDeliveryTag, multiple: true); + + $pendingDelivery = new AmqpPendingDelivery( + $this->createStub(AmqpContext::class), + [['message' => MessageBuilder::withPayload('order published before reconnect')->build(), 'deliveryTag' => $deliveryTag, 'correlationId' => '']], + timeoutInMilliseconds: 1000, + channelName: 'orders', + confirmations: $confirmations, + confirmationsEpoch: $prePublishEpoch, + ); + + $deliveryResult = $pendingDelivery->awaitDelivery(); + + $this->assertFalse($deliveryResult->isSuccessful()); + $this->assertStringContainsString('connection was reset', $deliveryResult->getFailedDeliveries()[0]->getFailureReason()); + } +} diff --git a/packages/DataProtection/tests/TestQueueChannel.php b/packages/DataProtection/tests/TestQueueChannel.php index e5e55743f..dff14e493 100644 --- a/packages/DataProtection/tests/TestQueueChannel.php +++ b/packages/DataProtection/tests/TestQueueChannel.php @@ -14,14 +14,14 @@ class TestQueueChannel extends QueueChannel { private ?Message $lastSentMessage = null; - public function __construct(string $name = 'unknown') + public function __construct(string $name = 'unknown', bool $batchMessagesSupport = false) { - parent::__construct($name); + parent::__construct($name, $batchMessagesSupport); } - public static function create(string $name = 'unknown'): self + public static function create(string $name = 'unknown', bool $batchMessagesSupport = false): self { - return new self($name); + return new self($name, $batchMessagesSupport); } public function send(Message $message): void diff --git a/packages/Dbal/src/Configuration/DbalMessagePublisherConfiguration.php b/packages/Dbal/src/Configuration/DbalMessagePublisherConfiguration.php index 59bab6d8b..16711c084 100644 --- a/packages/Dbal/src/Configuration/DbalMessagePublisherConfiguration.php +++ b/packages/Dbal/src/Configuration/DbalMessagePublisherConfiguration.php @@ -35,6 +35,8 @@ class DbalMessagePublisherConfiguration */ private $queueName; + private bool $asyncPublishing = false; + private function __construct(string $connectionReference, string $queueName, ?string $outputDefaultConversionMediaType, string $referenceName) { $this->connectionReference = $connectionReference; @@ -119,4 +121,16 @@ public function getReferenceName(): string { return $this->referenceName; } + + public function withAsyncPublishing(bool $asyncPublishing = true): self + { + $this->asyncPublishing = $asyncPublishing; + + return $this; + } + + public function isAsyncPublishingEnabled(): bool + { + return $this->asyncPublishing; + } } diff --git a/packages/Dbal/src/Configuration/DbalPublisherModule.php b/packages/Dbal/src/Configuration/DbalPublisherModule.php index 76317eb55..0446c6123 100644 --- a/packages/Dbal/src/Configuration/DbalPublisherModule.php +++ b/packages/Dbal/src/Configuration/DbalPublisherModule.php @@ -8,6 +8,7 @@ use Ecotone\Dbal\DbalBackedMessageChannelBuilder; use Ecotone\Dbal\DbalOutboundChannelAdapterBuilder; use Ecotone\Messaging\Attribute\ModuleAnnotation; +use Ecotone\Messaging\Channel\AsyncPublishing\Config\AsyncPublishGatewayRegistration; use Ecotone\Messaging\Config\Annotation\AnnotationModule; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ExtensionObjectResolver; use Ecotone\Messaging\Config\Configuration; @@ -116,7 +117,10 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO ->withAutoDeclareOnSend($dbalPublisher->isAutoDeclareQueueOnSend()) ->withHeaderMapper($dbalPublisher->getHeaderMapper()) ->withDefaultConversionMediaType($mediaType) + ->withAsyncPublishing($dbalPublisher->isAsyncPublishingEnabled()) ); + + AsyncPublishGatewayRegistration::registerFor($messagingConfiguration, $dbalPublisher->getReferenceName(), $dbalPublisher->isAsyncPublishingEnabled()); } } diff --git a/packages/Dbal/src/DbalBackedMessageChannelBuilder.php b/packages/Dbal/src/DbalBackedMessageChannelBuilder.php index 8d7ef7d33..29adc9994 100644 --- a/packages/Dbal/src/DbalBackedMessageChannelBuilder.php +++ b/packages/Dbal/src/DbalBackedMessageChannelBuilder.php @@ -30,4 +30,21 @@ public static function create(string $channelName, string $connectionReferenceNa { return new self($channelName, $connectionReferenceName); } + + public function withAsyncPublishing(bool $asyncPublishing = true): self + { + $this->getDbalOutboundChannelAdapter()->withAsyncPublishing($asyncPublishing); + + return $this; + } + + protected function supportsBatchMessages(): bool + { + return $this->getDbalOutboundChannelAdapter()->isAsyncPublishingEnabled(); + } + + private function getDbalOutboundChannelAdapter(): DbalOutboundChannelAdapterBuilder + { + return $this->outboundChannelAdapter; + } } diff --git a/packages/Dbal/src/DbalOutboundChannelAdapter.php b/packages/Dbal/src/DbalOutboundChannelAdapter.php index e16dd58ea..20cf2f273 100644 --- a/packages/Dbal/src/DbalOutboundChannelAdapter.php +++ b/packages/Dbal/src/DbalOutboundChannelAdapter.php @@ -7,10 +7,16 @@ use Ecotone\Dbal\Database\EnqueueTableManager; use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Enqueue\EnqueueOutboundChannelAdapter; +use Ecotone\Messaging\BatchMessage; +use Ecotone\Messaging\Channel\AsyncPublishing\AsyncPublishingRegistry; use Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessageConverter; use Ecotone\Messaging\Conversion\ConversionService; +use Ecotone\Messaging\Message; +use Ecotone\Messaging\MessageHeaders; use Enqueue\Dbal\DbalContext; use Enqueue\Dbal\DbalDestination; +use Enqueue\Dbal\DbalProducer; +use Interop\Queue\Context; /** * licence Apache-2.0 @@ -24,13 +30,18 @@ public function __construct( OutboundMessageConverter $outboundMessageConverter, ConversionService $conversionService, private EnqueueTableManager $tableManager, + AsyncPublishingRegistry $asyncPublishingRegistry, + bool $asyncPublishing = false, ) { parent::__construct( $connectionFactory, new DbalDestination($this->queueName), $autoDeclare, $outboundMessageConverter, - $conversionService + $conversionService, + $asyncPublishingRegistry, + $asyncPublishing, + $this->queueName, ); } @@ -46,4 +57,32 @@ public function initialize(): void $this->tableManager->createTable($context->getDbalConnection()); $context->createQueue($this->queueName); } + + protected function sendSingleMessage(Message $message, Context $context): void + { + $this->handleBatch( + BatchMessage::constructEmpty()->append($message->getPayload(), $message->getHeaders()->headers()), + $context, + ); + } + + protected function handleBatch(BatchMessage $batchMessage, Context $context): void + { + $messagesToSend = []; + foreach ($batchMessage->getEntries() as $entry) { + $outboundMessage = $this->prepareOutboundMessage($this->convertBatchEntryToMessage($entry)); + $headers = $outboundMessage->getHeaders(); + $headers[MessageHeaders::CONTENT_TYPE] = $outboundMessage->getContentType(); + + $messageToSend = $context->createMessage($outboundMessage->getPayload(), $headers, []); + $messageToSend->setDeliveryDelay($outboundMessage->getDeliveryDelay()); + $messageToSend->setTimeToLive($outboundMessage->getTimeToLive()); + + $messagesToSend[] = $messageToSend; + } + + /** @var DbalProducer $producer */ + $producer = $context->createProducer(); + $producer->sendBatch($this->destination, $messagesToSend); + } } diff --git a/packages/Dbal/src/DbalOutboundChannelAdapterBuilder.php b/packages/Dbal/src/DbalOutboundChannelAdapterBuilder.php index 165dd7ef6..7d01a237b 100644 --- a/packages/Dbal/src/DbalOutboundChannelAdapterBuilder.php +++ b/packages/Dbal/src/DbalOutboundChannelAdapterBuilder.php @@ -5,11 +5,13 @@ use Ecotone\Dbal\Database\EnqueueTableManager; use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Enqueue\EnqueueOutboundChannelAdapterBuilder; +use Ecotone\Messaging\Channel\AsyncPublishing\AsyncPublishingRegistry; use Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessageConverter; use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\Container\MessagingContainerBuilder; use Ecotone\Messaging\Config\Container\Reference; use Ecotone\Messaging\Conversion\ConversionService; +use Ecotone\Messaging\Support\LicensingException; use Enqueue\Dbal\DbalConnectionFactory; /** @@ -26,6 +28,8 @@ class DbalOutboundChannelAdapterBuilder extends EnqueueOutboundChannelAdapterBui */ private $connectionFactoryReferenceName; + private bool $asyncPublishing = false; + private function __construct(string $queueName, string $connectionFactoryReferenceName) { $this->initialize($connectionFactoryReferenceName); @@ -38,8 +42,24 @@ public static function create(string $queueName, string $connectionFactoryRefere return new self($queueName, $connectionFactoryReferenceName); } + public function withAsyncPublishing(bool $asyncPublishing = true): self + { + $this->asyncPublishing = $asyncPublishing; + + return $this; + } + + public function isAsyncPublishingEnabled(): bool + { + return $this->asyncPublishing; + } + public function compile(MessagingContainerBuilder $builder): Definition { + if ($this->asyncPublishing && ! $builder->getServiceConfiguration()->isRunningForEnterprise()) { + throw LicensingException::create('Asynchronous publishing is available only with Ecotone Enterprise licence.'); + } + $connectionFactory = new Definition(CachedConnectionFactory::class, [ new Definition(DbalReconnectableConnectionFactory::class, [ new Reference($this->connectionFactoryReferenceName), @@ -62,6 +82,8 @@ public function compile(MessagingContainerBuilder $builder): Definition $outboundMessageConverter, new Reference(ConversionService::REFERENCE_NAME), new Reference(EnqueueTableManager::class), + new Reference(AsyncPublishingRegistry::class), + $this->asyncPublishing, ]); } } diff --git a/packages/Dbal/src/EnqueueDbal/DbalProducer.php b/packages/Dbal/src/EnqueueDbal/DbalProducer.php index 8b4b2b258..ad3121d48 100644 --- a/packages/Dbal/src/EnqueueDbal/DbalProducer.php +++ b/packages/Dbal/src/EnqueueDbal/DbalProducer.php @@ -20,6 +20,23 @@ */ class DbalProducer implements Producer { + private const BATCH_INSERT_CHUNK_SIZE = 250; + + private const COLUMN_TYPES = [ + 'id' => DbalType::GUID, + 'published_at' => DbalType::INTEGER, + 'body' => DbalType::TEXT, + 'headers' => DbalType::TEXT, + 'properties' => DbalType::TEXT, + 'priority' => DbalType::SMALLINT, + 'queue' => DbalType::STRING, + 'redelivered' => DbalType::SMALLINT, + 'delivery_id' => DbalType::STRING, + 'redeliver_after' => DbalType::BIGINT, + 'delayed_until' => DbalType::INTEGER, + 'time_to_live' => DbalType::INTEGER, + ]; + /** * @var int|null */ @@ -50,10 +67,68 @@ public function __construct(DbalContext $context) * @param DbalMessage $message */ public function send(Destination $destination, Message $message): void + { + $this->sendBatch($destination, [$message]); + } + + /** + * @param DbalMessage[] $messages + */ + public function sendBatch(Destination $destination, array $messages): void { InvalidDestinationException::assertDestinationInstanceOf($destination, DbalDestination::class); - InvalidMessageException::assertMessageInstanceOf($message, DbalMessage::class); + if ([] === $messages) { + return; + } + + $records = []; + foreach ($messages as $message) { + InvalidMessageException::assertMessageInstanceOf($message, DbalMessage::class); + $this->applyProducerDefaults($message); + $records[] = $this->createRecord($destination, $message); + } + + try { + $rowsAffected = 0; + foreach (array_chunk($records, self::BATCH_INSERT_CHUNK_SIZE) as $recordsChunk) { + $rowsAffected += $this->insertRecords($recordsChunk); + } + } catch (\Exception $e) { + throw new Exception('The transport fails to send the message due to some internal error.', 0, $e); + } + + if (count($records) !== $rowsAffected) { + throw new Exception('The batch was not enqueued. Dbal did not confirm that all records are inserted.'); + } + } + + private function insertRecords(array $records): int + { + $columns = array_keys(self::COLUMN_TYPES); + $rowPlaceholders = '(' . implode(', ', array_fill(0, count($columns), '?')) . ')'; + + $sql = sprintf( + 'INSERT INTO %s (%s) VALUES %s', + $this->context->getTableName(), + implode(', ', $columns), + implode(', ', array_fill(0, count($records), $rowPlaceholders)), + ); + + $parameters = []; + $types = []; + foreach ($records as $record) { + foreach ($columns as $column) { + $parameters[] = $record[$column]; + $types[] = self::COLUMN_TYPES[$column]; + } + } + + return (int) $this->context->getDbalConnection()->executeStatement($sql, $parameters, $types); + } + + private function applyProducerDefaults(DbalMessage $message): void + { if (null !== $this->priority && null === $message->getPriority()) { $message->setPriority($this->priority); } @@ -63,16 +138,17 @@ public function send(Destination $destination, Message $message): void if (null !== $this->timeToLive && null === $message->getTimeToLive()) { $message->setTimeToLive($this->timeToLive); } + } - $body = $message->getBody(); - + private function createRecord(DbalDestination $destination, DbalMessage $message): array + { $publishedAt = $message->getPublishedAt() ?? (int) ($this->context->getClock()->now()->unixTime()->toFloat() * 10_000); // x 10_000 ?!?!!?? - $dbalMessage = [ + $record = [ 'id' => Uuid::v7()->toRfc4122(), 'published_at' => $publishedAt, - 'body' => $body, + 'body' => $message->getBody(), 'headers' => JSON::encode($message->getHeaders()), 'properties' => JSON::encode($message->getProperties()), 'priority' => -1 * $message->getPriority(), @@ -80,6 +156,8 @@ public function send(Destination $destination, Message $message): void 'redelivered' => false, 'delivery_id' => null, 'redeliver_after' => null, + 'delayed_until' => null, + 'time_to_live' => null, ]; $delay = $message->getDeliveryDelay(); @@ -92,7 +170,7 @@ public function send(Destination $destination, Message $message): void throw new LogicException(sprintf('Delay must be positive integer but got: "%s"', $delay)); } - $dbalMessage['delayed_until'] = $this->context->getClock()->now()->add(Duration::milliseconds($delay))->unixTime()->inSeconds(); + $record['delayed_until'] = $this->context->getClock()->now()->add(Duration::milliseconds($delay))->unixTime()->inSeconds(); } $timeToLive = $message->getTimeToLive(); @@ -105,31 +183,10 @@ public function send(Destination $destination, Message $message): void throw new LogicException(sprintf('TimeToLive must be positive integer but got: "%s"', $timeToLive)); } - $dbalMessage['time_to_live'] = $this->context->getClock()->now()->add(Duration::milliseconds($timeToLive))->unixTime()->inSeconds(); + $record['time_to_live'] = $this->context->getClock()->now()->add(Duration::milliseconds($timeToLive))->unixTime()->inSeconds(); } - try { - $rowsAffected = $this->context->getDbalConnection()->insert($this->context->getTableName(), $dbalMessage, [ - 'id' => DbalType::GUID, - 'published_at' => DbalType::INTEGER, - 'body' => DbalType::TEXT, - 'headers' => DbalType::TEXT, - 'properties' => DbalType::TEXT, - 'priority' => DbalType::SMALLINT, - 'queue' => DbalType::STRING, - 'time_to_live' => DbalType::INTEGER, - 'delayed_until' => DbalType::INTEGER, - 'redelivered' => DbalType::SMALLINT, - 'delivery_id' => DbalType::STRING, - 'redeliver_after' => DbalType::BIGINT, - ]); - - if (1 !== $rowsAffected) { - throw new Exception('The message was not enqueued. Dbal did not confirm that the record is inserted.'); - } - } catch (\Exception $e) { - throw new Exception('The transport fails to send the message due to some internal error.', 0, $e); - } + return $record; } public function setDeliveryDelay(?int $deliveryDelay = null): Producer diff --git a/packages/Dbal/tests/Fixture/AsyncPublishing/AsyncPublishingTestChannel.php b/packages/Dbal/tests/Fixture/AsyncPublishing/AsyncPublishingTestChannel.php new file mode 100644 index 000000000..ccfad310a --- /dev/null +++ b/packages/Dbal/tests/Fixture/AsyncPublishing/AsyncPublishingTestChannel.php @@ -0,0 +1,77 @@ +getPayload(); + + if ($payload instanceof BatchMessage) { + foreach ($payload->getEntries() as $entry) { + $this->queue[] = MessageBuilder::withPayload($entry['payload']) + ->setMultipleHeaders($entry['headers']) + ->build(); + } + } else { + $this->queue[] = $message; + } + + $pendingDelivery = new TestPendingDelivery($message, $this->channelName, $this->deliveryFailureReason); + + if (! $this->asyncPublishingRegistry->isScopeActive()) { + $deliveryResult = $pendingDelivery->awaitDelivery(); + if (! $deliveryResult->isSuccessful()) { + throw PublishingFailedException::withFailedDeliveries($deliveryResult->getFailedDeliveries()); + } + + return; + } + + $this->asyncPublishingRegistry->register($this->channelName, $pendingDelivery); + } + + public function receive(): ?Message + { + return array_shift($this->queue) ?: null; + } + + public function receiveWithTimeout(PollingMetadata $pollingMetadata): ?Message + { + return $this->receive(); + } + + public function onConsumerStop(): void + { + } + + public function supportsBatchMessages(): bool + { + return true; + } +} diff --git a/packages/Dbal/tests/Fixture/AsyncPublishing/AsyncPublishingTestChannelBuilder.php b/packages/Dbal/tests/Fixture/AsyncPublishing/AsyncPublishingTestChannelBuilder.php new file mode 100644 index 000000000..56d0d2877 --- /dev/null +++ b/packages/Dbal/tests/Fixture/AsyncPublishing/AsyncPublishingTestChannelBuilder.php @@ -0,0 +1,50 @@ +channelName; + } + + public function isPollable(): bool + { + return true; + } + + public function isStreamingChannel(): bool + { + return false; + } + + public function compile(MessagingContainerBuilder $builder): Definition|Reference + { + return new Definition(AsyncPublishingTestChannel::class, [ + $this->channelName, + new Reference(AsyncPublishingRegistry::class), + $this->deliveryFailureReason, + ]); + } +} diff --git a/packages/Dbal/tests/Fixture/AsyncPublishing/OrderWasPlaced.php b/packages/Dbal/tests/Fixture/AsyncPublishing/OrderWasPlaced.php new file mode 100644 index 000000000..b1b0edf39 --- /dev/null +++ b/packages/Dbal/tests/Fixture/AsyncPublishing/OrderWasPlaced.php @@ -0,0 +1,15 @@ +awaited = true; + + if ($this->failureReason !== null) { + return DeliveryResult::withFailedDeliveries([ + new FailedDelivery($this->message, $this->failureReason, $this->channelName), + ]); + } + + return DeliveryResult::successful(); + } + + public function isAwaited(): bool + { + return $this->awaited; + } +} diff --git a/packages/Dbal/tests/Integration/AsyncPublishingTest.php b/packages/Dbal/tests/Integration/AsyncPublishingTest.php new file mode 100644 index 000000000..ad13337cd --- /dev/null +++ b/packages/Dbal/tests/Integration/AsyncPublishingTest.php @@ -0,0 +1,314 @@ +createOrderService(); + $messaging = $this->bootstrapEcotoneWithChannel($orderService, LicenceTesting::VALID_LICENCE); + + $messaging->sendCommandWithRoutingKey('order.place', 'espresso'); + + $this->assertSame([], $messaging->sendQueryWithRouting('order.getReceived')); + + $messaging->run('asyncOrdersChannel', ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 3, maxExecutionTimeInMilliseconds: 10000)); + + $this->assertSame( + ['espresso-1', 'espresso-2', 'espresso-3'], + $messaging->sendQueryWithRouting('order.getReceived'), + ); + } + + public function test_async_publishing_requires_enterprise_licence(): void + { + $orderService = $this->createOrderService(); + + $this->expectException(LicensingException::class); + + $this->bootstrapEcotoneWithChannel($orderService, licenceKey: null); + } + + public function test_async_publishing_via_message_publisher_requires_enterprise_licence(): void + { + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [], + [DbalConnectionFactory::class => $this->getConnectionFactory()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([ + DbalMessagePublisherConfiguration::create(MessagePublisher::class, Uuid::v7()->toRfc4122()) + ->withAsyncPublishing(), + ]), + ); + } + + public function test_async_publish_on_publisher_without_async_configuration_throws_before_publishing(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: false); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $publishFailed = false; + try { + $publisher->asyncPublish('order that must not be published'); + } catch (PublishingFailedException) { + $publishFailed = true; + } + + $this->assertTrue($publishFailed); + $this->assertNull($messaging->getMessageChannel($queueName)->receive()); + } + + public function test_message_publisher_async_publish_confirms_delivery_on_future_resolve(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: true); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $singleFuture = $publisher->asyncPublish('single order'); + $batchFuture = $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('first order') + ->append('second order', ['priority' => '5']) + ); + + $this->assertNull($singleFuture->resolve()); + $this->assertNull($batchFuture->resolve()); + + $receivedPayloads = []; + while ($message = $messaging->getMessageChannel($queueName)->receive()) { + $receivedPayloads[] = $message->getPayload(); + } + sort($receivedPayloads); + $this->assertSame(['first order', 'second order', 'single order'], $receivedPayloads); + } + + public function test_sending_batch_message_over_channel_without_async_publishing_throws(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: false); + + $this->expectException(ConfigurationException::class); + + $messaging->getMessageChannel($queueName)->send( + MessageBuilder::withPayload(BatchMessage::constructEmpty()->append('first order'))->build() + ); + } + + public function test_sending_batch_message_via_publisher_without_async_publishing_throws(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: false); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $this->expectException(ConfigurationException::class); + + $publisher->convertAndSend(BatchMessage::constructEmpty()->append('first order')); + } + + public function test_batch_message_published_synchronously_from_command_handler_is_delivered(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $commandHandler = new class () { + #[CommandHandler('order.placeBatch')] + public function handle(string $order, #[Reference(MessagePublisher::class)] MessagePublisher $publisher): void + { + $publisher->convertAndSend( + BatchMessage::constructEmpty() + ->append($order . ' first order') + ->append($order . ' second order') + ); + } + }; + $messaging = EcotoneLite::bootstrapFlowTesting( + [$commandHandler::class], + [DbalConnectionFactory::class => $this->getConnectionFactory(), $commandHandler], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([ + DbalMessagePublisherConfiguration::create(MessagePublisher::class, $queueName) + ->withAsyncPublishing(), + DbalBackedMessageChannelBuilder::create($queueName), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + $messaging->sendCommandWithRoutingKey('order.placeBatch', 'espresso'); + + $receivedPayloads = []; + while ($message = $messaging->getMessageChannel($queueName)->receive()) { + $receivedPayloads[] = $message->getPayload(); + } + sort($receivedPayloads); + $this->assertSame(['espresso first order', 'espresso second order'], $receivedPayloads); + } + + public function test_delayed_entry_of_published_batch_is_delivered_after_delay(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: true); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('immediate order') + ->append('delayed order', [MessageHeaders::DELIVERY_DELAY => 3000]) + )->resolve(); + + $channel = $messaging->getMessageChannel($queueName); + $this->assertSame('immediate order', $channel->receive()->getPayload()); + $this->assertNull($channel->receiveWithTimeout(PollingMetadata::create('assertNotYetDelivered')->setExecutionTimeLimitInMilliseconds(500))); + + $this->assertSame('delayed order', $this->receiveWithDeadline($channel, 10)?->getPayload()); + } + + public function test_expired_entry_of_published_batch_is_not_delivered(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: true); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('expiring order', [MessageHeaders::TIME_TO_LIVE => 1000]) + ->append('kept order') + )->resolve(); + + sleep(2); + + $channel = $messaging->getMessageChannel($queueName); + $this->assertSame('kept order', $channel->receive()->getPayload()); + $this->assertNull($channel->receive()); + } + + public function test_publishing_after_queue_table_is_dropped_throws(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: true); + $publisher = $messaging->getGateway(MessagePublisher::class); + $publisher->asyncPublish('first order')->resolve(); + + $this->getConnection()->executeStatement('DROP TABLE enqueue'); + + $this->expectException(Exception::class); + + $publisher->asyncPublish('order published into missing table'); + } + + private function receiveWithDeadline(PollableChannel $channel, int $deadlineInSeconds): ?Message + { + $deadline = microtime(true) + $deadlineInSeconds; + while (microtime(true) < $deadline) { + if ($message = $channel->receive()) { + return $message; + } + usleep(100000); + } + + return null; + } + + private function createOrderService(): object + { + return new class () { + /** @var string[] */ + private array $receivedEvents = []; + + #[CommandHandler('order.place')] + public function placeOrder(string $order, EventBus $eventBus): void + { + $eventBus->publish(new OrderWasPlaced($order . '-1')); + $eventBus->publish(new OrderWasPlaced($order . '-2')); + $eventBus->publish(new OrderWasPlaced($order . '-3')); + } + + #[Asynchronous('asyncOrdersChannel')] + #[EventHandler(endpointId: 'async_dbal_order_collector')] + public function collect(OrderWasPlaced $event): void + { + $this->receivedEvents[] = $event->order; + } + + #[QueryHandler('order.getReceived')] + public function getReceived(): array + { + return $this->receivedEvents; + } + }; + } + + private function bootstrapEcotoneWithChannel(object $orderService, ?string $licenceKey): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + [$orderService::class], + [DbalConnectionFactory::class => $this->getConnectionFactory(), $orderService], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([ + DbalBackedMessageChannelBuilder::create('asyncOrdersChannel') + ->withAsyncPublishing(), + ]), + licenceKey: $licenceKey, + ); + } + + private function bootstrapPublisher(string $queueName, bool $asyncPublishing): FlowTestSupport + { + $publisherConfiguration = DbalMessagePublisherConfiguration::create(MessagePublisher::class, $queueName); + if ($asyncPublishing) { + $publisherConfiguration = $publisherConfiguration->withAsyncPublishing(); + } + + return EcotoneLite::bootstrapFlowTesting( + [], + [DbalConnectionFactory::class => $this->getConnectionFactory()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([ + $publisherConfiguration, + DbalBackedMessageChannelBuilder::create($queueName), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + } +} diff --git a/packages/Dbal/tests/Integration/AsyncPublishingTransactionTest.php b/packages/Dbal/tests/Integration/AsyncPublishingTransactionTest.php new file mode 100644 index 000000000..71b962ad1 --- /dev/null +++ b/packages/Dbal/tests/Integration/AsyncPublishingTransactionTest.php @@ -0,0 +1,104 @@ +bootstrapEcotone( + [AsyncPublishingTestChannelBuilder::create('notifications')], + [] + ); + + $ecotoneLite->sendCommand(new RegisterPerson(100, 'Johny')); + + $this->assertNotNull($ecotoneLite->sendQueryWithRouting('person.getName', metadata: ['aggregate.id' => 100])); + $this->assertNotNull($ecotoneLite->getMessageChannel('notifications')->receive()); + } + + public function test_failed_delivery_confirmation_rolls_back_database_transaction(): void + { + $ecotoneLite = $this->bootstrapEcotone( + [AsyncPublishingTestChannelBuilder::create('notifications', deliveryFailureReason: 'broker not available')], + [] + ); + + $deliveryFailed = false; + try { + $ecotoneLite->sendCommand(new RegisterPerson(100, 'Johny')); + } catch (PublishingFailedException) { + $deliveryFailed = true; + } + $this->assertTrue($deliveryFailed); + + $this->expectException(AggregateNotFoundException::class); + + $ecotoneLite->sendQueryWithRouting('person.getName', metadata: ['aggregate.id' => 100]); + } + + public function test_failed_delivery_routed_to_error_channel_commits_database_transaction(): void + { + $ecotoneLite = $this->bootstrapEcotone( + [ + AsyncPublishingTestChannelBuilder::create('notifications', deliveryFailureReason: 'broker not available'), + SimpleMessageChannelBuilder::createQueueChannel('failure_channel'), + ], + [GlobalPollableChannelConfiguration::createWithDefaults()->withErrorChannel('failure_channel')] + ); + + $ecotoneLite->sendCommand(new RegisterPerson(100, 'Johny')); + + $this->assertNotNull($ecotoneLite->sendQueryWithRouting('person.getName', metadata: ['aggregate.id' => 100])); + + $failedMessage = $ecotoneLite->receiveMessageFrom('failure_channel'); + $this->assertNotNull($failedMessage); + $this->assertStringContainsString('broker not available', $failedMessage->getHeaders()->get(ErrorContext::EXCEPTION_MESSAGE)); + } + + private function bootstrapEcotone(array $channelBuilders, array $extensionObjects): FlowTestSupport + { + $this->setupUserTable(); + + return EcotoneLite::bootstrapFlowTesting( + [Person::class, NotificationService::class], + [new NotificationService(), DbalConnectionFactory::class => $this->getORMConnectionFactory([__DIR__ . '/../Fixture/ORM/Person'])], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) + ->withExtensionObjects(array_merge( + $extensionObjects, + $channelBuilders, + [ + DbalConfiguration::createWithDefaults() + ->withTransactionOnCommandBus(true) + ->withTransactionOnAsynchronousEndpoints(true) + ->withDoctrineORMRepositories(true), + ] + )), + addInMemoryStateStoredRepository: false + ); + } +} diff --git a/packages/Ecotone/src/Messaging/BatchMessage.php b/packages/Ecotone/src/Messaging/BatchMessage.php new file mode 100644 index 000000000..fd4163b80 --- /dev/null +++ b/packages/Ecotone/src/Messaging/BatchMessage.php @@ -0,0 +1,60 @@ +}> */ + private array $entries = []; + + private function __construct() + { + } + + public static function constructEmpty(): self + { + return new self(); + } + + /** + * @param array}> $entries + */ + public static function fromEntries(array $entries): self + { + $batchMessage = new self(); + $batchMessage->entries = array_values($entries); + + return $batchMessage; + } + + /** + * @param array $headers + */ + public function append(mixed $payload, array $headers = []): self + { + $appended = clone $this; + $appended->entries[] = ['payload' => $payload, 'headers' => $headers]; + + return $appended; + } + + /** + * @return array}> + */ + public function getEntries(): array + { + return $this->entries; + } + + public function count(): int + { + return count($this->entries); + } +} diff --git a/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/AsyncPublishingGateway.php b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/AsyncPublishingGateway.php new file mode 100644 index 000000000..d89645210 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/AsyncPublishingGateway.php @@ -0,0 +1,69 @@ +asyncPublishingEnabled) { + throw PublishingFailedException::publisherNotConfiguredForAsyncPublishing($this->publisherReference); + } + + $payload = $message->getPayload(); + if ($payload instanceof BatchMessage && count($payload) === 0) { + return DeliveryFuture::forPendingDeliveries([]); + } + + $scopeWasActive = $this->asyncPublishingRegistry->isScopeActive(); + if (! $scopeWasActive) { + $this->asyncPublishingRegistry->openScope(); + } + + try { + $collectionPoint = $this->asyncPublishingRegistry->collectionPoint(); + + $this->configuredMessagingSystem->getMessageChannelByName($this->publisherReference)->send( + MessageBuilder::fromMessage($message) + ->removeHeader(MessageHeaders::REPLY_CHANNEL) + ->removeHeader(MessageHeaders::ROUTING_SLIP) + ->build() + ); + + $pendingDeliveries = $this->asyncPublishingRegistry->registeredSince($collectionPoint); + if (! $scopeWasActive) { + $this->asyncPublishingRegistry->markRegisteredSinceAsPublisherOwned($collectionPoint); + } + } finally { + if (! $scopeWasActive) { + $this->asyncPublishingRegistry->closeScope(); + } + } + + if ($pendingDeliveries === []) { + throw PublishingFailedException::publisherNotConfiguredForAsyncPublishing($this->publisherReference); + } + + return DeliveryFuture::forPendingDeliveries($pendingDeliveries); + } +} diff --git a/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/AsyncPublishingRegistry.php b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/AsyncPublishingRegistry.php new file mode 100644 index 000000000..bedf05ed7 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/AsyncPublishingRegistry.php @@ -0,0 +1,195 @@ + */ + private array $pendingDeliveries = []; + + /** @var WeakMap|null */ + private static ?WeakMap $registriesFlushedOnShutdown = null; + + public function __construct(private LoggingGateway $logger) + { + } + + private const PRUNE_INTERVAL = 256; + + private const MAX_UNAWAITED_BACKLOG = 1024; + + private int $nextRegistrationIndex = 0; + + private int $registrationsSinceLastPrune = 0; + + private bool $scopeActive = false; + + public function openScope(): void + { + $this->scopeActive = true; + } + + public function isScopeActive(): bool + { + return $this->scopeActive; + } + + public function closeScope(): void + { + $this->scopeActive = false; + foreach ($this->pendingDeliveries as $index => $registration) { + if ($registration['scopeOwned']) { + if (! $registration['pendingDelivery']->isAwaited()) { + $this->awaitAndLogFailures($registration['pendingDelivery']); + } + unset($this->pendingDeliveries[$index]); + } + } + } + + public function markRegisteredSinceAsPublisherOwned(int $collectionPoint): void + { + for ($index = $collectionPoint; $index < $this->nextRegistrationIndex; $index++) { + if (isset($this->pendingDeliveries[$index])) { + $this->pendingDeliveries[$index]['scopeOwned'] = false; + } + } + } + + public function awaitAll(): DeliveryResult + { + $failedDeliveries = []; + foreach ($this->pendingDeliveries as $registration) { + if (! $registration['scopeOwned'] || $registration['pendingDelivery']->isAwaited()) { + continue; + } + + $deliveryResult = $registration['pendingDelivery']->awaitDelivery(); + if (! $deliveryResult->isSuccessful()) { + $failedDeliveries = array_merge($failedDeliveries, $deliveryResult->getFailedDeliveries()); + } + } + + return $failedDeliveries === [] ? DeliveryResult::successful() : DeliveryResult::withFailedDeliveries($failedDeliveries); + } + + public function register(string $channelName, PendingDelivery $pendingDelivery): void + { + $this->flushUnawaitedDeliveriesOnShutdown(); + if (++$this->registrationsSinceLastPrune >= self::PRUNE_INTERVAL) { + $this->pruneAwaitedDeliveries(); + $this->registrationsSinceLastPrune = 0; + } + $this->pendingDeliveries[$this->nextRegistrationIndex++] = ['channelName' => $channelName, 'pendingDelivery' => $pendingDelivery, 'scopeOwned' => $this->scopeActive]; + } + + public function collectionPoint(): int + { + return $this->nextRegistrationIndex; + } + + /** + * @return PendingDelivery[] + */ + public function registeredSince(int $collectionPoint): array + { + $registered = []; + for ($index = $collectionPoint; $index < $this->nextRegistrationIndex; $index++) { + if (isset($this->pendingDeliveries[$index])) { + $registered[] = $this->pendingDeliveries[$index]['pendingDelivery']; + } + } + + return $registered; + } + + private function flushUnawaitedDeliveriesOnShutdown(): void + { + if (self::$registriesFlushedOnShutdown === null) { + self::$registriesFlushedOnShutdown = new WeakMap(); + register_shutdown_function(static function (): void { + foreach (self::$registriesFlushedOnShutdown as $registry => $awaitingFlush) { + $registry->flushUnawaitedDeliveries(); + } + }); + } + + self::$registriesFlushedOnShutdown[$this] = true; + } + + public function flushUnawaitedDeliveries(): void + { + foreach ($this->pendingDeliveries as $registration) { + if (! $registration['pendingDelivery']->isAwaited()) { + $this->awaitAndLogFailures($registration['pendingDelivery']); + } + } + $this->pendingDeliveries = []; + } + + private function pruneAwaitedDeliveries(): void + { + foreach ($this->pendingDeliveries as $index => $registration) { + if ($registration['pendingDelivery']->isAwaited()) { + unset($this->pendingDeliveries[$index]); + } + } + + $this->flushOldestPublisherOwnedDeliveriesAboveBacklogLimit(); + } + + private function flushOldestPublisherOwnedDeliveriesAboveBacklogLimit(): void + { + $exceedingBacklogLimit = count($this->pendingDeliveries) - self::MAX_UNAWAITED_BACKLOG; + if ($exceedingBacklogLimit <= 0) { + return; + } + + foreach ($this->pendingDeliveries as $index => $registration) { + if ($registration['scopeOwned']) { + continue; + } + + $this->awaitAndLogFailures($registration['pendingDelivery']); + unset($this->pendingDeliveries[$index]); + + if (--$exceedingBacklogLimit <= 0) { + return; + } + } + } + + private function awaitAndLogFailures(PendingDelivery $pendingDelivery): void + { + try { + $deliveryResult = $pendingDelivery->awaitDelivery(); + } catch (Throwable $exception) { + $this->logger->error( + sprintf('Async publishing: awaiting unresolved delivery failed: %s', $exception->getMessage()), + [], + ['exception' => $exception], + ); + + return; + } + foreach ($deliveryResult->getFailedDeliveries() as $failedDelivery) { + $this->logger->error( + sprintf( + 'Async publishing: unresolved delivery for channel `%s` failed confirmation: %s', + $failedDelivery->getChannelName(), + $failedDelivery->getFailureReason(), + ), + $failedDelivery->getMessage(), + ); + } + } +} diff --git a/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/AsyncPublishingWaiterInterceptor.php b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/AsyncPublishingWaiterInterceptor.php new file mode 100644 index 000000000..6de7aca01 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/AsyncPublishingWaiterInterceptor.php @@ -0,0 +1,106 @@ + $errorChannels + */ + public function __construct( + private AsyncPublishingRegistry $asyncPublishingRegistry, + private array $errorChannels, + private ?string $globalErrorChannelName, + private ErrorChannelService $errorChannelService, + private ConfiguredMessagingSystem $configuredMessagingSystem, + ) { + } + + public function await(MethodInvocation $methodInvocation): mixed + { + if ($this->asyncPublishingRegistry->isScopeActive()) { + return $methodInvocation->proceed(); + } + + $this->asyncPublishingRegistry->openScope(); + try { + $result = $methodInvocation->proceed(); + + $deliveryResult = $this->asyncPublishingRegistry->awaitAll(); + if (! $deliveryResult->isSuccessful()) { + $unroutedFailedDeliveries = $this->handleFailedDeliveries($deliveryResult->getFailedDeliveries()); + + $errorChannelDeliveryResult = $this->asyncPublishingRegistry->awaitAll(); + $remainingFailedDeliveries = array_merge($unroutedFailedDeliveries, $errorChannelDeliveryResult->getFailedDeliveries()); + if ($remainingFailedDeliveries !== []) { + throw PublishingFailedException::withFailedDeliveries($remainingFailedDeliveries); + } + } + } finally { + $this->asyncPublishingRegistry->closeScope(); + } + + return $result; + } + + /** + * @param FailedDelivery[] $failedDeliveries + * @return FailedDelivery[] + */ + private function handleFailedDeliveries(array $failedDeliveries): array + { + $unroutedFailedDeliveries = []; + foreach ($failedDeliveries as $failedDelivery) { + $errorChannelName = array_key_exists($failedDelivery->getChannelName(), $this->errorChannels) + ? $this->errorChannels[$failedDelivery->getChannelName()] + : $this->globalErrorChannelName; + + if ($errorChannelName === null) { + $unroutedFailedDeliveries[] = $failedDelivery; + + continue; + } + + foreach ($this->unpackFailedMessages($failedDelivery->getMessage()) as $failedMessage) { + $this->errorChannelService->handle( + $failedMessage, + PublishingFailedException::withFailedDeliveries([$failedDelivery]), + $this->configuredMessagingSystem->getMessageChannelByName($errorChannelName), + $failedDelivery->getChannelName(), + ); + } + } + + return $unroutedFailedDeliveries; + } + + /** + * @return Message[] + */ + private function unpackFailedMessages(Message $message): array + { + $payload = $message->getPayload(); + if (! $payload instanceof BatchMessage) { + return [$message]; + } + + return array_map( + fn (array $entry): Message => MessageBuilder::withPayload($entry['payload']) + ->setMultipleHeaders($entry['headers']) + ->build(), + $payload->getEntries(), + ); + } +} diff --git a/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/Config/AsyncPublishGatewayRegistration.php b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/Config/AsyncPublishGatewayRegistration.php new file mode 100644 index 000000000..bebede3e5 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/Config/AsyncPublishGatewayRegistration.php @@ -0,0 +1,55 @@ +registerGatewayBuilder( + GatewayProxyBuilder::create($publisherReferenceName, MessagePublisher::class, 'asyncPublish', $asyncPublishRequestChannel) + ->withParameterConverters([ + GatewayPayloadBuilder::create('data'), + GatewayHeaderBuilder::create('sourceMediaType', MessageHeaders::CONTENT_TYPE), + GatewayHeadersBuilder::create('metadata'), + ]) + ) + ->registerMessageChannel(SimpleMessageChannelBuilder::createDirectMessageChannel($asyncPublishRequestChannel)) + ->registerMessageHandler( + ServiceActivatorBuilder::createWithDefinition( + new Definition(AsyncPublishingGateway::class, [ + $publisherReferenceName, + $asyncPublishingEnabled, + new Reference(ConfiguredMessagingSystem::class), + new Reference(AsyncPublishingRegistry::class), + ]), + 'publish' + ) + ->withInputChannelName($asyncPublishRequestChannel) + ->withEndpointId($asyncPublishRequestChannel . '.endpoint') + ); + } +} diff --git a/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/Config/AsyncPublishingModule.php b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/Config/AsyncPublishingModule.php new file mode 100644 index 000000000..55e089750 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/Config/AsyncPublishingModule.php @@ -0,0 +1,78 @@ +getChannelName()] = $pollableChannelConfiguration->getErrorChannelName(); + } + + $messagingConfiguration->registerServiceDefinition( + AsyncPublishingWaiterInterceptor::class, + new Definition(AsyncPublishingWaiterInterceptor::class, [ + new Reference(AsyncPublishingRegistry::class), + $errorChannels, + $globalPollableChannelConfiguration->getErrorChannelName(), + new Reference(ErrorChannelService::class), + new Reference(ConfiguredMessagingSystem::class), + ]) + ); + $messagingConfiguration->registerAroundMethodInterceptor( + AroundInterceptorBuilder::create( + AsyncPublishingWaiterInterceptor::class, + $interfaceToCallRegistry->getFor(AsyncPublishingWaiterInterceptor::class, 'await'), + Precedence::ASYNC_PUBLISHING_AWAIT_PRECEDENCE, + CommandBus::class . '||' . AsynchronousRunningEndpoint::class, + ) + ); + } + + public function canHandle($extensionObject): bool + { + return $extensionObject instanceof PollableChannelConfiguration + || $extensionObject instanceof GlobalPollableChannelConfiguration; + } + + public function getModulePackageName(): string + { + return ModulePackageList::CORE_PACKAGE; + } +} diff --git a/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/ConfirmedDelivery.php b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/ConfirmedDelivery.php new file mode 100644 index 000000000..f354fb9aa --- /dev/null +++ b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/ConfirmedDelivery.php @@ -0,0 +1,21 @@ +resolved) { + if ($this->failure !== null) { + throw $this->failure; + } + + return; + } + + $this->resolved = true; + $failedDeliveries = []; + $awaitFailure = null; + foreach ($this->pendingDeliveries as $pendingDelivery) { + try { + $deliveryResult = $pendingDelivery->awaitDelivery(); + } catch (Throwable $exception) { + $awaitFailure ??= $exception; + + continue; + } + + if (! $deliveryResult->isSuccessful()) { + $failedDeliveries = array_merge($failedDeliveries, $deliveryResult->getFailedDeliveries()); + } + } + + if ($awaitFailure !== null) { + $this->failure = $awaitFailure instanceof PublishingFailedException && $failedDeliveries === [] + ? $awaitFailure + : new PublishingFailedException(sprintf('Awaiting delivery confirmation failed: %s', $awaitFailure->getMessage()), 0, $awaitFailure); + + throw $this->failure; + } + + if ($failedDeliveries !== []) { + $this->failure = PublishingFailedException::withFailedDeliveries($failedDeliveries); + + throw $this->failure; + } + } +} diff --git a/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/DeliveryResult.php b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/DeliveryResult.php new file mode 100644 index 000000000..42542df25 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/DeliveryResult.php @@ -0,0 +1,44 @@ +failedDeliveries === []; + } + + /** + * @return FailedDelivery[] + */ + public function getFailedDeliveries(): array + { + return $this->failedDeliveries; + } +} diff --git a/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/FailedDelivery.php b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/FailedDelivery.php new file mode 100644 index 000000000..d62fdbbb0 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/FailedDelivery.php @@ -0,0 +1,35 @@ +channelName; + } + + public function getMessage(): Message + { + return $this->message; + } + + public function getFailureReason(): string + { + return $this->failureReason; + } +} diff --git a/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/PendingDelivery.php b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/PendingDelivery.php new file mode 100644 index 000000000..444704b82 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Channel/AsyncPublishing/PendingDelivery.php @@ -0,0 +1,15 @@ + $failedDelivery->getFailureReason(), + $failedDeliveries, + ); + + $exception = new self(sprintf( + 'Failed to deliver %d published message(s): %s', + count($failedDeliveries), + implode('; ', array_unique($failureReasons)), + )); + $exception->failedDeliveries = $failedDeliveries; + + return $exception; + } + + /** @var FailedDelivery[] */ + private array $failedDeliveries = []; + + public static function publisherNotConfiguredForAsyncPublishing(string $publisherReference): self + { + return new self(sprintf( + 'Message Publisher `%s` is not configured for asynchronous publishing. Enable async publishing on the publisher configuration to make use of asyncPublish.', + $publisherReference, + )); + } + + /** + * @return FailedDelivery[] + */ + public function getFailedDeliveries(): array + { + return $this->failedDeliveries; + } +} diff --git a/packages/Ecotone/src/Messaging/Channel/BatchSupportingMessageChannel.php b/packages/Ecotone/src/Messaging/Channel/BatchSupportingMessageChannel.php new file mode 100644 index 000000000..269632151 --- /dev/null +++ b/packages/Ecotone/src/Messaging/Channel/BatchSupportingMessageChannel.php @@ -0,0 +1,13 @@ +getTargetChannel($configuredMessagingSystem); - foreach ($collectedMessages as $collectedMessage) { - $messageChannel->send($collectedMessage); + if ($this->supportsBatchMessages($messageChannel)) { + $messageChannel->send( + MessageBuilder::withPayload($this->combineIntoBatch($collectedMessages))->build() + ); + } else { + foreach ($collectedMessages as $collectedMessage) { + $messageChannel->send($collectedMessage); + } } } } finally { @@ -58,4 +68,35 @@ private function getTargetChannel(ConfiguredMessagingSystem $configuredMessaging { return $configuredMessagingSystem->getMessageChannelByName($this->targetChannel); } + + private function supportsBatchMessages(MessageChannel $messageChannel): bool + { + if ($messageChannel instanceof MessageChannelInterceptorAdapter) { + $messageChannel = $messageChannel->getInternalMessageChannel(); + } + + return $messageChannel instanceof BatchSupportingMessageChannel && $messageChannel->supportsBatchMessages(); + } + + /** + * @param Message[] $collectedMessages + */ + private function combineIntoBatch(array $collectedMessages): BatchMessage + { + $entries = []; + foreach ($collectedMessages as $collectedMessage) { + $payload = $collectedMessage->getPayload(); + if ($payload instanceof BatchMessage) { + foreach ($payload->getEntries() as $entry) { + $entries[] = $entry; + } + + continue; + } + + $entries[] = ['payload' => $payload, 'headers' => $collectedMessage->getHeaders()->headers()]; + } + + return BatchMessage::fromEntries($entries); + } } diff --git a/packages/Ecotone/src/Messaging/Channel/DelayableQueueChannel.php b/packages/Ecotone/src/Messaging/Channel/DelayableQueueChannel.php index ff1a5e6de..7a735ebbd 100644 --- a/packages/Ecotone/src/Messaging/Channel/DelayableQueueChannel.php +++ b/packages/Ecotone/src/Messaging/Channel/DelayableQueueChannel.php @@ -5,6 +5,7 @@ namespace Ecotone\Messaging\Channel; use DateTimeInterface; +use Ecotone\Messaging\BatchMessage; use Ecotone\Messaging\Config\Container\DefinedObject; use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Endpoint\PollingMetadata; @@ -13,6 +14,7 @@ use Ecotone\Messaging\PollableChannel; use Ecotone\Messaging\Scheduling\DatePoint; use Ecotone\Messaging\Scheduling\TimeSpan; +use Ecotone\Messaging\Support\LicensingException; use Ecotone\Messaging\Support\MessageBuilder; /** @@ -23,13 +25,18 @@ final class DelayableQueueChannel implements PollableChannel, DefinedObject /** * @param Message[] $queue */ - public function __construct(private string $name, private array $queue = [], private int|DateTimeInterface $releaseMessagesAwaitingFor = 0) + public function __construct(private string $name, private array $queue = [], private int|DateTimeInterface $releaseMessagesAwaitingFor = 0, private bool $batchMessagesSupport = false) { } - public static function create(string $name): self + public static function create(string $name, bool $batchMessagesSupport = false): self { - return new self($name); + return new self($name, batchMessagesSupport: $batchMessagesSupport); + } + + public function enableBatchMessagesSupport(): void + { + $this->batchMessagesSupport = true; } /** @@ -37,6 +44,21 @@ public static function create(string $name): self */ public function send(Message $message): void { + $payload = $message->getPayload(); + if ($payload instanceof BatchMessage) { + if (! $this->batchMessagesSupport) { + throw LicensingException::create('Sending BatchMessage is available only with Ecotone Enterprise licence.'); + } + + foreach ($payload->getEntries() as $entry) { + $this->queue[] = MessageBuilder::withPayload($entry['payload']) + ->setMultipleHeaders($entry['headers']) + ->build(); + } + + return; + } + $this->queue[] = $message; } @@ -97,7 +119,7 @@ public function __toString() public function getDefinition(): Definition { - return new Definition(self::class, [$this->name], 'create'); + return new Definition(self::class, [$this->name, $this->batchMessagesSupport], 'create'); } public function getCurrentDeliveryTimeShift(Message $message): int diff --git a/packages/Ecotone/src/Messaging/Channel/PollableChannel/SendRetries/SendRetryChannelInterceptor.php b/packages/Ecotone/src/Messaging/Channel/PollableChannel/SendRetries/SendRetryChannelInterceptor.php index f75378f20..bf27eb9fb 100644 --- a/packages/Ecotone/src/Messaging/Channel/PollableChannel/SendRetries/SendRetryChannelInterceptor.php +++ b/packages/Ecotone/src/Messaging/Channel/PollableChannel/SendRetries/SendRetryChannelInterceptor.php @@ -4,7 +4,10 @@ namespace Ecotone\Messaging\Channel\PollableChannel\SendRetries; +use Ecotone\Messaging\BatchMessage; use Ecotone\Messaging\Channel\AbstractChannelInterceptor; +use Ecotone\Messaging\Channel\AsyncPublishing\FailedDelivery; +use Ecotone\Messaging\Channel\AsyncPublishing\PublishingFailedException; use Ecotone\Messaging\Channel\ChannelInterceptor; use Ecotone\Messaging\Config\ConfiguredMessagingSystem; use Ecotone\Messaging\Handler\Gateway\ErrorChannelService; @@ -12,6 +15,7 @@ use Ecotone\Messaging\Message; use Ecotone\Messaging\MessageChannel; use Ecotone\Messaging\Scheduling\EcotoneClockInterface; +use Ecotone\Messaging\Support\MessageBuilder; use Exception; use Psr\Log\LoggerInterface; use Throwable; @@ -38,6 +42,8 @@ public function afterSendCompletion(Message $message, MessageChannel $messageCha return false; } + $messageToRedeliver = $this->messageContainingOnlyFailedDeliveries($message, $exception); + if ($exception !== null) { $attempt = 1; while ($this->retryTemplate->canBeCalledNextTime($attempt)) { @@ -49,10 +55,11 @@ public function afterSendCompletion(Message $message, MessageChannel $messageCha try { $this->clock->sleep($this->retryTemplate->durationToNextRetry($attempt)); - $messageChannel->send($message); + $messageChannel->send($messageToRedeliver); return true; } catch (Exception $exception) { + $messageToRedeliver = $this->messageContainingOnlyFailedDeliveries($messageToRedeliver, $exception); $attempt++; } } @@ -64,16 +71,61 @@ public function afterSendCompletion(Message $message, MessageChannel $messageCha ]); if ($this->deadLetterChannel !== null) { - $this->errorChannelService->handle( - $message, - $exception, - $this->configuredMessagingSystem->getMessageChannelByName($this->deadLetterChannel), - $this->relatedChannel, - ); + $deadLetterChannel = $this->configuredMessagingSystem->getMessageChannelByName($this->deadLetterChannel); + foreach ($this->unpackMessages($messageToRedeliver) as $failedMessage) { + $this->errorChannelService->handle( + $failedMessage, + $exception, + $deadLetterChannel, + $this->relatedChannel, + ); + } return true; } return false; } + + /** + * @return Message[] + */ + private function unpackMessages(Message $message): array + { + $payload = $message->getPayload(); + if (! $payload instanceof BatchMessage) { + return [$message]; + } + + return array_map( + static fn (array $entry): Message => MessageBuilder::withPayload($entry['payload']) + ->setMultipleHeaders($entry['headers']) + ->build(), + $payload->getEntries(), + ); + } + + private function messageContainingOnlyFailedDeliveries(Message $message, Throwable $exception): Message + { + if (! $exception instanceof PublishingFailedException || ! $message->getPayload() instanceof BatchMessage) { + return $message; + } + + $failedDeliveries = $exception->getFailedDeliveries(); + if ($failedDeliveries === []) { + return $message; + } + + $batchOfFailedDeliveries = BatchMessage::fromEntries(array_map( + static fn (FailedDelivery $failedDelivery): array => [ + 'payload' => $failedDelivery->getMessage()->getPayload(), + 'headers' => $failedDelivery->getMessage()->getHeaders()->headers(), + ], + $failedDeliveries, + )); + + return MessageBuilder::fromMessage($message) + ->setPayload($batchOfFailedDeliveries) + ->build(); + } } diff --git a/packages/Ecotone/src/Messaging/Channel/PollableChannel/Serialization/OutboundSerializationChannelInterceptor.php b/packages/Ecotone/src/Messaging/Channel/PollableChannel/Serialization/OutboundSerializationChannelInterceptor.php index d2be53928..b43612507 100644 --- a/packages/Ecotone/src/Messaging/Channel/PollableChannel/Serialization/OutboundSerializationChannelInterceptor.php +++ b/packages/Ecotone/src/Messaging/Channel/PollableChannel/Serialization/OutboundSerializationChannelInterceptor.php @@ -4,6 +4,7 @@ namespace Ecotone\Messaging\Channel\PollableChannel\Serialization; +use Ecotone\Messaging\BatchMessage; use Ecotone\Messaging\Channel\AbstractChannelInterceptor; use Ecotone\Messaging\Conversion\ConversionService; use Ecotone\Messaging\Message; @@ -32,6 +33,10 @@ public function preSend(Message $message, MessageChannel $messageChannel): ?Mess return $message; } + if ($message->getPayload() instanceof BatchMessage) { + return $message; + } + $outboundMessage = $this->outboundMessageConverter->prepare($message, $this->conversionService); $preparedMessage = MessageBuilder::withPayload($outboundMessage->getPayload()) ->setMultipleHeaders($outboundMessage->getHeaders()); diff --git a/packages/Ecotone/src/Messaging/Channel/QueueChannel.php b/packages/Ecotone/src/Messaging/Channel/QueueChannel.php index 7f501c3ef..be534ca26 100644 --- a/packages/Ecotone/src/Messaging/Channel/QueueChannel.php +++ b/packages/Ecotone/src/Messaging/Channel/QueueChannel.php @@ -2,11 +2,14 @@ namespace Ecotone\Messaging\Channel; +use Ecotone\Messaging\BatchMessage; use Ecotone\Messaging\Config\Container\DefinedObject; use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Endpoint\PollingMetadata; use Ecotone\Messaging\Message; use Ecotone\Messaging\PollableChannel; +use Ecotone\Messaging\Support\LicensingException; +use Ecotone\Messaging\Support\MessageBuilder; /** * licence Apache-2.0 @@ -18,13 +21,18 @@ class QueueChannel implements PollableChannel, DefinedObject */ private array $queue = []; - public function __construct(private string $name) + public function __construct(private string $name, private bool $batchMessagesSupport = false) { } - public static function create(string $name = 'unknown'): self + public static function create(string $name = 'unknown', bool $batchMessagesSupport = false): self { - return new self($name); + return new self($name, $batchMessagesSupport); + } + + public function enableBatchMessagesSupport(): void + { + $this->batchMessagesSupport = true; } /** @@ -32,6 +40,21 @@ public static function create(string $name = 'unknown'): self */ public function send(Message $message): void { + $payload = $message->getPayload(); + if ($payload instanceof BatchMessage) { + if (! $this->batchMessagesSupport) { + throw LicensingException::create('Sending BatchMessage is available only with Ecotone Enterprise licence.'); + } + + foreach ($payload->getEntries() as $entry) { + $this->queue[] = MessageBuilder::withPayload($entry['payload']) + ->setMultipleHeaders($entry['headers']) + ->build(); + } + + return; + } + $this->queue[] = $message; } @@ -68,6 +91,6 @@ public function __toString() public function getDefinition(): Definition { - return new Definition(self::class, [$this->name]); + return new Definition(self::class, [$this->name, $this->batchMessagesSupport]); } } diff --git a/packages/Ecotone/src/Messaging/Channel/SimpleMessageChannelBuilder.php b/packages/Ecotone/src/Messaging/Channel/SimpleMessageChannelBuilder.php index d8db4f5e7..0990d99d1 100644 --- a/packages/Ecotone/src/Messaging/Channel/SimpleMessageChannelBuilder.php +++ b/packages/Ecotone/src/Messaging/Channel/SimpleMessageChannelBuilder.php @@ -163,6 +163,13 @@ public function compile(MessagingContainerBuilder $builder): Definition ]); } + if ( + ($this->messageChannel instanceof QueueChannel || $this->messageChannel instanceof DelayableQueueChannel) + && $builder->getServiceConfiguration()->isRunningForEnterprise() + ) { + $this->messageChannel->enableBatchMessagesSupport(); + } + return new DefinedObjectWrapper($this->messageChannel); } diff --git a/packages/Ecotone/src/Messaging/Config/Container/Compiler/RegisterSingletonMessagingServices.php b/packages/Ecotone/src/Messaging/Config/Container/Compiler/RegisterSingletonMessagingServices.php index f2f36a08c..35652a393 100644 --- a/packages/Ecotone/src/Messaging/Config/Container/Compiler/RegisterSingletonMessagingServices.php +++ b/packages/Ecotone/src/Messaging/Config/Container/Compiler/RegisterSingletonMessagingServices.php @@ -3,6 +3,7 @@ namespace Ecotone\Messaging\Config\Container\Compiler; use Ecotone\EventSourcing\Mapping\EventMapper; +use Ecotone\Messaging\Channel\AsyncPublishing\AsyncPublishingRegistry; use Ecotone\Messaging\Config\ConfiguredMessagingSystem; use Ecotone\Messaging\Config\Container\ChannelResolverWithContainer; use Ecotone\Messaging\Config\Container\ContainerBuilder; @@ -20,6 +21,7 @@ use Ecotone\Messaging\Handler\Enricher\PropertyReaderAccessor; use Ecotone\Messaging\Handler\ExpressionEvaluationService; use Ecotone\Messaging\Handler\Gateway\ProxyFactory; +use Ecotone\Messaging\Handler\Logger\LoggingGateway; use Ecotone\Messaging\Handler\ReferenceSearchService; use Ecotone\Messaging\Handler\SymfonyExpressionEvaluationAdapter; use Ecotone\Messaging\NullableMessageChannel; @@ -59,6 +61,7 @@ public function process(ContainerBuilder $builder): void $this->registerDefault($builder, ConfiguredMessagingSystem::class, new Definition(MessagingSystemContainer::class, [new Reference(ContainerInterface::class), [], []])); $this->registerDefault($builder, EventMapper::class, new Definition(EventMapper::class, factory: 'createEmpty')); $this->registerDefault($builder, LicenceDecider::class, new Definition(LicenceDecider::class, [$this->serviceConfiguration->isRunningForEnterprise()])); + $this->registerDefault($builder, AsyncPublishingRegistry::class, new Definition(AsyncPublishingRegistry::class, [new Reference(LoggingGateway::class)])); } private function registerDefault(ContainerBuilder $builder, string $id, Definition|Reference $definition): void diff --git a/packages/Ecotone/src/Messaging/Config/ModuleClassList.php b/packages/Ecotone/src/Messaging/Config/ModuleClassList.php index 713232361..1046e033c 100644 --- a/packages/Ecotone/src/Messaging/Config/ModuleClassList.php +++ b/packages/Ecotone/src/Messaging/Config/ModuleClassList.php @@ -26,6 +26,7 @@ use Ecotone\Kafka\Configuration\KafkaModule; use Ecotone\Laravel\Config\LaravelConnectionModule; use Ecotone\Lite\Test\Configuration\EcotoneTestSupportModule; +use Ecotone\Messaging\Channel\AsyncPublishing\Config\AsyncPublishingModule; use Ecotone\Messaging\Channel\Collector\Config\CollectorModule; use Ecotone\Messaging\Channel\DynamicChannel\Config\DynamicMessageChannelModule; use Ecotone\Messaging\Channel\Manager\ChannelSetupModule; @@ -108,6 +109,7 @@ class ModuleClassList RouterModule::class, ScheduledModule::class, CollectorModule::class, + AsyncPublishingModule::class, ChannelSetupModule::class, SerializerModule::class, ServiceActivatorModule::class, diff --git a/packages/Ecotone/src/Messaging/Handler/Gateway/FutureReplyReceiver.php b/packages/Ecotone/src/Messaging/Handler/Gateway/FutureReplyReceiver.php index cf4b437d2..41e3075b2 100644 --- a/packages/Ecotone/src/Messaging/Handler/Gateway/FutureReplyReceiver.php +++ b/packages/Ecotone/src/Messaging/Handler/Gateway/FutureReplyReceiver.php @@ -21,6 +21,10 @@ class FutureReplyReceiver implements Future */ private $replyCallable; + private bool $resolved = false; + + private mixed $resolvedValue = null; + /** * FutureReplySender constructor. * @param callable $replyCallable @@ -44,10 +48,24 @@ public static function create(callable $replyCallable): self */ public function resolve() { + if ($this->resolved) { + if ($this->resolvedValue instanceof Future) { + return $this->resolvedValue->resolve(); + } + + return $this->resolvedValue; + } + $replyCallable = $this->replyCallable; /** @var Message $message */ $message = $replyCallable(); + $this->resolvedValue = $message ? $message->getPayload() : null; + $this->resolved = true; + + if ($this->resolvedValue instanceof Future) { + return $this->resolvedValue->resolve(); + } - return $message ? $message->getPayload() : null; + return $this->resolvedValue; } } diff --git a/packages/Ecotone/src/Messaging/MessagePublisher.php b/packages/Ecotone/src/Messaging/MessagePublisher.php index 92146b3c7..2ffec2626 100644 --- a/packages/Ecotone/src/Messaging/MessagePublisher.php +++ b/packages/Ecotone/src/Messaging/MessagePublisher.php @@ -18,4 +18,6 @@ public function sendWithMetadata(string $data, string $sourceMediaType = MediaTy public function convertAndSend(object|array $data): void; public function convertAndSendWithMetadata(object|array $data, array $metadata): void; + + public function asyncPublish(mixed $data, string $sourceMediaType = MediaType::APPLICATION_X_PHP, array $metadata = []): Future; } diff --git a/packages/Ecotone/src/Messaging/Precedence.php b/packages/Ecotone/src/Messaging/Precedence.php index 21f301284..2744a9945 100644 --- a/packages/Ecotone/src/Messaging/Precedence.php +++ b/packages/Ecotone/src/Messaging/Precedence.php @@ -58,10 +58,15 @@ interface Precedence */ public const DATABASE_TRANSACTION_PRECEDENCE = -2000; + /** + * Awaits delivery confirmations of asynchronously published messages before transaction commits + */ + public const ASYNC_PUBLISHING_AWAIT_PRECEDENCE = self::DATABASE_TRANSACTION_PRECEDENCE + 1; + /** * Collects messages to be sent to asynchronous channels. */ - public const COLLECTOR_SENDER_PRECEDENCE = self::DATABASE_TRANSACTION_PRECEDENCE + 1; + public const COLLECTOR_SENDER_PRECEDENCE = self::ASYNC_PUBLISHING_AWAIT_PRECEDENCE + 1; public const DATABASE_OBJECT_MANAGER_PRECEDENCE = self::COLLECTOR_SENDER_PRECEDENCE + 1; diff --git a/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/AsyncOrderForwarder.php b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/AsyncOrderForwarder.php new file mode 100644 index 000000000..6dadbde4e --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/AsyncOrderForwarder.php @@ -0,0 +1,27 @@ +operationsLog->log('consumer handler executed'); + $eventBus->publish(new OrderWasPlaced($event->order . '-forwarded')); + } +} diff --git a/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/AsyncOrderSubscriber.php b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/AsyncOrderSubscriber.php new file mode 100644 index 000000000..8926ce032 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/AsyncOrderSubscriber.php @@ -0,0 +1,32 @@ +receivedEvents[] = $event; + } + + /** + * @return string[] + */ + public function getReceivedOrderIds(): array + { + return array_map(fn (OrderWasPlaced $event) => $event->orderId, $this->receivedEvents); + } +} diff --git a/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/FakeTransactionInterceptor.php b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/FakeTransactionInterceptor.php new file mode 100644 index 000000000..01edb2d77 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/FakeTransactionInterceptor.php @@ -0,0 +1,42 @@ +transactionActive) { + return $methodInvocation->proceed(); + } + + $this->transactionActive = true; + $this->operationsLog->log('transaction started'); + try { + $result = $methodInvocation->proceed(); + $this->operationsLog->log('transaction committed'); + + return $result; + } catch (Throwable $exception) { + $this->operationsLog->log('transaction rolled back'); + + throw $exception; + } finally { + $this->transactionActive = false; + } + } +} diff --git a/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/FakeTransactionModule.php b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/FakeTransactionModule.php new file mode 100644 index 000000000..b241c01dd --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/FakeTransactionModule.php @@ -0,0 +1,58 @@ +registerServiceDefinition( + FakeTransactionInterceptor::class, + new Definition(FakeTransactionInterceptor::class, [new Reference(OperationsLog::class)]) + ); + $messagingConfiguration->registerAroundMethodInterceptor( + AroundInterceptorBuilder::create( + FakeTransactionInterceptor::class, + $interfaceToCallRegistry->getFor(FakeTransactionInterceptor::class, 'transactional'), + Precedence::DATABASE_TRANSACTION_PRECEDENCE, + CommandBus::class . '||' . AsynchronousRunningEndpoint::class, + ) + ); + } + + public function canHandle($extensionObject): bool + { + return false; + } + + public function getModulePackageName(): string + { + return ModulePackageList::CORE_PACKAGE; + } +} diff --git a/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryAsyncOutboundAdapter.php b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryAsyncOutboundAdapter.php new file mode 100644 index 000000000..23767a13e --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryAsyncOutboundAdapter.php @@ -0,0 +1,126 @@ +sentMessages[] = $message; + + if (! $this->registersPendingDeliveries) { + return; + } + + $pendingDelivery = new InMemoryPendingDelivery($message, $this->resolveFailureReason($message), failedMessages: $this->resolveFailedMessages($message)); + $this->pendingDeliveries[] = $pendingDelivery; + $asyncPublishingRegistry->register(InMemoryAsyncPublisherModule::PUBLISHER_REFERENCE, $pendingDelivery); + } + + /** + * @return Message[] + */ + public function getSentMessages(): array + { + return $this->sentMessages; + } + + /** + * @return mixed[] + */ + public function getSentPayloads(): array + { + return array_map(fn (Message $message) => $message->getPayload(), $this->sentMessages); + } + + public function awaitedDeliveriesCount(): int + { + return count(array_filter($this->pendingDeliveries, fn (InMemoryPendingDelivery $pendingDelivery) => $pendingDelivery->isAwaited())); + } + + public function totalAwaitCalls(): int + { + return array_sum(array_map(fn (InMemoryPendingDelivery $pendingDelivery) => $pendingDelivery->awaitCalls(), $this->pendingDeliveries)); + } + + public function failDeliveriesWith(string $failureReason): void + { + $this->deliveryFailureReason = $failureReason; + } + + public function failDeliveriesContaining(string $payloadFragment, string $failureReason): void + { + $this->failingPayloadFragment = $payloadFragment; + $this->deliveryFailureReason = $failureReason; + } + + private function resolveFailureReason(Message $message): ?string + { + if ($this->failingPayloadFragment === null) { + return $this->deliveryFailureReason; + } + + return $this->resolveFailedMessages($message) === [] ? null : $this->deliveryFailureReason; + } + + /** + * @return Message[] + */ + private function resolveFailedMessages(Message $message): array + { + if ($this->failingPayloadFragment === null) { + return []; + } + + $payload = $message->getPayload(); + if (! $payload instanceof BatchMessage) { + return $this->matchesFailingFragment($payload) ? [$message] : []; + } + + $failedMessages = []; + foreach ($payload->getEntries() as $entry) { + if ($this->matchesFailingFragment($entry['payload'])) { + $failedMessages[] = MessageBuilder::withPayload($entry['payload']) + ->setMultipleHeaders($entry['headers']) + ->build(); + } + } + + return $failedMessages; + } + + private function matchesFailingFragment(mixed $payload): bool + { + $payloadAsString = is_string($payload) ? $payload : json_encode($payload); + + return is_string($payloadAsString) && str_contains($payloadAsString, $this->failingPayloadFragment); + } + + public function actAsSynchronousPublisher(): void + { + $this->registersPendingDeliveries = false; + } +} diff --git a/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryAsyncPublisherModule.php b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryAsyncPublisherModule.php new file mode 100644 index 000000000..4e3a3ebb0 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryAsyncPublisherModule.php @@ -0,0 +1,56 @@ +registerMessageChannel(SimpleMessageChannelBuilder::createDirectMessageChannel($publisherReference)) + ->registerMessageHandler( + ServiceActivatorBuilder::create(InMemoryAsyncOutboundAdapter::class, 'handle') + ->withInputChannelName($publisherReference) + ->withEndpointId($publisherReference . '.handler') + ); + } + + public function canHandle($extensionObject): bool + { + return false; + } + + public function getModulePackageName(): string + { + return ModulePackageList::CORE_PACKAGE; + } +} diff --git a/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryAsyncPublishingChannel.php b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryAsyncPublishingChannel.php new file mode 100644 index 000000000..f4ec8cb11 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryAsyncPublishingChannel.php @@ -0,0 +1,142 @@ +getPayload(); + + if ($payload instanceof BatchMessage) { + $this->operationsLog->log(sprintf('published batch of %d messages to broker', count($payload))); + foreach ($payload->getEntries() as $entry) { + $this->queue[] = MessageBuilder::withPayload($entry['payload']) + ->setMultipleHeaders($entry['headers']) + ->build(); + } + } else { + $this->operationsLog->log('published message to broker'); + $this->queue[] = $message; + } + + $pendingDelivery = new InMemoryPendingDelivery( + $message, + $this->resolveFailureReason($message), + $this->operationsLog, + $this->channelName, + failedMessages: $this->resolveFailedMessages($message), + ); + + if (! $this->asyncPublishingRegistry->isScopeActive()) { + $deliveryResult = $pendingDelivery->awaitDelivery(); + if (! $deliveryResult->isSuccessful()) { + throw PublishingFailedException::withFailedDeliveries($deliveryResult->getFailedDeliveries()); + } + + return; + } + + $this->asyncPublishingRegistry->register($this->channelName, $pendingDelivery); + } + + public function receive(): ?Message + { + return array_shift($this->queue) ?: null; + } + + public function receiveWithTimeout(PollingMetadata $pollingMetadata): ?Message + { + return $this->receive(); + } + + public function onConsumerStop(): void + { + } + + public function supportsBatchMessages(): bool + { + return true; + } + + public function failDeliveriesWith(string $failureReason): void + { + $this->deliveryFailureReason = $failureReason; + } + + public function failDeliveriesContaining(string $payloadFragment, string $failureReason): void + { + $this->failingPayloadFragment = $payloadFragment; + $this->deliveryFailureReason = $failureReason; + } + + private function resolveFailureReason(Message $message): ?string + { + if ($this->failingPayloadFragment === null) { + return $this->deliveryFailureReason; + } + + return $this->resolveFailedMessages($message) === [] ? null : $this->deliveryFailureReason; + } + + /** + * @return Message[] + */ + private function resolveFailedMessages(Message $message): array + { + if ($this->failingPayloadFragment === null) { + return []; + } + + $payload = $message->getPayload(); + if (! $payload instanceof BatchMessage) { + return $this->matchesFailingFragment($payload) ? [$message] : []; + } + + $failedMessages = []; + foreach ($payload->getEntries() as $entry) { + if ($this->matchesFailingFragment($entry['payload'])) { + $failedMessages[] = MessageBuilder::withPayload($entry['payload']) + ->setMultipleHeaders($entry['headers']) + ->build(); + } + } + + return $failedMessages; + } + + private function matchesFailingFragment(mixed $payload): bool + { + $payloadAsString = is_string($payload) ? $payload : json_encode($payload); + + return is_string($payloadAsString) && str_contains($payloadAsString, $this->failingPayloadFragment); + } +} diff --git a/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryAsyncPublishingChannelBuilder.php b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryAsyncPublishingChannelBuilder.php new file mode 100644 index 000000000..edf9fbbf0 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryAsyncPublishingChannelBuilder.php @@ -0,0 +1,50 @@ +channelName; + } + + public function isPollable(): bool + { + return true; + } + + public function isStreamingChannel(): bool + { + return false; + } + + public function compile(MessagingContainerBuilder $builder): Definition|Reference + { + return new Definition(InMemoryAsyncPublishingChannel::class, [ + $this->channelName, + new Reference(AsyncPublishingRegistry::class), + new Reference(OperationsLog::class), + ]); + } +} diff --git a/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryPendingDelivery.php b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryPendingDelivery.php new file mode 100644 index 000000000..03c733022 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/InMemoryPendingDelivery.php @@ -0,0 +1,69 @@ +deliveryResult !== null) { + return $this->deliveryResult; + } + + $this->awaitCalls++; + $this->operationsLog?->log('delivery confirmations awaited'); + + if ($this->throwOnAwait) { + throw new RuntimeException('broker connection lost while awaiting confirmation'); + } + + if ($this->failureReason !== null) { + $messagesToFail = $this->failedMessages !== [] ? $this->failedMessages : [$this->message]; + + return $this->deliveryResult = DeliveryResult::withFailedDeliveries(array_map( + fn (Message $failedMessage): FailedDelivery => new FailedDelivery($failedMessage, $this->failureReason, $this->channelName), + $messagesToFail, + )); + } + + return $this->deliveryResult = DeliveryResult::successful(); + } + + public function isAwaited(): bool + { + return $this->awaitCalls > 0; + } + + public function awaitCalls(): int + { + return $this->awaitCalls; + } +} diff --git a/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/OperationsLog.php b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/OperationsLog.php new file mode 100644 index 000000000..2813100db --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/OperationsLog.php @@ -0,0 +1,27 @@ +operations[] = $operation; + } + + /** + * @return string[] + */ + public function getOperations(): array + { + return $this->operations; + } +} diff --git a/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/OrderRequestReceived.php b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/OrderRequestReceived.php new file mode 100644 index 000000000..8d576a417 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/OrderRequestReceived.php @@ -0,0 +1,15 @@ +operationsLog->log('command handler executed'); + $eventBus->publish(new OrderWasPlaced($order . '-1')); + $eventBus->publish(new OrderWasPlaced($order . '-2')); + } + + #[CommandHandler('order.forward')] + public function forwardOrder(string $order, CommandBus $commandBus): void + { + $this->operationsLog->log('forwarding command handler executed'); + $commandBus->sendWithRouting('order.place', $order); + } +} diff --git a/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/OrderWasPlaced.php b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/OrderWasPlaced.php new file mode 100644 index 000000000..c10c29e5c --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/AsyncPublishing/OrderWasPlaced.php @@ -0,0 +1,15 @@ +append('first payload') + ->append($orderPlaced, ['priority' => 5]); + + $this->assertSame( + [ + ['payload' => 'first payload', 'headers' => []], + ['payload' => $orderPlaced, 'headers' => ['priority' => 5]], + ], + $batch->getEntries(), + ); + } + + public function test_counting_appended_messages(): void + { + $this->assertCount(0, BatchMessage::constructEmpty()); + $this->assertCount(2, BatchMessage::constructEmpty()->append('one')->append('two')); + } + + public function test_appending_returns_new_instance_keeping_original_untouched(): void + { + $original = BatchMessage::constructEmpty()->append('first payload'); + + $extended = $original->append('second payload'); + + $this->assertCount(1, $original); + $this->assertCount(2, $extended); + } + + public function test_constructing_from_entries(): void + { + $batch = BatchMessage::fromEntries([ + ['payload' => 'first payload', 'headers' => []], + ['payload' => 'second payload', 'headers' => ['priority' => 5]], + ]); + + $this->assertSame( + [ + ['payload' => 'first payload', 'headers' => []], + ['payload' => 'second payload', 'headers' => ['priority' => 5]], + ], + $batch->getEntries(), + ); + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/AsyncPublishingChannelTest.php b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/AsyncPublishingChannelTest.php new file mode 100644 index 000000000..fc87f03da --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/AsyncPublishingChannelTest.php @@ -0,0 +1,82 @@ +bootstrapEcotone($operationsLog); + + $ecotoneLite->sendCommandWithRoutingKey('order.place', 'espresso'); + + $this->assertSame( + [ + 'transaction started', + 'command handler executed', + 'published batch of 2 messages to broker', + 'delivery confirmations awaited', + 'transaction committed', + ], + $operationsLog->getOperations(), + ); + } + + public function test_failed_delivery_confirmation_fails_command_execution_and_rolls_back_transaction(): void + { + $operationsLog = new OperationsLog(); + $ecotoneLite = $this->bootstrapEcotone($operationsLog); + $channel = $ecotoneLite->getMessageChannel('async_orders'); + assert($channel instanceof MessageChannelInterceptorAdapter); + $channel->getInternalMessageChannel()->failDeliveriesWith('broker not available'); + + $commandException = null; + try { + $ecotoneLite->sendCommandWithRoutingKey('order.place', 'espresso'); + } catch (PublishingFailedException $exception) { + $commandException = $exception; + } + + $this->assertInstanceOf(PublishingFailedException::class, $commandException); + $this->assertStringContainsString('broker not available', $commandException->getMessage()); + $this->assertSame( + [ + 'transaction started', + 'command handler executed', + 'published batch of 2 messages to broker', + 'delivery confirmations awaited', + 'transaction rolled back', + ], + $operationsLog->getOperations(), + ); + } + + private function bootstrapEcotone(OperationsLog $operationsLog): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + [OrderService::class, AsyncOrderSubscriber::class, FakeTransactionModule::class], + [new OrderService($operationsLog), new AsyncOrderSubscriber(), OperationsLog::class => $operationsLog], + enableAsynchronousProcessing: [ + InMemoryAsyncPublishingChannelBuilder::create('async_orders'), + ], + ); + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/AsyncPublishingCollectorMatrixTest.php b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/AsyncPublishingCollectorMatrixTest.php new file mode 100644 index 000000000..8841b2a1f --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/AsyncPublishingCollectorMatrixTest.php @@ -0,0 +1,77 @@ +bootstrapEcotone($operationsLog, collectorEnabled: false); + + $ecotoneLite->sendCommandWithRoutingKey('order.place', 'espresso'); + + $this->assertSame( + [ + 'transaction started', + 'command handler executed', + 'published message to broker', + 'published message to broker', + 'delivery confirmations awaited', + 'delivery confirmations awaited', + 'transaction committed', + ], + $operationsLog->getOperations(), + ); + } + + public function test_messages_are_consumable_from_channel_with_and_without_collector(): void + { + foreach ([true, false] as $collectorEnabled) { + $ecotoneLite = $this->bootstrapEcotone(new OperationsLog(), $collectorEnabled); + + $ecotoneLite->sendCommandWithRoutingKey('order.place', 'espresso'); + + $this->assertEquals( + [new OrderWasPlaced('espresso-1'), new OrderWasPlaced('espresso-2')], + [ + $ecotoneLite->receiveMessageFrom('async_orders')->getPayload(), + $ecotoneLite->receiveMessageFrom('async_orders')->getPayload(), + ], + ); + $this->assertNull($ecotoneLite->receiveMessageFrom('async_orders')); + } + } + + private function bootstrapEcotone(OperationsLog $operationsLog, bool $collectorEnabled): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + [OrderService::class, AsyncOrderSubscriber::class, FakeTransactionModule::class], + [new OrderService($operationsLog), new AsyncOrderSubscriber(), OperationsLog::class => $operationsLog], + ServiceConfiguration::createWithDefaults()->withExtensionObjects([ + PollableChannelConfiguration::neverRetry('async_orders')->withCollector($collectorEnabled), + ]), + enableAsynchronousProcessing: [ + InMemoryAsyncPublishingChannelBuilder::create('async_orders'), + ], + ); + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/AsyncPublishingReliabilityTest.php b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/AsyncPublishingReliabilityTest.php new file mode 100644 index 000000000..dbabab250 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/AsyncPublishingReliabilityTest.php @@ -0,0 +1,254 @@ +build(), + throwOnAwait: true, + ); + $future = DeliveryFuture::forPendingDeliveries([$throwingDelivery]); + + $firstResolveException = null; + try { + $future->resolve(); + } catch (PublishingFailedException $exception) { + $firstResolveException = $exception; + } + $this->assertNotNull($firstResolveException); + + $this->expectException(PublishingFailedException::class); + + $future->resolve(); + } + + public function test_future_does_not_reawait_deliveries_already_awaited_by_interceptor_scope(): void + { + $alreadyAwaitedDelivery = new InMemoryPendingDelivery(MessageBuilder::withPayload('order')->build()); + $alreadyAwaitedDelivery->awaitDelivery(); + + DeliveryFuture::forPendingDeliveries([$alreadyAwaitedDelivery])->resolve(); + + $this->assertSame(1, $alreadyAwaitedDelivery->awaitCalls()); + } + + public function test_failed_deliveries_routed_to_async_error_channel_are_awaited_before_commit(): void + { + $operationsLog = new OperationsLog(); + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + [OrderService::class, AsyncOrderSubscriber::class, FakeTransactionModule::class], + [new OrderService($operationsLog), new AsyncOrderSubscriber(), OperationsLog::class => $operationsLog], + ServiceConfiguration::createWithDefaults()->withExtensionObjects([ + \Ecotone\Messaging\Channel\PollableChannel\GlobalPollableChannelConfiguration::createWithDefaults()->withErrorChannel('failure_channel'), + ]), + enableAsynchronousProcessing: [ + InMemoryAsyncPublishingChannelBuilder::create('async_orders'), + InMemoryAsyncPublishingChannelBuilder::create('failure_channel'), + ], + ); + $ordersChannel = $ecotoneLite->getMessageChannel('async_orders'); + assert($ordersChannel instanceof MessageChannelInterceptorAdapter); + $ordersChannel->getInternalMessageChannel()->failDeliveriesWith('broker not available'); + + $ecotoneLite->sendCommandWithRoutingKey('order.place', 'espresso'); + + $operations = $operationsLog->getOperations(); + $this->assertSame('transaction committed', $operations[count($operations) - 1]); + $awaitedConfirmationsForFailedBatchAndEachErrorChannelMessage = count(array_filter($operations, fn (string $operation) => $operation === 'delivery confirmations awaited')); + $this->assertSame(3, $awaitedConfirmationsForFailedBatchAndEachErrorChannelMessage); + } + + public function test_future_of_delivery_flushed_by_backlog_limit_still_reports_failure(): void + { + $outboundAdapter = new InMemoryAsyncOutboundAdapter(); + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + [InMemoryAsyncPublisherModule::class, InMemoryAsyncOutboundAdapter::class], + [$outboundAdapter], + ); + $publisher = $ecotoneLite->getGateway(InMemoryAsyncPublisherModule::PUBLISHER_REFERENCE); + $outboundAdapter->failDeliveriesWith('broker down'); + + $firstFuture = $publisher->asyncPublish('first order'); + for ($messageNumber = 0; $messageNumber < 1300; $messageNumber++) { + $publisher->asyncPublish('unresolved order ' . $messageNumber); + } + $this->assertGreaterThan(0, $outboundAdapter->awaitedDeliveriesCount()); + + $this->expectException(PublishingFailedException::class); + + $firstFuture->resolve(); + } + + public function test_unawaited_deliveries_of_all_registries_are_flushed_on_script_shutdown(): void + { + $scriptPath = tempnam(sys_get_temp_dir(), 'async_publishing_shutdown_flush_'); + file_put_contents($scriptPath, <<<'PHP' + awaited = true; + echo 'flushed;'; + + return \Ecotone\Messaging\Channel\AsyncPublishing\DeliveryResult::successful(); + } + + public function isAwaited(): bool + { + return $this->awaited; + } + }; + + $firstRegistry = new \Ecotone\Messaging\Channel\AsyncPublishing\AsyncPublishingRegistry(new \Ecotone\Messaging\Handler\Logger\LoggingService()); + $firstRegistry->register('orders', $unawaitedDelivery); + $secondRegistry = new \Ecotone\Messaging\Channel\AsyncPublishing\AsyncPublishingRegistry(new \Ecotone\Messaging\Handler\Logger\LoggingService()); + $secondRegistry->register('shipments', clone $unawaitedDelivery); + + echo 'script finished;'; + PHP); + + $output = shell_exec(sprintf( + 'php %s %s 2>&1', + escapeshellarg($scriptPath), + escapeshellarg($this->nearestComposerAutoloadPath()), + )); + unlink($scriptPath); + + $this->assertSame('script finished;flushed;flushed;', $output); + } + + private function nearestComposerAutoloadPath(): string + { + for ($directory = __DIR__; $directory !== dirname($directory); $directory = dirname($directory)) { + if (file_exists($directory . '/vendor/autoload.php')) { + return $directory . '/vendor/autoload.php'; + } + } + + $this->fail('No composer autoload found above ' . __DIR__); + } + + public function test_shutdown_flush_continues_when_one_delivery_throws(): void + { + $registry = new AsyncPublishingRegistry(new LoggingService()); + $throwingDelivery = new InMemoryPendingDelivery(MessageBuilder::withPayload('first order')->build(), throwOnAwait: true); + $followingDelivery = new InMemoryPendingDelivery(MessageBuilder::withPayload('second order')->build()); + $registry->register('orders', $throwingDelivery); + $registry->register('orders', $followingDelivery); + + $registry->flushUnawaitedDeliveries(); + + $this->assertTrue($followingDelivery->isAwaited()); + } + + public function test_closing_scope_awaits_deliveries_left_unawaited_when_execution_fails_before_await(): void + { + $registry = new AsyncPublishingRegistry(new LoggingService()); + $registry->openScope(); + $delivery = new InMemoryPendingDelivery(MessageBuilder::withPayload('order')->build()); + $registry->register('orders', $delivery); + + $registry->closeScope(); + + $this->assertTrue($delivery->isAwaited()); + } + + public function test_future_awaits_remaining_deliveries_when_earlier_delivery_throws(): void + { + $throwingDelivery = new InMemoryPendingDelivery(MessageBuilder::withPayload('first order')->build(), throwOnAwait: true); + $followingDelivery = new InMemoryPendingDelivery(MessageBuilder::withPayload('second order')->build()); + $future = DeliveryFuture::forPendingDeliveries([$throwingDelivery, $followingDelivery]); + + try { + $future->resolve(); + } catch (PublishingFailedException) { + } + + $this->assertTrue($followingDelivery->isAwaited()); + } + + public function test_one_failing_batch_among_many_published_in_command_handler_rolls_back_transaction(): void + { + $operationsLog = new OperationsLog(); + $outboundAdapter = new InMemoryAsyncOutboundAdapter(); + $commandHandler = new class () { + #[CommandHandler('order.placeAllBatches')] + public function handle(string $order, #[Reference(InMemoryAsyncPublisherModule::PUBLISHER_REFERENCE)] MessagePublisher $publisher): void + { + $publisher->asyncPublish(BatchMessage::constructEmpty()->append($order . ' first batch')); + $publisher->asyncPublish(BatchMessage::constructEmpty()->append($order . ' poisoned batch')); + $publisher->asyncPublish(BatchMessage::constructEmpty()->append($order . ' third batch')); + } + }; + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + [$commandHandler::class, InMemoryAsyncPublisherModule::class, InMemoryAsyncOutboundAdapter::class, FakeTransactionModule::class], + [$commandHandler, $outboundAdapter, OperationsLog::class => $operationsLog], + ); + $outboundAdapter->failDeliveriesContaining('poisoned', 'broker rejected the batch'); + + $commandFailed = false; + try { + $ecotoneLite->sendCommandWithRoutingKey('order.placeAllBatches', 'espresso'); + } catch (PublishingFailedException) { + $commandFailed = true; + } + + $this->assertTrue($commandFailed); + $operations = $operationsLog->getOperations(); + $this->assertSame('transaction rolled back', $operations[count($operations) - 1]); + $this->assertSame(3, $outboundAdapter->awaitedDeliveriesCount()); + } + + public function test_unresolved_publisher_futures_above_backlog_limit_are_flushed(): void + { + $outboundAdapter = new InMemoryAsyncOutboundAdapter(); + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + [InMemoryAsyncPublisherModule::class, InMemoryAsyncOutboundAdapter::class], + [$outboundAdapter], + ); + $publisher = $ecotoneLite->getGateway(InMemoryAsyncPublisherModule::PUBLISHER_REFERENCE); + + for ($messageNumber = 0; $messageNumber < 1300; $messageNumber++) { + $publisher->asyncPublish('unresolved order ' . $messageNumber); + } + + $this->assertGreaterThan(0, $outboundAdapter->awaitedDeliveriesCount()); + $this->assertLessThan(1300, $outboundAdapter->awaitedDeliveriesCount()); + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/AsyncPublishingScenariosTest.php b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/AsyncPublishingScenariosTest.php new file mode 100644 index 000000000..e86d34619 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/AsyncPublishingScenariosTest.php @@ -0,0 +1,176 @@ +bootstrapEcotone($operationsLog); + + $ecotoneLite->sendCommandWithRoutingKey('order.forward', 'espresso'); + + $this->assertSame( + [ + 'transaction started', + 'forwarding command handler executed', + 'command handler executed', + 'published batch of 2 messages to broker', + 'delivery confirmations awaited', + 'transaction committed', + ], + $operationsLog->getOperations(), + ); + } + + public function test_polling_consumer_awaits_deliveries_before_acknowledging_inbound_message(): void + { + $operationsLog = new OperationsLog(); + $ecotoneLite = $this->bootstrapEcotone($operationsLog); + + $ecotoneLite->publishEvent(new OrderRequestReceived('espresso')); + $this->assertSame([], $operationsLog->getOperations()); + + $ecotoneLite->run('incoming_orders', ExecutionPollingMetadata::createWithTestingSetup()); + + $this->assertSame( + [ + 'transaction started', + 'consumer handler executed', + 'published batch of 1 messages to broker', + 'delivery confirmations awaited', + 'transaction committed', + ], + $operationsLog->getOperations(), + ); + $this->assertEquals( + new OrderWasPlaced('espresso-forwarded'), + $ecotoneLite->receiveMessageFrom('async_orders')->getPayload(), + ); + } + + public function test_bus_driven_sends_outside_any_scope_fall_back_to_synchronous_awaiting(): void + { + $operationsLog = new OperationsLog(); + $ecotoneLite = $this->bootstrapEcotone($operationsLog); + + $ecotoneLite->publishEvent(new OrderWasPlaced('espresso-1')); + + $this->assertSame( + [ + 'published message to broker', + 'delivery confirmations awaited', + ], + $operationsLog->getOperations(), + ); + $this->assertEquals( + new OrderWasPlaced('espresso-1'), + $ecotoneLite->receiveMessageFrom('async_orders')->getPayload(), + ); + } + + public function test_failed_deliveries_are_routed_to_error_channel_and_transaction_commits(): void + { + $operationsLog = new OperationsLog(); + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + [OrderService::class, AsyncOrderSubscriber::class, FakeTransactionModule::class], + [new OrderService($operationsLog), new AsyncOrderSubscriber(), OperationsLog::class => $operationsLog], + ServiceConfiguration::createWithDefaults()->withExtensionObjects([ + GlobalPollableChannelConfiguration::createWithDefaults()->withErrorChannel('failure_channel'), + PollableChannelConfiguration::neverRetry('async_orders')->withCollector(false)->withErrorChannel('failure_channel'), + ]), + enableAsynchronousProcessing: [ + InMemoryAsyncPublishingChannelBuilder::create('async_orders'), + SimpleMessageChannelBuilder::createQueueChannel('failure_channel'), + ], + ); + $channel = $ecotoneLite->getMessageChannel('async_orders'); + assert($channel instanceof MessageChannelInterceptorAdapter); + $channel->getInternalMessageChannel()->failDeliveriesWith('broker not available'); + + $ecotoneLite->sendCommandWithRoutingKey('order.place', 'espresso'); + + $this->assertSame('transaction committed', $operationsLog->getOperations()[count($operationsLog->getOperations()) - 1]); + + $firstFailedMessage = $ecotoneLite->receiveMessageFrom('failure_channel'); + $secondFailedMessage = $ecotoneLite->receiveMessageFrom('failure_channel'); + $this->assertStringContainsString('espresso-1', $firstFailedMessage->getPayload()); + $this->assertStringContainsString('espresso-2', $secondFailedMessage->getPayload()); + $this->assertStringContainsString(OrderWasPlaced::class, $firstFailedMessage->getHeaders()->get(MessageHeaders::TYPE_ID)); + $this->assertStringContainsString('broker not available', $firstFailedMessage->getHeaders()->get(ErrorContext::EXCEPTION_MESSAGE)); + $this->assertNull($ecotoneLite->receiveMessageFrom('failure_channel')); + } + + public function test_only_failed_message_from_batch_is_routed_to_error_channel(): void + { + $operationsLog = new OperationsLog(); + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + [OrderService::class, AsyncOrderSubscriber::class, FakeTransactionModule::class], + [new OrderService($operationsLog), new AsyncOrderSubscriber(), OperationsLog::class => $operationsLog], + ServiceConfiguration::createWithDefaults()->withExtensionObjects([ + GlobalPollableChannelConfiguration::createWithDefaults()->withErrorChannel('failure_channel'), + PollableChannelConfiguration::neverRetry('async_orders')->withCollector(false)->withErrorChannel('failure_channel'), + ]), + enableAsynchronousProcessing: [ + InMemoryAsyncPublishingChannelBuilder::create('async_orders'), + SimpleMessageChannelBuilder::createQueueChannel('failure_channel'), + ], + ); + $channel = $ecotoneLite->getMessageChannel('async_orders'); + assert($channel instanceof MessageChannelInterceptorAdapter); + $channel->getInternalMessageChannel()->failDeliveriesContaining('espresso-2', 'broker rejected message'); + + $ecotoneLite->sendCommandWithRoutingKey('order.place', 'espresso'); + + $this->assertSame('transaction committed', $operationsLog->getOperations()[count($operationsLog->getOperations()) - 1]); + + $failedMessage = $ecotoneLite->receiveMessageFrom('failure_channel'); + $this->assertStringContainsString('espresso-2', $failedMessage->getPayload()); + $this->assertStringContainsString('broker rejected message', $failedMessage->getHeaders()->get(ErrorContext::EXCEPTION_MESSAGE)); + $this->assertNull($ecotoneLite->receiveMessageFrom('failure_channel')); + } + + private function bootstrapEcotone(OperationsLog $operationsLog): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + [OrderService::class, AsyncOrderSubscriber::class, AsyncOrderForwarder::class, FakeTransactionModule::class], + [ + new OrderService($operationsLog), + new AsyncOrderSubscriber(), + new AsyncOrderForwarder($operationsLog), + OperationsLog::class => $operationsLog, + ], + enableAsynchronousProcessing: [ + InMemoryAsyncPublishingChannelBuilder::create('async_orders'), + SimpleMessageChannelBuilder::createQueueChannel('incoming_orders'), + ], + ); + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/DeadLetterOfFailedBatchDeliveriesTest.php b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/DeadLetterOfFailedBatchDeliveriesTest.php new file mode 100644 index 000000000..426a3bf33 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/DeadLetterOfFailedBatchDeliveriesTest.php @@ -0,0 +1,113 @@ +bootstrapWithDeadLetterChannel(); + $ordersChannel = $ecotoneLite->getMessageChannel('async_orders'); + assert($ordersChannel instanceof MessageChannelInterceptorAdapter); + $ordersChannel->getInternalMessageChannel()->failDeliveriesContaining('poison', 'nacked by broker'); + + $ordersChannel->send(MessageBuilder::withPayload( + BatchMessage::constructEmpty() + ->append('first poison order') + ->append('delivered order') + ->append('second poison order') + )->build()); + + $this->assertSame( + ['first poison order', 'second poison order'], + $this->receiveAllPayloads($ecotoneLite->getMessageChannel('dead_letters')), + ); + } + + public function test_failed_async_batch_deliveries_are_stored_as_separate_dead_letters(): void + { + $operationsLog = new OperationsLog(); + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + [OrderService::class, AsyncOrderSubscriber::class], + [new OrderService($operationsLog), new AsyncOrderSubscriber(), OperationsLog::class => $operationsLog], + ServiceConfiguration::createWithDefaults()->withExtensionObjects([ + PollableChannelConfiguration::create('async_orders', RetryTemplateBuilder::fixedBackOff(1)->maxRetryAttempts(1)->build()) + ->withErrorChannel('dead_letters'), + ]), + enableAsynchronousProcessing: [ + InMemoryAsyncPublishingChannelBuilder::create('async_orders'), + SimpleMessageChannelBuilder::createQueueChannel('dead_letters'), + ], + ); + $ordersChannel = $ecotoneLite->getMessageChannel('async_orders'); + assert($ordersChannel instanceof MessageChannelInterceptorAdapter); + $ordersChannel->getInternalMessageChannel()->failDeliveriesWith('broker not available'); + + $ecotoneLite->sendCommandWithRoutingKey('order.place', 'espresso'); + + $deadLetteredPayloads = $this->receiveAllPayloads($ecotoneLite->getMessageChannel('dead_letters')); + $this->assertCount(2, $deadLetteredPayloads); + $this->assertStringContainsString('espresso-1', $deadLetteredPayloads[0]); + $this->assertStringNotContainsString('espresso-2', $deadLetteredPayloads[0]); + $this->assertStringContainsString('espresso-2', $deadLetteredPayloads[1]); + $this->assertStringNotContainsString('espresso-1', $deadLetteredPayloads[1]); + } + + private function bootstrapWithDeadLetterChannel(): \Ecotone\Lite\Test\FlowTestSupport + { + $operationsLog = new OperationsLog(); + + return EcotoneLite::bootstrapFlowTesting( + [OrderService::class], + [new OrderService($operationsLog), OperationsLog::class => $operationsLog], + ServiceConfiguration::createWithDefaults()->withExtensionObjects([ + PollableChannelConfiguration::create('async_orders', RetryTemplateBuilder::fixedBackOff(1)->maxRetryAttempts(1)->build()) + ->withErrorChannel('dead_letters'), + ]), + enableAsynchronousProcessing: [ + InMemoryAsyncPublishingChannelBuilder::create('async_orders'), + SimpleMessageChannelBuilder::createQueueChannel('dead_letters'), + ], + ); + } + + /** + * @return array + */ + private function receiveAllPayloads(PollableChannel $deadLetterChannel): array + { + $payloads = []; + while ($deadLetter = $deadLetterChannel->receive()) { + $payloads[] = $this->unwrapPayload($deadLetter); + } + + return $payloads; + } + + private function unwrapPayload(Message $deadLetter): mixed + { + return $deadLetter->getPayload(); + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/MessagePublisherAsyncPublishBatchTest.php b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/MessagePublisherAsyncPublishBatchTest.php new file mode 100644 index 000000000..6a815d99c --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/MessagePublisherAsyncPublishBatchTest.php @@ -0,0 +1,68 @@ +bootstrapPublisher($outboundAdapter); + + $future = $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('first order') + ->append('second order', ['priority' => 5]) + ); + + $this->assertCount(1, $outboundAdapter->getSentMessages()); + $batchPayload = $outboundAdapter->getSentMessages()[0]->getPayload(); + $this->assertInstanceOf(BatchMessage::class, $batchPayload); + $this->assertSame( + [ + ['payload' => 'first order', 'headers' => []], + ['payload' => 'second order', 'headers' => ['priority' => 5]], + ], + $batchPayload->getEntries(), + ); + $this->assertSame(0, $outboundAdapter->awaitedDeliveriesCount()); + + $future->resolve(); + + $this->assertSame(1, $outboundAdapter->awaitedDeliveriesCount()); + } + + public function test_publishing_empty_batch_resolves_without_sending_anything(): void + { + $outboundAdapter = new InMemoryAsyncOutboundAdapter(); + $publisher = $this->bootstrapPublisher($outboundAdapter); + + $future = $publisher->asyncPublish(BatchMessage::constructEmpty()); + $future->resolve(); + + $this->assertCount(0, $outboundAdapter->getSentMessages()); + } + + private function bootstrapPublisher(InMemoryAsyncOutboundAdapter $outboundAdapter): MessagePublisher + { + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + [InMemoryAsyncPublisherModule::class, InMemoryAsyncOutboundAdapter::class], + [$outboundAdapter], + ); + + return $ecotoneLite->getGateway(InMemoryAsyncPublisherModule::PUBLISHER_REFERENCE); + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/MessagePublisherAsyncPublishTest.php b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/MessagePublisherAsyncPublishTest.php new file mode 100644 index 000000000..2272b17d5 --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/MessagePublisherAsyncPublishTest.php @@ -0,0 +1,112 @@ +bootstrapPublisher($outboundAdapter); + + $future = $publisher->asyncPublish('order was placed'); + + $this->assertSame(['order was placed'], $outboundAdapter->getSentPayloads()); + $this->assertSame(0, $outboundAdapter->awaitedDeliveriesCount()); + + $future->resolve(); + + $this->assertSame(1, $outboundAdapter->awaitedDeliveriesCount()); + } + + public function test_resolving_future_twice_awaits_delivery_only_once(): void + { + $outboundAdapter = new InMemoryAsyncOutboundAdapter(); + $publisher = $this->bootstrapPublisher($outboundAdapter); + + $future = $publisher->asyncPublish('order was placed'); + $future->resolve(); + $future->resolve(); + + $this->assertSame(1, $outboundAdapter->totalAwaitCalls()); + } + + public function test_resolving_future_throws_when_delivery_failed(): void + { + $outboundAdapter = new InMemoryAsyncOutboundAdapter(); + $outboundAdapter->failDeliveriesWith('broker rejected message'); + $publisher = $this->bootstrapPublisher($outboundAdapter); + + $future = $publisher->asyncPublish('order was placed'); + + $this->expectException(PublishingFailedException::class); + + $future->resolve(); + } + + public function test_async_publish_on_synchronous_publisher_throws_clear_exception(): void + { + $outboundAdapter = new InMemoryAsyncOutboundAdapter(); + $outboundAdapter->actAsSynchronousPublisher(); + $publisher = $this->bootstrapPublisher($outboundAdapter); + + $this->expectException(PublishingFailedException::class); + $this->expectExceptionMessageMatches('/not configured for asynchronous publishing/'); + + $publisher->asyncPublish('order was placed'); + } + + public function test_metadata_passed_to_async_publish_lands_on_published_message(): void + { + $outboundAdapter = new InMemoryAsyncOutboundAdapter(); + $publisher = $this->bootstrapPublisher($outboundAdapter); + + $publisher->asyncPublish('order was placed', metadata: ['orderId' => '123']); + + $this->assertSame('123', $outboundAdapter->getSentMessages()[0]->getHeaders()->get('orderId')); + } + + public function test_flushing_unawaited_deliveries_awaits_only_unresolved_futures(): void + { + $outboundAdapter = new InMemoryAsyncOutboundAdapter(); + $ecotoneLite = $this->bootstrapEcotone($outboundAdapter); + $publisher = $ecotoneLite->getGateway(InMemoryAsyncPublisherModule::PUBLISHER_REFERENCE); + + $resolvedFuture = $publisher->asyncPublish('first order'); + $resolvedFuture->resolve(); + $publisher->asyncPublish('second order'); + + $ecotoneLite->getServiceFromContainer(AsyncPublishingRegistry::class)->flushUnawaitedDeliveries(); + + $this->assertSame(2, $outboundAdapter->awaitedDeliveriesCount()); + $this->assertSame(2, $outboundAdapter->totalAwaitCalls()); + } + + private function bootstrapPublisher(InMemoryAsyncOutboundAdapter $outboundAdapter): MessagePublisher + { + return $this->bootstrapEcotone($outboundAdapter)->getGateway(InMemoryAsyncPublisherModule::PUBLISHER_REFERENCE); + } + + private function bootstrapEcotone(InMemoryAsyncOutboundAdapter $outboundAdapter): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + [InMemoryAsyncPublisherModule::class, InMemoryAsyncOutboundAdapter::class], + [$outboundAdapter], + ); + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/SendRetryOfFailedBatchDeliveriesTest.php b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/SendRetryOfFailedBatchDeliveriesTest.php new file mode 100644 index 000000000..79040a1eb --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Channel/AsyncPublishing/SendRetryOfFailedBatchDeliveriesTest.php @@ -0,0 +1,104 @@ +createRecordingChannel(); + $interceptor = $this->createInterceptor(); + + $batchMessage = MessageBuilder::withPayload( + BatchMessage::constructEmpty() + ->append('first delivered order') + ->append('poison order') + ->append('second delivered order') + )->build(); + + $interceptor->afterSendCompletion( + $batchMessage, + $channel, + PublishingFailedException::withFailedDeliveries([ + new FailedDelivery(MessageBuilder::withPayload('poison order')->build(), 'nacked by broker', 'orders'), + ]), + ); + + $this->assertSame([['poison order']], $channel->sentBatchPayloads); + } + + public function test_retry_of_single_message_failure_redelivers_original_message(): void + { + $channel = $this->createRecordingChannel(); + $interceptor = $this->createInterceptor(); + + $singleMessage = MessageBuilder::withPayload('failed order')->build(); + + $interceptor->afterSendCompletion( + $singleMessage, + $channel, + PublishingFailedException::withFailedDeliveries([ + new FailedDelivery($singleMessage, 'nacked by broker', 'orders'), + ]), + ); + + $this->assertSame([['failed order']], $channel->sentBatchPayloads); + } + + private function createInterceptor(): SendRetryChannelInterceptor + { + return new SendRetryChannelInterceptor( + 'orders', + RetryTemplateBuilder::fixedBackOff(1)->maxRetryAttempts(1)->build(), + null, + new ErrorChannelService( + new LoggingService(), + $this->createStub(OutboundMessageConverter::class), + $this->createStub(ConversionService::class), + new MessageHeadersPropagatorInterceptor(), + ), + $this->createStub(ConfiguredMessagingSystem::class), + new NullLogger(), + StubUTCClock::createWithCurrentTime('2025-01-01 00:00:00'), + ); + } + + private function createRecordingChannel(): MessageChannel + { + return new class () implements MessageChannel { + /** @var array> */ + public array $sentBatchPayloads = []; + + public function send(Message $message): void + { + $payload = $message->getPayload(); + $this->sentBatchPayloads[] = $payload instanceof BatchMessage + ? array_map(fn (array $entry): mixed => $entry['payload'], $payload->getEntries()) + : [$payload]; + } + }; + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Channel/BatchMessageSendingTest.php b/packages/Ecotone/tests/Messaging/Unit/Channel/BatchMessageSendingTest.php new file mode 100644 index 000000000..b1849fd5b --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/Channel/BatchMessageSendingTest.php @@ -0,0 +1,114 @@ +append('first order') + ->append('second order', ['priority' => 5]); + + $ecotoneLite->getMessageChannel('orders')->send( + MessageBuilder::withPayload($batch)->build() + ); + + $firstMessage = $ecotoneLite->receiveMessageFrom('orders'); + $secondMessage = $ecotoneLite->receiveMessageFrom('orders'); + + $this->assertSame('first order', $firstMessage->getPayload()); + $this->assertSame('second order', $secondMessage->getPayload()); + $this->assertSame(5, $secondMessage->getHeaders()->get('priority')); + $this->assertNull($ecotoneLite->receiveMessageFrom('orders')); + } + + public function test_batch_message_sent_to_handler_output_channel_is_split_into_individual_messages(): void + { + $orderProcessor = $this->createOrderProcessor(); + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + [$orderProcessor::class], + [$orderProcessor], + enableAsynchronousProcessing: [ + SimpleMessageChannelBuilder::createQueueChannel('orders'), + ], + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + $ecotoneLite->sendCommandWithRoutingKey('order.placeAll', ['espresso', 'latte']); + + $this->assertSame('espresso', $ecotoneLite->receiveMessageFrom('orders')->getPayload()); + $this->assertSame('latte', $ecotoneLite->receiveMessageFrom('orders')->getPayload()); + $this->assertNull($ecotoneLite->receiveMessageFrom('orders')); + } + + public function test_batch_message_sent_to_handler_output_channel_requires_enterprise_licence(): void + { + $orderProcessor = $this->createOrderProcessor(); + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + [$orderProcessor::class], + [$orderProcessor], + enableAsynchronousProcessing: [ + SimpleMessageChannelBuilder::createQueueChannel('orders'), + ], + ); + + $this->expectException(LicensingException::class); + + $ecotoneLite->sendCommandWithRoutingKey('order.placeAll', ['espresso', 'latte']); + } + + private function createOrderProcessor(): object + { + return new class () { + #[CommandHandler('order.placeAll', outputChannelName: 'orders')] + public function placeOrders(array $orders): BatchMessage + { + $batch = BatchMessage::constructEmpty(); + foreach ($orders as $order) { + $batch = $batch->append($order); + } + + return $batch; + } + }; + } + + public function test_empty_batch_message_delivers_nothing(): void + { + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + enableAsynchronousProcessing: [ + SimpleMessageChannelBuilder::createQueueChannel('orders'), + ], + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + $ecotoneLite->getMessageChannel('orders')->send( + MessageBuilder::withPayload(BatchMessage::constructEmpty())->build() + ); + + $this->assertNull($ecotoneLite->receiveMessageFrom('orders')); + } +} diff --git a/packages/Ecotone/tests/Messaging/Unit/Channel/TestQueueChannel.php b/packages/Ecotone/tests/Messaging/Unit/Channel/TestQueueChannel.php index 1ab3c7786..05e70f1b7 100644 --- a/packages/Ecotone/tests/Messaging/Unit/Channel/TestQueueChannel.php +++ b/packages/Ecotone/tests/Messaging/Unit/Channel/TestQueueChannel.php @@ -24,7 +24,7 @@ public function __construct(string $name = 'unknown', bool $throwException = fal $this->messageToReturn = $messageToReturn; } - public static function create(string $name = 'unknown'): self + public static function create(string $name = 'unknown', bool $batchMessagesSupport = false): self { return new self($name); } diff --git a/packages/Ecotone/tests/Messaging/Unit/PrecedenceTest.php b/packages/Ecotone/tests/Messaging/Unit/PrecedenceTest.php new file mode 100644 index 000000000..87ebd5e9a --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Unit/PrecedenceTest.php @@ -0,0 +1,23 @@ +assertGreaterThan(Precedence::DATABASE_TRANSACTION_PRECEDENCE, Precedence::ASYNC_PUBLISHING_AWAIT_PRECEDENCE); + $this->assertGreaterThan(Precedence::ASYNC_PUBLISHING_AWAIT_PRECEDENCE, Precedence::COLLECTOR_SENDER_PRECEDENCE); + $this->assertGreaterThan(Precedence::COLLECTOR_SENDER_PRECEDENCE, Precedence::DATABASE_OBJECT_MANAGER_PRECEDENCE); + $this->assertGreaterThan(Precedence::DATABASE_OBJECT_MANAGER_PRECEDENCE, Precedence::LAZY_EVENT_PUBLICATION_PRECEDENCE); + } +} diff --git a/packages/Enqueue/src/EnqueueMessageChannel.php b/packages/Enqueue/src/EnqueueMessageChannel.php index fc9cbf415..990fbab9a 100644 --- a/packages/Enqueue/src/EnqueueMessageChannel.php +++ b/packages/Enqueue/src/EnqueueMessageChannel.php @@ -4,6 +4,7 @@ namespace Ecotone\Enqueue; +use Ecotone\Messaging\Channel\BatchSupportingMessageChannel; use Ecotone\Messaging\Endpoint\PollingMetadata; use Ecotone\Messaging\Message; use Ecotone\Messaging\MessageHandler; @@ -12,12 +13,17 @@ /** * licence Apache-2.0 */ -final class EnqueueMessageChannel implements PollableChannel +final class EnqueueMessageChannel implements PollableChannel, BatchSupportingMessageChannel { - public function __construct(private EnqueueInboundChannelAdapter $inboundChannelAdapter, private MessageHandler $outboundChannelAdapter) + public function __construct(private EnqueueInboundChannelAdapter $inboundChannelAdapter, private MessageHandler $outboundChannelAdapter, private bool $supportsBatchMessages = false) { } + public function supportsBatchMessages(): bool + { + return $this->supportsBatchMessages; + } + public function send(Message $message): void { $this->outboundChannelAdapter->handle($message); diff --git a/packages/Enqueue/src/EnqueueMessageChannelBuilder.php b/packages/Enqueue/src/EnqueueMessageChannelBuilder.php index 6996677ef..1b962fe81 100644 --- a/packages/Enqueue/src/EnqueueMessageChannelBuilder.php +++ b/packages/Enqueue/src/EnqueueMessageChannelBuilder.php @@ -121,6 +121,12 @@ public function compile(MessagingContainerBuilder $builder): Definition return new Definition(EnqueueMessageChannel::class, [ $this->inboundChannelAdapter->compile($builder), $this->outboundChannelAdapter->compile($builder), + $this->supportsBatchMessages(), ]); } + + protected function supportsBatchMessages(): bool + { + return false; + } } diff --git a/packages/Enqueue/src/EnqueueOutboundChannelAdapter.php b/packages/Enqueue/src/EnqueueOutboundChannelAdapter.php index 18cb2eb4f..289eae2d2 100644 --- a/packages/Enqueue/src/EnqueueOutboundChannelAdapter.php +++ b/packages/Enqueue/src/EnqueueOutboundChannelAdapter.php @@ -4,11 +4,18 @@ namespace Ecotone\Enqueue; +use Ecotone\Messaging\BatchMessage; +use Ecotone\Messaging\Channel\AsyncPublishing\AsyncPublishingRegistry; +use Ecotone\Messaging\Channel\AsyncPublishing\ConfirmedDelivery; +use Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessage; use Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessageConverter; +use Ecotone\Messaging\Config\ConfigurationException; use Ecotone\Messaging\Conversion\ConversionService; use Ecotone\Messaging\Message; use Ecotone\Messaging\MessageHandler; use Ecotone\Messaging\MessageHeaders; +use Ecotone\Messaging\Support\MessageBuilder; +use Interop\Queue\Context; use Interop\Queue\Destination; use function spl_object_id; @@ -25,13 +32,33 @@ public function __construct( protected Destination $destination, protected bool $autoDeclare, protected OutboundMessageConverter $outboundMessageConverter, - private ConversionService $conversionService + private ConversionService $conversionService, + private AsyncPublishingRegistry $asyncPublishingRegistry, + private bool $asyncPublishing = false, + private string $asyncPublishingChannelName = '', ) { } abstract public function initialize(): void; public function handle(Message $message): void + { + if ($message->getPayload() instanceof BatchMessage && ! $this->asyncPublishing) { + throw ConfigurationException::create(sprintf('Sending BatchMessage over `%s` requires async publishing to be enabled. Enable it with withAsyncPublishing(), available as part of Ecotone Enterprise.', $this->asyncPublishingChannelName)); + } + + $context = $this->createOutboundContext(); + + if ($message->getPayload() instanceof BatchMessage) { + $this->handleBatch($message->getPayload(), $context); + } else { + $this->sendSingleMessage($message, $context); + } + + $this->registerSynchronouslyConfirmedDelivery(); + } + + protected function createOutboundContext(): Context { $context = $this->connectionFactory->createContext(); if ($this->autoDeclare) { @@ -43,7 +70,40 @@ public function handle(Message $message): void } } - $outboundMessage = $this->outboundMessageConverter->prepare($message, $this->conversionService); + return $context; + } + + protected function registerSynchronouslyConfirmedDelivery(): void + { + if (! $this->asyncPublishing || ! $this->asyncPublishingRegistry->isScopeActive()) { + return; + } + + $this->asyncPublishingRegistry->register($this->asyncPublishingChannelName, new ConfirmedDelivery()); + } + + protected function handleBatch(BatchMessage $batchMessage, Context $context): void + { + foreach ($batchMessage->getEntries() as $entry) { + $this->sendSingleMessage($this->convertBatchEntryToMessage($entry), $context); + } + } + + protected function convertBatchEntryToMessage(array $entry): Message + { + return MessageBuilder::withPayload($entry['payload']) + ->setMultipleHeaders($entry['headers']) + ->build(); + } + + protected function prepareOutboundMessage(Message $message): OutboundMessage + { + return $this->outboundMessageConverter->prepare($message, $this->conversionService); + } + + protected function sendSingleMessage(Message $message, Context $context): void + { + $outboundMessage = $this->prepareOutboundMessage($message); $headers = $outboundMessage->getHeaders(); $headers[MessageHeaders::CONTENT_TYPE] = $outboundMessage->getContentType(); diff --git a/packages/Kafka/src/Channel/KafkaMessageChannel.php b/packages/Kafka/src/Channel/KafkaMessageChannel.php index f94213407..99a5bca78 100644 --- a/packages/Kafka/src/Channel/KafkaMessageChannel.php +++ b/packages/Kafka/src/Channel/KafkaMessageChannel.php @@ -7,6 +7,7 @@ use Ecotone\Kafka\Configuration\KafkaConsumerConfiguration; use Ecotone\Kafka\Inbound\KafkaInboundChannelAdapter; use Ecotone\Kafka\Outbound\KafkaOutboundChannelAdapter; +use Ecotone\Messaging\Channel\BatchSupportingMessageChannel; use Ecotone\Messaging\Endpoint\PollingMetadata; use Ecotone\Messaging\Message; use Ecotone\Messaging\PollableChannel; @@ -14,7 +15,7 @@ /** * licence Enterprise */ -final class KafkaMessageChannel implements PollableChannel +final class KafkaMessageChannel implements PollableChannel, BatchSupportingMessageChannel { public function __construct( private KafkaInboundChannelAdapter $inboundChannelAdapter, @@ -23,6 +24,11 @@ public function __construct( } + public function supportsBatchMessages(): bool + { + return $this->outboundChannelAdapter->isAsyncPublishingEnabled(); + } + public function send(Message $message): void { $this->outboundChannelAdapter->handle($message); diff --git a/packages/Kafka/src/Channel/KafkaMessageChannelBuilder.php b/packages/Kafka/src/Channel/KafkaMessageChannelBuilder.php index 2e74a3a8d..90de43b80 100644 --- a/packages/Kafka/src/Channel/KafkaMessageChannelBuilder.php +++ b/packages/Kafka/src/Channel/KafkaMessageChannelBuilder.php @@ -15,6 +15,7 @@ use Ecotone\Messaging\Endpoint\FinalFailureStrategy; use Ecotone\Messaging\MessageConverter\DefaultHeaderMapper; use Ecotone\Messaging\MessageConverter\HeaderMapper; +use Ecotone\Messaging\Support\Assert; /** * licence Enterprise @@ -25,6 +26,8 @@ final class KafkaMessageChannelBuilder implements MessageChannelWithSerializatio private KafkaOutboundChannelAdapterBuilder $outboundChannelAdapterBuilder; private string $headerMapper; private ?MediaType $conversionMediaType = null; + private bool $asyncPublishing = false; + private ?int $asyncPublishingTimeout = null; private function __construct( private string $channelName, @@ -116,6 +119,27 @@ public function withDefaultConversionMediaType(string $mediaType): self return $this; } + public function withAsyncPublishing(bool $enabled = true, ?int $timeoutInMilliseconds = null): self + { + Assert::isTrue($timeoutInMilliseconds === null || $timeoutInMilliseconds > 0, 'Async publishing timeout must be a positive amount of milliseconds.'); + $this->asyncPublishing = $enabled; + if ($timeoutInMilliseconds !== null) { + $this->asyncPublishingTimeout = $timeoutInMilliseconds; + } + + return $this; + } + + public function isAsyncPublishingEnabled(): bool + { + return $this->asyncPublishing; + } + + public function getAsyncPublishingTimeout(): ?int + { + return $this->asyncPublishingTimeout; + } + /** * Set the commit interval in messages. Offsets will be committed every X messages. * diff --git a/packages/Kafka/src/Configuration/KafkaAdmin.php b/packages/Kafka/src/Configuration/KafkaAdmin.php index d2a8537ae..427379210 100644 --- a/packages/Kafka/src/Configuration/KafkaAdmin.php +++ b/packages/Kafka/src/Configuration/KafkaAdmin.php @@ -5,6 +5,7 @@ namespace Ecotone\Kafka\Configuration; use Ecotone\Kafka\Attribute\KafkaConsumer as KafkaConsumerAttribute; +use Ecotone\Kafka\Outbound\KafkaDeliveryTracker; use Ecotone\Messaging\Config\ConfigurationException; use Ecotone\Messaging\Handler\Logger\LoggingGateway; use Exception; @@ -28,6 +29,11 @@ final class KafkaAdmin */ private array $initializedConsumers = []; + /** + * @var KafkaDeliveryTracker[] + */ + private array $deliveryTrackers = []; + /** * @param KafkaConsumerAttribute[] $consumerConfigurations * @param KafkaConsumerConfiguration[] $rdKafkaConsumerConfigurations @@ -121,6 +127,12 @@ public function getProducer(string $referenceName): Producer $conf = $configuration->getAsKafkaConfig(); $conf->set('metadata.broker.list', implode(',', $this->kafkaBrokerConfigurations[$configuration->getBrokerConfigurationReference()]->getBootstrapServers())); $this->setLoggerCallbacks($conf, $referenceName); + $deliveryTracker = $this->getDeliveryTracker($referenceName); + $conf->setDrMsgCb( + function ($producer, $kafkaMessage) use ($deliveryTracker): void { + $deliveryTracker->recordDeliveryReport($kafkaMessage); + } + ); $producer = new Producer($conf); $producer->addBrokers(implode(',', $this->kafkaBrokerConfigurations[$configuration->getBrokerConfigurationReference()]->getBootstrapServers())); @@ -130,6 +142,11 @@ public function getProducer(string $referenceName): Producer return $this->initializedProducers[$referenceName]; } + public function getDeliveryTracker(string $referenceName): KafkaDeliveryTracker + { + return $this->deliveryTrackers[$referenceName] ??= new KafkaDeliveryTracker(); + } + public function getTopicForProducer(string $referenceName): ProducerTopic { $producer = $this->getProducer($referenceName); diff --git a/packages/Kafka/src/Configuration/KafkaModule.php b/packages/Kafka/src/Configuration/KafkaModule.php index 5bfd31f5d..f4bd3a87d 100644 --- a/packages/Kafka/src/Configuration/KafkaModule.php +++ b/packages/Kafka/src/Configuration/KafkaModule.php @@ -11,6 +11,7 @@ use Ecotone\Kafka\Inbound\KafkaInboundChannelAdapterBuilder; use Ecotone\Kafka\Outbound\KafkaOutboundChannelAdapterBuilder; use Ecotone\Messaging\Attribute\ModuleAnnotation; +use Ecotone\Messaging\Channel\AsyncPublishing\Config\AsyncPublishGatewayRegistration; use Ecotone\Messaging\Config\Annotation\AnnotationModule; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ExtensionObjectResolver; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\NoExternalConfigurationModule; @@ -111,7 +112,8 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO $extensionObject->topicName, MessagePublisher::class . '::' . $extensionObject->getMessageChannelName(), ) - ->withHeaderMapper($extensionObject->getHeaderMapper()); + ->withHeaderMapper($extensionObject->getHeaderMapper()) + ->withAsyncPublishing($extensionObject->isAsyncPublishingEnabled(), $extensionObject->getAsyncPublishingTimeout()); } } @@ -224,6 +226,8 @@ private function registerMessagePublisher(Configuration $messagingConfiguration, ->withHeaderMapper($extensionObject->getHeaderMapper()) ->withDefaultConversionMediaType($mediaType) ); + + AsyncPublishGatewayRegistration::registerFor($messagingConfiguration, $extensionObject->getReferenceName(), $extensionObject->isAsyncPublishingEnabled()); } private function getPublisherEndpointId(string $referenceName): string diff --git a/packages/Kafka/src/Configuration/KafkaPublisherConfiguration.php b/packages/Kafka/src/Configuration/KafkaPublisherConfiguration.php index f0edc688a..c14098d6e 100644 --- a/packages/Kafka/src/Configuration/KafkaPublisherConfiguration.php +++ b/packages/Kafka/src/Configuration/KafkaPublisherConfiguration.php @@ -9,6 +9,7 @@ use Ecotone\Messaging\MessageConverter\DefaultHeaderMapper; use Ecotone\Messaging\MessageConverter\HeaderMapper; use Ecotone\Messaging\MessagePublisher; +use Ecotone\Messaging\Support\Assert; use RdKafka\Conf; /** @@ -20,6 +21,8 @@ final class KafkaPublisherConfiguration implements DefinedObject { public const ACKNOWLEDGE_TIMEOUT = '8000'; + public const DEFAULT_ASYNC_PUBLISHING_TIMEOUT = 12000; + /** * @param array $configuration */ @@ -30,6 +33,8 @@ public function __construct( private string $brokerConfigurationReference, private HeaderMapper $headerMapper, private ?string $outputDefaultConversionMediaType = null, + private bool $asyncPublishing = false, + private int $asyncPublishingTimeout = self::DEFAULT_ASYNC_PUBLISHING_TIMEOUT, ) { } @@ -58,6 +63,8 @@ public static function createWithDefaults(string $topicName = '', string $refere 'retries' => '5', // Backoff time between retries in milliseconds 'retry.backoff.ms' => '300', + // Disables Nagle algorithm (TCP_NODELAY) so small produce requests are not delayed. Default in librdkafka only since v2.1 + 'socket.nagle.disable' => 'true', ], $brokerConfigurationReference, DefaultHeaderMapper::createAllHeadersMapping(), @@ -99,6 +106,27 @@ public function getHeaderMapper(): HeaderMapper return $this->headerMapper; } + public function withAsyncPublishing(bool $asyncPublishing = true, ?int $timeoutInMilliseconds = null): self + { + Assert::isTrue($timeoutInMilliseconds === null || $timeoutInMilliseconds > 0, 'Async publishing timeout must be a positive amount of milliseconds.'); + $this->asyncPublishing = $asyncPublishing; + if ($timeoutInMilliseconds !== null) { + $this->asyncPublishingTimeout = $timeoutInMilliseconds; + } + + return $this; + } + + public function isAsyncPublishingEnabled(): bool + { + return $this->asyncPublishing; + } + + public function getAsyncPublishingTimeout(): int + { + return $this->asyncPublishingTimeout; + } + public function getOutputDefaultConversionMediaType(): ?string { return $this->outputDefaultConversionMediaType; @@ -111,8 +139,13 @@ public function getBrokerConfigurationReference(): string public function getAsKafkaConfig(): Conf { + $configuration = $this->configuration; + if ($this->asyncPublishing && ! isset($configuration['linger.ms']) && ! isset($configuration['queue.buffering.max.ms'])) { + $configuration['linger.ms'] = '20'; + } + $conf = new Conf(); - foreach ($this->configuration as $key => $value) { + foreach ($configuration as $key => $value) { $conf->set($key, $value); } @@ -138,6 +171,8 @@ public function getDefinition(): Definition $this->brokerConfigurationReference, $this->headerMapper->getDefinition(), $this->outputDefaultConversionMediaType, + $this->asyncPublishing, + $this->asyncPublishingTimeout, ]); } } diff --git a/packages/Kafka/src/Outbound/KafkaDeliveryTracker.php b/packages/Kafka/src/Outbound/KafkaDeliveryTracker.php new file mode 100644 index 000000000..a6fc25814 --- /dev/null +++ b/packages/Kafka/src/Outbound/KafkaDeliveryTracker.php @@ -0,0 +1,72 @@ + */ + private array $inFlightMessages = []; + + /** @var array */ + private array $deliveryFailures = []; + + private int $nextDeliveryId = 0; + + public function trackInFlight(Message $message): string + { + $deliveryId = (string) $this->nextDeliveryId++; + $this->inFlightMessages[$deliveryId] = $message; + + return $deliveryId; + } + + public function recordDeliveryReport(KafkaMessage $kafkaMessage): void + { + $deliveryId = $kafkaMessage->opaque; + if (! is_string($deliveryId) || ! array_key_exists($deliveryId, $this->inFlightMessages)) { + return; + } + + if ($kafkaMessage->err !== RD_KAFKA_RESP_ERR_NO_ERROR) { + $this->deliveryFailures[$deliveryId] = rd_kafka_err2str($kafkaMessage->err); + + return; + } + + unset($this->inFlightMessages[$deliveryId]); + } + + /** + * @param string[] $deliveryIds + */ + public function discard(string $deliveryId): void + { + unset($this->inFlightMessages[$deliveryId], $this->deliveryFailures[$deliveryId]); + } + + public function collectResult(array $deliveryIds, string $channelName): DeliveryResult + { + $failedDeliveries = []; + foreach ($deliveryIds as $deliveryId) { + if (array_key_exists($deliveryId, $this->deliveryFailures)) { + $failedDeliveries[] = new FailedDelivery($this->inFlightMessages[$deliveryId], $this->deliveryFailures[$deliveryId], $channelName); + } elseif (array_key_exists($deliveryId, $this->inFlightMessages)) { + $failedDeliveries[] = new FailedDelivery($this->inFlightMessages[$deliveryId], 'Timed out awaiting delivery confirmation from Kafka broker', $channelName); + } + + unset($this->inFlightMessages[$deliveryId], $this->deliveryFailures[$deliveryId]); + } + + return $failedDeliveries === [] ? DeliveryResult::successful() : DeliveryResult::withFailedDeliveries($failedDeliveries); + } +} diff --git a/packages/Kafka/src/Outbound/KafkaOutboundChannelAdapter.php b/packages/Kafka/src/Outbound/KafkaOutboundChannelAdapter.php index 2630a3ed8..1dedee701 100644 --- a/packages/Kafka/src/Outbound/KafkaOutboundChannelAdapter.php +++ b/packages/Kafka/src/Outbound/KafkaOutboundChannelAdapter.php @@ -7,24 +7,36 @@ use Ecotone\Kafka\Api\KafkaHeader; use Ecotone\Kafka\Configuration\KafkaAdmin; use Ecotone\Kafka\Configuration\KafkaPublisherConfiguration; +use Ecotone\Messaging\BatchMessage; +use Ecotone\Messaging\Channel\AsyncPublishing\AsyncPublishingRegistry; +use Ecotone\Messaging\Channel\AsyncPublishing\FailedDelivery; +use Ecotone\Messaging\Channel\AsyncPublishing\PublishingFailedException; use Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessageConverter; +use Ecotone\Messaging\Config\ConfigurationException; use Ecotone\Messaging\Conversion\ConversionService; use Ecotone\Messaging\Message; use Ecotone\Messaging\MessageHandler; use Ecotone\Messaging\MessageHeaders; +use Ecotone\Messaging\Support\MessageBuilder; use Ecotone\Modelling\AggregateFlow\AggregateIdMetadata; use Ecotone\Modelling\AggregateMessage; +use RdKafka\Producer; +use RdKafka\ProducerTopic; +use Throwable; /** * licence Enterprise */ final class KafkaOutboundChannelAdapter implements MessageHandler { + private const POLL_EVERY_PRODUCED_MESSAGES = 100; + public function __construct( private string $referenceName, private KafkaAdmin $kafkaAdmin, private ConversionService $conversionService, - private OutboundMessageConverter $outboundMessageConverter + private OutboundMessageConverter $outboundMessageConverter, + private AsyncPublishingRegistry $asyncPublishingRegistry, ) { } @@ -33,8 +45,71 @@ public function __construct( */ public function handle(Message $message): void { + if ($message->getPayload() instanceof BatchMessage && ! $this->isAsyncPublishingEnabled()) { + throw ConfigurationException::create(sprintf('Sending BatchMessage over `%s` requires async publishing to be enabled. Enable it with withAsyncPublishing(), available as part of Ecotone Enterprise.', $this->referenceName)); + } + $producer = $this->kafkaAdmin->getProducer($this->referenceName); $topic = $this->kafkaAdmin->getTopicForProducer($this->referenceName); + + if ($message->getPayload() instanceof BatchMessage) { + $this->handleBatch($message->getPayload(), $producer, $topic); + + return; + } + + $deliveryId = $this->produce($message, $topic, trackDelivery: true); + $producer->poll(0); + + if ($this->canPublishAsynchronously()) { + $this->registerPendingDelivery($producer, [$deliveryId]); + + return; + } + + $this->flushSynchronously($producer, [$deliveryId]); + } + + public function isAsyncPublishingEnabled(): bool + { + return $this->kafkaAdmin->getConfigurationForPublisher($this->referenceName)->isAsyncPublishingEnabled(); + } + + private function handleBatch(BatchMessage $batchMessage, Producer $producer, ProducerTopic $topic): void + { + $deliveryIds = []; + try { + $producedMessages = 0; + foreach ($batchMessage->getEntries() as $entry) { + $entryMessage = MessageBuilder::withPayload($entry['payload']) + ->setMultipleHeaders($entry['headers']) + ->build(); + + $deliveryIds[] = $this->produce($entryMessage, $topic, trackDelivery: true); + if (++$producedMessages % self::POLL_EVERY_PRODUCED_MESSAGES === 0) { + $producer->poll(0); + } + } + $producer->poll(0); + } catch (Throwable $exception) { + if ($deliveryIds !== []) { + $this->registerPendingDelivery($producer, $deliveryIds); + } + + throw $exception; + } + + if ($this->canPublishAsynchronously()) { + $this->registerPendingDelivery($producer, $deliveryIds); + + return; + } + + $this->flushSynchronously($producer, $deliveryIds); + } + + private function produce(Message $message, ProducerTopic $topic, bool $trackDelivery): ?string + { $outboundMessage = $this->outboundMessageConverter->prepare($message, $this->conversionService); if ($message->getHeaders()->containsKey(KafkaHeader::KAFKA_TARGET_PARTITION_KEY_HEADER_NAME)) { @@ -47,9 +122,41 @@ public function handle(Message $message): void $partitionKey = $message->getHeaders()->getMessageId(); } - $headers = $outboundMessage->getHeaders(); + $headers = array_filter($outboundMessage->getHeaders(), fn (mixed $headerValue) => $headerValue !== null); unset($headers[KafkaHeader::KAFKA_TARGET_PARTITION_KEY_HEADER_NAME]); + $deliveryId = $trackDelivery + ? $this->kafkaAdmin->getDeliveryTracker($this->referenceName)->trackInFlight($message) + : null; + + try { + $retryDeadline = microtime(true) + ($this->kafkaAdmin->getConfigurationForPublisher($this->referenceName)->getAsyncPublishingTimeout() / 1000); + while (true) { + try { + $this->produceTracked($topic, $outboundMessage, $partitionKey, $headers, $deliveryId); + + break; + } catch (\RdKafka\Exception $exception) { + if ($exception->getCode() !== RD_KAFKA_RESP_ERR__QUEUE_FULL || microtime(true) >= $retryDeadline) { + throw $exception; + } + + $this->kafkaAdmin->getProducer($this->referenceName)->poll(100); + } + } + } catch (Throwable $exception) { + if ($deliveryId !== null) { + $this->kafkaAdmin->getDeliveryTracker($this->referenceName)->discard($deliveryId); + } + + throw $exception; + } + + return $deliveryId; + } + + private function produceTracked(ProducerTopic $topic, \Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessage $outboundMessage, mixed $partitionKey, array $headers, ?string $deliveryId): void + { $topic->producev( RD_KAFKA_PARTITION_UA, 0, @@ -60,14 +167,63 @@ public function handle(Message $message): void [ KafkaHeader::KAFKA_SOURCE_PARTITION_KEY_HEADER_NAME => $partitionKey, ] - ) + ), + null, + $deliveryId, + ); + } + + private function canPublishAsynchronously(): bool + { + return $this->isAsyncPublishingEnabled() && $this->asyncPublishingRegistry->isScopeActive(); + } + + /** + * @param string[] $deliveryIds + */ + private function registerPendingDelivery(Producer $producer, array $deliveryIds): void + { + $this->asyncPublishingRegistry->register( + $this->referenceName, + new KafkaPendingDelivery( + $producer, + $this->kafkaAdmin->getDeliveryTracker($this->referenceName), + $deliveryIds, + $this->kafkaAdmin->getConfigurationForPublisher($this->referenceName)->getAsyncPublishingTimeout(), + $this->referenceName, + ), ); + } + /** + * @param string[] $deliveryIds + */ + private function flushSynchronously(Producer $producer, array $deliveryIds = []): void + { /** * Producer won't produce the message to the broker immediately it will wait until the producer queue (queue.buffering.max.messages)gets full or size of the queue(queue.buffering.max.kbytes). * calling flush immediately after produce will publish all messages to the broker irrespective of these two config values. */ $result = $producer->flush((int)(KafkaPublisherConfiguration::ACKNOWLEDGE_TIMEOUT * 1.5)); + + if ($deliveryIds !== []) { + $deliveryResult = $this->kafkaAdmin->getDeliveryTracker($this->referenceName)->collectResult($deliveryIds, $this->referenceName); + if (! $deliveryResult->isSuccessful()) { + if ($this->isAsyncPublishingEnabled()) { + throw PublishingFailedException::withFailedDeliveries($deliveryResult->getFailedDeliveries()); + } + + throw MessagePublishingException::create(sprintf( + 'Failed to deliver %d message(s) to Kafka: %s', + count($deliveryResult->getFailedDeliveries()), + implode('; ', array_unique(array_map( + fn (FailedDelivery $failedDelivery): string => $failedDelivery->getFailureReason(), + $deliveryResult->getFailedDeliveries(), + ))), + )); + } + } + if ($result !== 0) { throw MessagePublishingException::create('Failed to send message to Kafka'); } diff --git a/packages/Kafka/src/Outbound/KafkaOutboundChannelAdapterBuilder.php b/packages/Kafka/src/Outbound/KafkaOutboundChannelAdapterBuilder.php index 3e1a6dd62..1c0497bae 100644 --- a/packages/Kafka/src/Outbound/KafkaOutboundChannelAdapterBuilder.php +++ b/packages/Kafka/src/Outbound/KafkaOutboundChannelAdapterBuilder.php @@ -5,6 +5,7 @@ namespace Ecotone\Kafka\Outbound; use Ecotone\Kafka\Configuration\KafkaAdmin; +use Ecotone\Messaging\Channel\AsyncPublishing\AsyncPublishingRegistry; use Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessageConverter; use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\Container\MessagingContainerBuilder; @@ -99,6 +100,7 @@ public function compile(MessagingContainerBuilder $builder): Definition new Reference(KafkaAdmin::class), new Reference(ConversionService::REFERENCE_NAME), $outboundMessageConverter, + new Reference(AsyncPublishingRegistry::class), ]); } diff --git a/packages/Kafka/src/Outbound/KafkaPendingDelivery.php b/packages/Kafka/src/Outbound/KafkaPendingDelivery.php new file mode 100644 index 000000000..3811e2da7 --- /dev/null +++ b/packages/Kafka/src/Outbound/KafkaPendingDelivery.php @@ -0,0 +1,48 @@ +deliveryResult !== null) { + return $this->deliveryResult; + } + + $this->awaited = true; + $this->producer->flush($this->timeoutInMilliseconds); + + return $this->deliveryResult = $this->deliveryTracker->collectResult($this->deliveryIds, $this->channelName); + } + + public function isAwaited(): bool + { + return $this->awaited; + } +} diff --git a/packages/Kafka/tests/Integration/AsyncPublishingReliabilityTest.php b/packages/Kafka/tests/Integration/AsyncPublishingReliabilityTest.php new file mode 100644 index 000000000..c36069602 --- /dev/null +++ b/packages/Kafka/tests/Integration/AsyncPublishingReliabilityTest.php @@ -0,0 +1,85 @@ +bootstrapPublisher(); + + $this->expectException(PublishingFailedException::class); + + $publisher->send(str_repeat('x', 2_000_000)); + } + + public function test_broker_rejected_message_fails_async_publish_on_future_resolve(): void + { + $publisher = $this->bootstrapPublisher(); + + $future = $publisher->asyncPublish(str_repeat('x', 2_000_000)); + + $this->expectException(PublishingFailedException::class); + + $future->resolve(); + } + + public function test_broker_rejected_message_fails_plain_synchronous_publisher(): void + { + $messaging = EcotoneLite::bootstrapFlowTesting( + [], + [KafkaBrokerConfiguration::class => ConnectionTestCase::getConnection()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::KAFKA_PACKAGE])) + ->withExtensionObjects([ + KafkaPublisherConfiguration::createWithDefaults(topicName: Uuid::v7()->toRfc4122()) + ->setConfiguration('message.max.bytes', '4000000'), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $this->expectException(MessagePublishingException::class); + + $publisher->send(str_repeat('x', 2_000_000)); + } + + private function bootstrapPublisher(): MessagePublisher + { + $messaging = EcotoneLite::bootstrapFlowTesting( + [], + [KafkaBrokerConfiguration::class => ConnectionTestCase::getConnection()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::KAFKA_PACKAGE])) + ->withExtensionObjects([ + KafkaPublisherConfiguration::createWithDefaults(topicName: Uuid::v7()->toRfc4122()) + ->withAsyncPublishing(timeoutInMilliseconds: 10000) + ->setConfiguration('message.max.bytes', '4000000'), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + return $messaging->getGateway(MessagePublisher::class); + } +} diff --git a/packages/Kafka/tests/Integration/AsyncPublishingTest.php b/packages/Kafka/tests/Integration/AsyncPublishingTest.php new file mode 100644 index 000000000..54fc2a094 --- /dev/null +++ b/packages/Kafka/tests/Integration/AsyncPublishingTest.php @@ -0,0 +1,238 @@ +createOrderService($channelName); + $messaging = $this->bootstrapEcotone($channelName, $orderService, ConnectionTestCase::getConnection()); + + $messaging->sendCommandWithRoutingKey('order.place', 'espresso'); + + $this->assertSame([], $messaging->sendQueryWithRouting('order.getReceived')); + + $messaging->run($channelName, ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 3, maxExecutionTimeInMilliseconds: 10000)); + + $this->assertCount(3, $messaging->sendQueryWithRouting('order.getReceived')); + } + + public function test_failing_to_deliver_asynchronously_published_messages_throws(): void + { + $channelName = 'async_orders'; + $orderService = $this->createOrderService($channelName); + $messaging = $this->bootstrapEcotone( + $channelName, + $orderService, + KafkaBrokerConfiguration::createWithDefaults(['wronghost:9092']), + asyncPublishingTimeout: 500, + ); + + $this->expectException(PublishingFailedException::class); + + $messaging->sendCommandWithRoutingKey('order.place', 'espresso'); + } + + public function test_async_publishing_requires_enterprise_licence(): void + { + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [], + [KafkaBrokerConfiguration::class => ConnectionTestCase::getConnection()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::KAFKA_PACKAGE])) + ->withExtensionObjects([ + KafkaPublisherConfiguration::createWithDefaults(topicName: Uuid::v7()->toRfc4122()) + ->withAsyncPublishing(), + ]), + ); + } + + public function test_async_publishing_via_message_channel_requires_enterprise_licence(): void + { + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [], + [KafkaBrokerConfiguration::class => ConnectionTestCase::getConnection()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::KAFKA_PACKAGE])) + ->withExtensionObjects([ + KafkaMessageChannelBuilder::create( + 'async_orders', + topicName: $uniqueId = Uuid::v7()->toRfc4122(), + messageGroupId: $uniqueId, + )->withAsyncPublishing(), + ]), + ); + } + + public function test_message_publisher_async_publish_confirms_delivery_on_future_resolve(): void + { + $messaging = EcotoneLite::bootstrapFlowTesting( + [], + [KafkaBrokerConfiguration::class => ConnectionTestCase::getConnection()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::KAFKA_PACKAGE])) + ->withExtensionObjects([ + KafkaPublisherConfiguration::createWithDefaults(topicName: Uuid::v7()->toRfc4122()) + ->withAsyncPublishing(), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $singleFuture = $publisher->asyncPublish('single order'); + $batchFuture = $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('first order') + ->append('second order', ['priority' => '5']) + ); + + $this->assertNull($singleFuture->resolve()); + $this->assertNull($batchFuture->resolve()); + } + + public function test_batch_message_published_synchronously_from_command_handler_is_delivered(): void + { + $topicName = Uuid::v7()->toRfc4122(); + $commandHandler = new class () { + /** @var string[] */ + private array $receivedPayloads = []; + + #[CommandHandler('order.placeBatch')] + public function handle(string $order, #[Reference(MessagePublisher::class)] MessagePublisher $publisher): void + { + $publisher->convertAndSend( + BatchMessage::constructEmpty() + ->append($order . ' first order') + ->append($order . ' second order') + ); + } + + #[KafkaConsumer('batchOrdersConsumer', 'batchOrdersTopic')] + public function collect(string $payload): void + { + $this->receivedPayloads[] = $payload; + } + + #[QueryHandler('order.getReceivedBatchOrders')] + public function getReceived(): array + { + return $this->receivedPayloads; + } + }; + $messaging = EcotoneLite::bootstrapFlowTesting( + [$commandHandler::class], + [KafkaBrokerConfiguration::class => ConnectionTestCase::getConnection(), $commandHandler], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::KAFKA_PACKAGE])) + ->withExtensionObjects([ + KafkaPublisherConfiguration::createWithDefaults(topicName: $topicName) + ->withAsyncPublishing(), + TopicConfiguration::createWithReferenceName('batchOrdersTopic', $topicName), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + $messaging->sendCommandWithRoutingKey('order.placeBatch', 'espresso'); + + $messaging->run('batchOrdersConsumer', ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 2, maxExecutionTimeInMilliseconds: 30000)); + + $receivedPayloads = $messaging->sendQueryWithRouting('order.getReceivedBatchOrders'); + sort($receivedPayloads); + $this->assertSame(['espresso first order', 'espresso second order'], $receivedPayloads); + } + + private function createOrderService(string $channelName): object + { + return new class ($channelName) { + /** @var string[] */ + private array $receivedEvents = []; + + public function __construct(private string $channelName) + { + } + + #[CommandHandler('order.place')] + public function placeOrder(string $order, EventBus $eventBus): void + { + $eventBus->publish(new ExampleEvent($order . '-1')); + $eventBus->publish(new ExampleEvent($order . '-2')); + $eventBus->publish(new ExampleEvent($order . '-3')); + } + + #[Asynchronous('async_orders')] + #[EventHandler(endpointId: 'async_order_collector')] + public function collect(ExampleEvent $event): void + { + $this->receivedEvents[] = $event->id; + } + + #[QueryHandler('order.getReceived')] + public function getReceived(): array + { + return $this->receivedEvents; + } + }; + } + + private function bootstrapEcotone(string $channelName, object $orderService, KafkaBrokerConfiguration $brokerConfiguration, ?int $asyncPublishingTimeout = null): FlowTestSupport + { + $channelBuilder = KafkaMessageChannelBuilder::create( + $channelName, + topicName: $uniqueId = Uuid::v7()->toRfc4122(), + messageGroupId: $uniqueId, + )->withAsyncPublishing(); + + if ($asyncPublishingTimeout !== null) { + $channelBuilder = $channelBuilder->withAsyncPublishing(timeoutInMilliseconds: $asyncPublishingTimeout); + } + + return EcotoneLite::bootstrapFlowTesting( + [$orderService::class], + [KafkaBrokerConfiguration::class => $brokerConfiguration, $orderService], + ServiceConfiguration::createWithAsynchronicityOnly() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::KAFKA_PACKAGE])) + ->withExtensionObjects([$channelBuilder]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + } +} diff --git a/packages/PdoEventSourcing/tests/InMemory/ProjectionMetadataPropagationTest.php b/packages/PdoEventSourcing/tests/InMemory/ProjectionMetadataPropagationTest.php index ffe6e6c8f..01123a90f 100644 --- a/packages/PdoEventSourcing/tests/InMemory/ProjectionMetadataPropagationTest.php +++ b/packages/PdoEventSourcing/tests/InMemory/ProjectionMetadataPropagationTest.php @@ -62,7 +62,7 @@ public function test_metadata_propagation_with_async_projection_when_catching_up $ecotoneLite->sendCommandWithRoutingKey(routingKey: 'order.create', command: 2, metadata: ['eventId' => 2]); $ecotoneLite->sendCommandWithRoutingKey(routingKey: 'order.create', command: 3, metadata: ['foo' => 'baz', 'eventId' => 3]); - $ecotoneLite->run(name: OrderProjection::CHANNEL, executionPollingMetadata: ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 15, maxExecutionTimeInMilliseconds: 1000)); + $ecotoneLite->run(name: OrderProjection::CHANNEL, executionPollingMetadata: ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 15, maxExecutionTimeInMilliseconds: 5000)); self::assertEquals(expected: 4, actual: $ecotoneLite->sendQueryWithRouting('foo_orders.count')); self::assertEquals(expected: 4, actual: $ecotoneLite->sendQueryWithRouting('getNotificationCountWithFoo')); @@ -80,19 +80,19 @@ public function test_metadata_propagation_with_async_projection_when_populated_d ); $ecotoneLite->sendCommandWithRoutingKey(routingKey: 'order.create', command: 1, metadata: ['foo' => 'bar']); - $ecotoneLite->run(name: OrderProjection::CHANNEL, executionPollingMetadata: ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 4, maxExecutionTimeInMilliseconds: 1000)); + $ecotoneLite->run(name: OrderProjection::CHANNEL, executionPollingMetadata: ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 4, maxExecutionTimeInMilliseconds: 5000)); self::assertEquals(expected: 2, actual: $ecotoneLite->sendQueryWithRouting('foo_orders.count')); self::assertEquals(expected: 2, actual: $ecotoneLite->sendQueryWithRouting('getNotificationCountWithFoo')); $ecotoneLite->sendCommandWithRoutingKey(routingKey: 'order.create', command: 2); - $ecotoneLite->run(name: OrderProjection::CHANNEL, executionPollingMetadata: ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 4, maxExecutionTimeInMilliseconds: 1000)); + $ecotoneLite->run(name: OrderProjection::CHANNEL, executionPollingMetadata: ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 4, maxExecutionTimeInMilliseconds: 5000)); self::assertEquals(expected: 2, actual: $ecotoneLite->sendQueryWithRouting('foo_orders.count')); self::assertEquals(expected: 2, actual: $ecotoneLite->sendQueryWithRouting('getNotificationCountWithFoo')); $ecotoneLite->sendCommandWithRoutingKey(routingKey: 'order.create', command: 3, metadata: ['foo' => 'baz']); - $ecotoneLite->run(name: OrderProjection::CHANNEL, executionPollingMetadata: ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 4, maxExecutionTimeInMilliseconds: 1000)); + $ecotoneLite->run(name: OrderProjection::CHANNEL, executionPollingMetadata: ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 4, maxExecutionTimeInMilliseconds: 5000)); self::assertEquals(expected: 4, actual: $ecotoneLite->sendQueryWithRouting('foo_orders.count')); self::assertEquals(expected: 4, actual: $ecotoneLite->sendQueryWithRouting('getNotificationCountWithFoo')); diff --git a/packages/PdoEventSourcing/tests/Projecting/Global/MultiTenantProjectionTest.php b/packages/PdoEventSourcing/tests/Projecting/Global/MultiTenantProjectionTest.php index df78cab02..12e9ba2e0 100644 --- a/packages/PdoEventSourcing/tests/Projecting/Global/MultiTenantProjectionTest.php +++ b/packages/PdoEventSourcing/tests/Projecting/Global/MultiTenantProjectionTest.php @@ -149,7 +149,7 @@ classesToResolve: [get_class($projection), Ticket::class, TicketEventConverter:: SimpleMessageChannelBuilder::createQueueChannel('async_projection_channel'), PollingMetadata::create('async_projection_channel') ->setExecutionAmountLimit(3) - ->setExecutionTimeLimitInMilliseconds(300), + ->setExecutionTimeLimitInMilliseconds(5000), ]), runForProductionEventStore: true, licenceKey: LicenceTesting::VALID_LICENCE, diff --git a/packages/PdoEventSourcing/tests/Projecting/Partitioned/AsynchronousEventDrivenProjectionTest.php b/packages/PdoEventSourcing/tests/Projecting/Partitioned/AsynchronousEventDrivenProjectionTest.php index 7001795b4..66b7ef68b 100644 --- a/packages/PdoEventSourcing/tests/Projecting/Partitioned/AsynchronousEventDrivenProjectionTest.php +++ b/packages/PdoEventSourcing/tests/Projecting/Partitioned/AsynchronousEventDrivenProjectionTest.php @@ -182,8 +182,8 @@ classesToResolve: [$projection::class, Ticket::class, TicketEventConverter::clas $ecotone->run($projection::CHANNEL); $finishTime = microtime(true); - // around ~300 ms as default testing setup is 100ms (however connection and set up might take longer) - self::assertLessThan(300, ($finishTime - $currentTime) * 1000); + // well below the default 1s polling timeout, proving the run does not wait for it (CI runners can be slow) + self::assertLessThan(800, ($finishTime - $currentTime) * 1000); self::assertEquals([['ticket_id' => '123', 'ticket_type' => 'alert']], $ecotone->sendQueryWithRouting('getInProgressTickets')); } diff --git a/packages/PdoEventSourcing/tests/Projecting/Partitioned/MultiTenantProjectionTest.php b/packages/PdoEventSourcing/tests/Projecting/Partitioned/MultiTenantProjectionTest.php index a8f6655dc..81a6b302d 100644 --- a/packages/PdoEventSourcing/tests/Projecting/Partitioned/MultiTenantProjectionTest.php +++ b/packages/PdoEventSourcing/tests/Projecting/Partitioned/MultiTenantProjectionTest.php @@ -148,7 +148,7 @@ classesToResolve: [get_class($projection), Ticket::class, TicketEventConverter:: SimpleMessageChannelBuilder::createQueueChannel('async_projection_channel'), PollingMetadata::create('async_projection_channel') ->setExecutionAmountLimit(3) - ->setExecutionTimeLimitInMilliseconds(300), + ->setExecutionTimeLimitInMilliseconds(5000), ]), runForProductionEventStore: true, licenceKey: LicenceTesting::VALID_LICENCE, diff --git a/packages/Redis/src/Configuration/RedisMessagePublisherConfiguration.php b/packages/Redis/src/Configuration/RedisMessagePublisherConfiguration.php index b3df241ff..4aaafbc3c 100644 --- a/packages/Redis/src/Configuration/RedisMessagePublisherConfiguration.php +++ b/packages/Redis/src/Configuration/RedisMessagePublisherConfiguration.php @@ -14,6 +14,7 @@ final class RedisMessagePublisherConfiguration { private bool $autoDeclareOnSend = true; private string $headerMapper = ''; + private bool $asyncPublishing = false; private function __construct(private string $connectionReference, private string $queueName, private ?string $outputDefaultConversionMediaType, private string $referenceName) { @@ -71,4 +72,16 @@ public function getReferenceName(): string { return $this->referenceName; } + + public function withAsyncPublishing(bool $asyncPublishing = true): self + { + $this->asyncPublishing = $asyncPublishing; + + return $this; + } + + public function isAsyncPublishingEnabled(): bool + { + return $this->asyncPublishing; + } } diff --git a/packages/Redis/src/Configuration/RedisMessagePublisherModule.php b/packages/Redis/src/Configuration/RedisMessagePublisherModule.php index c74edeca9..82435d4af 100644 --- a/packages/Redis/src/Configuration/RedisMessagePublisherModule.php +++ b/packages/Redis/src/Configuration/RedisMessagePublisherModule.php @@ -6,6 +6,7 @@ use Ecotone\AnnotationFinder\AnnotationFinder; use Ecotone\Messaging\Attribute\ModuleAnnotation; +use Ecotone\Messaging\Channel\AsyncPublishing\Config\AsyncPublishGatewayRegistration; use Ecotone\Messaging\Config\Annotation\AnnotationModule; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ExtensionObjectResolver; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\NoExternalConfigurationModule; @@ -81,7 +82,10 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO ->withAutoDeclareOnSend($messagePublisher->isAutoDeclareOnSend()) ->withHeaderMapper($messagePublisher->getHeaderMapper()) ->withDefaultConversionMediaType($mediaType) + ->withAsyncPublishing($messagePublisher->isAsyncPublishingEnabled()) ); + + AsyncPublishGatewayRegistration::registerFor($messagingConfiguration, $messagePublisher->getReferenceName(), $messagePublisher->isAsyncPublishingEnabled()); } } diff --git a/packages/Redis/src/RedisBackedMessageChannelBuilder.php b/packages/Redis/src/RedisBackedMessageChannelBuilder.php index 210e7e854..df716069f 100644 --- a/packages/Redis/src/RedisBackedMessageChannelBuilder.php +++ b/packages/Redis/src/RedisBackedMessageChannelBuilder.php @@ -32,4 +32,21 @@ public static function create(string $channelName, string $connectionReferenceNa { return new self($channelName, $connectionReferenceName); } + + public function withAsyncPublishing(bool $asyncPublishing = true): self + { + $this->getRedisOutboundChannelAdapter()->withAsyncPublishing($asyncPublishing); + + return $this; + } + + protected function supportsBatchMessages(): bool + { + return $this->getRedisOutboundChannelAdapter()->isAsyncPublishingEnabled(); + } + + private function getRedisOutboundChannelAdapter(): RedisOutboundChannelAdapterBuilder + { + return $this->outboundChannelAdapter; + } } diff --git a/packages/Redis/src/RedisOutboundChannelAdapter.php b/packages/Redis/src/RedisOutboundChannelAdapter.php index 850b49c74..a115f6921 100644 --- a/packages/Redis/src/RedisOutboundChannelAdapter.php +++ b/packages/Redis/src/RedisOutboundChannelAdapter.php @@ -6,24 +6,66 @@ use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Enqueue\EnqueueOutboundChannelAdapter; +use Ecotone\Messaging\BatchMessage; +use Ecotone\Messaging\Channel\AsyncPublishing\AsyncPublishingRegistry; +use Ecotone\Messaging\Channel\AsyncPublishing\FailedDelivery; +use Ecotone\Messaging\Channel\AsyncPublishing\PublishingFailedException; use Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessageConverter; use Ecotone\Messaging\Conversion\ConversionService; +use Ecotone\Messaging\Message; +use Ecotone\Messaging\MessageHeaders; use Enqueue\Redis\RedisContext; use Enqueue\Redis\RedisDestination; +use Enqueue\Redis\RedisMessage; +use Interop\Queue\Context; +use Ramsey\Uuid\Uuid; +use RuntimeException; /** * licence Apache-2.0 */ final class RedisOutboundChannelAdapter extends EnqueueOutboundChannelAdapter { - public function __construct(CachedConnectionFactory $connectionFactory, private string $queueName, bool $autoDeclare, OutboundMessageConverter $outboundMessageConverter, ConversionService $conversionService) - { + private const BATCH_PUBLISH_SCRIPT = <<<'LUA' + local pushed = 0 + local immediateAmount = tonumber(ARGV[1]) + for argumentIndex = 2, immediateAmount + 1 do + local result = redis.pcall("lpush", KEYS[1], ARGV[argumentIndex]) + if type(result) == "table" and result.err then + return {pushed, result.err} + end + pushed = pushed + 1 + end + local argumentIndex = immediateAmount + 2 + while argumentIndex <= #ARGV do + local result = redis.pcall("zadd", KEYS[2], ARGV[argumentIndex], ARGV[argumentIndex + 1]) + if type(result) == "table" and result.err then + return {pushed, result.err} + end + pushed = pushed + 1 + argumentIndex = argumentIndex + 2 + end + return pushed + LUA; + + public function __construct( + CachedConnectionFactory $connectionFactory, + private string $queueName, + bool $autoDeclare, + OutboundMessageConverter $outboundMessageConverter, + ConversionService $conversionService, + AsyncPublishingRegistry $asyncPublishingRegistry, + bool $asyncPublishing = false, + ) { parent::__construct( $connectionFactory, new RedisDestination($queueName), $autoDeclare, $outboundMessageConverter, - $conversionService + $conversionService, + $asyncPublishingRegistry, + $asyncPublishing, + $queueName, ); } @@ -33,4 +75,100 @@ public function initialize(): void $context = $this->connectionFactory->createContext(); $context->createQueue($this->queueName); } + + protected function sendSingleMessage(Message $message, Context $context): void + { + $this->handleBatch( + BatchMessage::constructEmpty()->append($message->getPayload(), $message->getHeaders()->headers()), + $context, + ); + } + + protected function handleBatch(BatchMessage $batchMessage, Context $context): void + { + if (count($batchMessage) === 0) { + return; + } + + /** @var RedisContext $context */ + $immediatePayloads = []; + $immediateMessages = []; + $delayedEntries = []; + $delayedMessages = []; + foreach ($batchMessage->getEntries() as $entry) { + $originalMessage = $this->convertBatchEntryToMessage($entry); + $outboundMessage = $this->prepareOutboundMessage($originalMessage); + $headers = $outboundMessage->getHeaders(); + $headers[MessageHeaders::CONTENT_TYPE] = $outboundMessage->getContentType(); + + /** @var RedisMessage $messageToSend */ + $messageToSend = $context->createMessage($outboundMessage->getPayload(), $headers, []); + $messageToSend->setMessageId(Uuid::uuid4()->toString()); + $messageToSend->setHeader('attempts', 0); + + if ($outboundMessage->getTimeToLive()) { + $messageToSend->setTimeToLive($outboundMessage->getTimeToLive()); + $messageToSend->setHeader('expires_at', time() + (int) ceil($outboundMessage->getTimeToLive() / 1000)); + } + + $payload = $context->getSerializer()->toString($messageToSend); + + if ($outboundMessage->getDeliveryDelay()) { + $delayedEntries[] = ['score' => time() + $outboundMessage->getDeliveryDelay() / 1000, 'payload' => $payload]; + $delayedMessages[] = $originalMessage; + } else { + $immediatePayloads[] = $payload; + $immediateMessages[] = $originalMessage; + } + } + + if (count($immediatePayloads) === 1 && $delayedEntries === []) { + $queueLength = $context->getRedis()->lpush($this->queueName, $immediatePayloads[0]); + if ($queueLength < 1) { + throw new RuntimeException(sprintf('Redis did not confirm publishing message to queue %s.', $this->queueName)); + } + + return; + } + + if ($immediatePayloads === [] && count($delayedEntries) === 1) { + $addedMessages = $context->getRedis()->zadd($this->queueName . ':delayed', $delayedEntries[0]['payload'], $delayedEntries[0]['score']); + if ($addedMessages !== 1) { + throw new RuntimeException(sprintf('Redis did not confirm publishing delayed message to queue %s.', $this->queueName)); + } + + return; + } + + $arguments = [count($immediatePayloads), ...$immediatePayloads]; + foreach ($delayedEntries as $delayedEntry) { + $arguments[] = $delayedEntry['score']; + $arguments[] = $delayedEntry['payload']; + } + + $batchPublishResult = $context->getRedis()->eval( + self::BATCH_PUBLISH_SCRIPT, + [$this->queueName, $this->queueName . ':delayed'], + $arguments, + ); + + if (is_array($batchPublishResult)) { + $pushedMessages = (int) ($batchPublishResult[0] ?? 0); + $failureReason = (string) ($batchPublishResult[1] ?? 'Redis rejected publishing'); + + throw PublishingFailedException::withFailedDeliveries(array_map( + fn (Message $unpublishedMessage): FailedDelivery => new FailedDelivery($unpublishedMessage, $failureReason, $this->queueName), + array_slice([...$immediateMessages, ...$delayedMessages], $pushedMessages), + )); + } + + if ((int) $batchPublishResult !== count($batchMessage)) { + throw new RuntimeException(sprintf( + 'Redis did not confirm publishing whole batch to queue %s. Expected %d published messages, got %s.', + $this->queueName, + count($batchMessage), + var_export($batchPublishResult, true), + )); + } + } } diff --git a/packages/Redis/src/RedisOutboundChannelAdapterBuilder.php b/packages/Redis/src/RedisOutboundChannelAdapterBuilder.php index b3205a17b..22311888f 100644 --- a/packages/Redis/src/RedisOutboundChannelAdapterBuilder.php +++ b/packages/Redis/src/RedisOutboundChannelAdapterBuilder.php @@ -7,11 +7,13 @@ use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Enqueue\EnqueueOutboundChannelAdapterBuilder; use Ecotone\Enqueue\HttpReconnectableConnectionFactory; +use Ecotone\Messaging\Channel\AsyncPublishing\AsyncPublishingRegistry; use Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessageConverter; use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\Container\MessagingContainerBuilder; use Ecotone\Messaging\Config\Container\Reference; use Ecotone\Messaging\Conversion\ConversionService; +use Ecotone\Messaging\Support\LicensingException; use Enqueue\Redis\RedisConnectionFactory; /** @@ -19,6 +21,8 @@ */ final class RedisOutboundChannelAdapterBuilder extends EnqueueOutboundChannelAdapterBuilder { + private bool $asyncPublishing = false; + private function __construct(private string $queueName, private string $connectionFactoryReferenceName) { $this->initialize($connectionFactoryReferenceName); @@ -32,8 +36,24 @@ public static function createWith(string $queueName, string $connectionFactoryRe ); } + public function withAsyncPublishing(bool $asyncPublishing = true): self + { + $this->asyncPublishing = $asyncPublishing; + + return $this; + } + + public function isAsyncPublishingEnabled(): bool + { + return $this->asyncPublishing; + } + public function compile(MessagingContainerBuilder $builder): Definition { + if ($this->asyncPublishing && ! $builder->getServiceConfiguration()->isRunningForEnterprise()) { + throw LicensingException::create('Asynchronous publishing is available only with Ecotone Enterprise licence.'); + } + $connectionFactory = new Definition(CachedConnectionFactory::class, [ new Definition(HttpReconnectableConnectionFactory::class, [ new Reference($this->connectionFactoryReferenceName), @@ -55,6 +75,8 @@ public function compile(MessagingContainerBuilder $builder): Definition $this->autoDeclare, $outboundMessageConverter, new Reference(ConversionService::REFERENCE_NAME), + new Reference(AsyncPublishingRegistry::class), + $this->asyncPublishing, ]); } } diff --git a/packages/Redis/tests/Fixture/AsyncPublishing/OrderWasPlaced.php b/packages/Redis/tests/Fixture/AsyncPublishing/OrderWasPlaced.php new file mode 100644 index 000000000..1bb32fa51 --- /dev/null +++ b/packages/Redis/tests/Fixture/AsyncPublishing/OrderWasPlaced.php @@ -0,0 +1,15 @@ +redis(); + $redis->del(self::CHANNEL_NAME); + $redis->del(self::CHANNEL_NAME . ':delayed'); + } + + public function test_mid_batch_failure_reports_only_unpushed_entries_so_retry_does_not_duplicate_delivered_ones(): void + { + $this->makeDelayedStorageRejectWrites(); + $messaging = $this->bootstrapEcotoneWithRetryingChannel(); + $channel = $messaging->getMessageChannel(self::CHANNEL_NAME); + + $caughtException = null; + try { + $channel->send(MessageBuilder::withPayload( + BatchMessage::constructEmpty() + ->append('delivered order') + ->append('order for broken delayed storage', [MessageHeaders::DELIVERY_DELAY => 60000]) + )->build()); + } catch (Throwable $exception) { + $caughtException = $exception; + } + + $this->assertInstanceOf(PublishingFailedException::class, $caughtException); + $this->assertCount(1, $caughtException->getFailedDeliveries()); + $this->assertSame('order for broken delayed storage', $caughtException->getFailedDeliveries()[0]->getMessage()->getPayload()); + $this->assertSame(1, $this->queueLength()); + } + + private function makeDelayedStorageRejectWrites(): void + { + $this->redis()->lpush(self::CHANNEL_NAME . ':delayed', 'occupying delayed storage with wrong type'); + } + + private function bootstrapEcotoneWithRetryingChannel(): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + [], + [RedisConnectionFactory::class => $this->getConnectionFactory()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::REDIS_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) + ->withExtensionObjects([ + PollableChannelConfiguration::create(self::CHANNEL_NAME, RetryTemplateBuilder::fixedBackOff(1)->maxRetryAttempts(1)->build()), + ]), + enableAsynchronousProcessing: [ + RedisBackedMessageChannelBuilder::create(self::CHANNEL_NAME)->withAsyncPublishing(), + ], + licenceKey: LicenceTesting::VALID_LICENCE, + ); + } + + private function queueLength(): int + { + return (int) $this->redis()->eval('return redis.call("llen", KEYS[1])', [self::CHANNEL_NAME], []); + } + + private function redis(): \Enqueue\Redis\Redis + { + /** @var RedisContext $context */ + $context = $this->getConnectionFactory()->createContext(); + + return $context->getRedis(); + } +} diff --git a/packages/Redis/tests/Integration/AsyncPublishingTest.php b/packages/Redis/tests/Integration/AsyncPublishingTest.php new file mode 100644 index 000000000..d38992d85 --- /dev/null +++ b/packages/Redis/tests/Integration/AsyncPublishingTest.php @@ -0,0 +1,321 @@ +getConnectionFactory()->createContext(); + $context->getRedis()->del('asyncOrdersChannel'); + $context->getRedis()->del('asyncOrdersChannel:delayed'); + } + + public function test_multiple_messages_published_asynchronously_from_command_handler_are_delivered(): void + { + $orderService = $this->createOrderService(); + $messaging = $this->bootstrapEcotoneWithChannel($orderService, LicenceTesting::VALID_LICENCE); + + $messaging->sendCommandWithRoutingKey('order.place', 'espresso'); + + $this->assertSame([], $messaging->sendQueryWithRouting('order.getReceived')); + + $messaging->run('asyncOrdersChannel', ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 3, maxExecutionTimeInMilliseconds: 10000)); + + $this->assertSame( + ['espresso-1', 'espresso-2', 'espresso-3'], + $messaging->sendQueryWithRouting('order.getReceived'), + ); + } + + public function test_async_publishing_requires_enterprise_licence(): void + { + $orderService = $this->createOrderService(); + + $this->expectException(LicensingException::class); + + $this->bootstrapEcotoneWithChannel($orderService, licenceKey: null); + } + + public function test_async_publishing_via_message_publisher_requires_enterprise_licence(): void + { + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [], + [RedisConnectionFactory::class => $this->getConnectionFactory()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::REDIS_PACKAGE])) + ->withExtensionObjects([ + RedisMessagePublisherConfiguration::create(queueName: Uuid::v7()->toRfc4122()) + ->withAsyncPublishing(), + ]), + ); + } + + public function test_async_publish_on_publisher_without_async_configuration_throws_before_publishing(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: false); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $publishFailed = false; + try { + $publisher->asyncPublish('order that must not be published'); + } catch (PublishingFailedException) { + $publishFailed = true; + } + + $this->assertTrue($publishFailed); + $this->assertNull($messaging->getMessageChannel($queueName)->receive()); + } + + public function test_message_publisher_async_publish_confirms_delivery_on_future_resolve(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: true); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $singleFuture = $publisher->asyncPublish('single order'); + $batchFuture = $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('first order') + ->append('second order', ['priority' => '5']) + ); + + $this->assertNull($singleFuture->resolve()); + $this->assertNull($batchFuture->resolve()); + + $receivedPayloads = []; + while ($message = $messaging->getMessageChannel($queueName)->receive()) { + $receivedPayloads[] = $message->getPayload(); + } + sort($receivedPayloads); + $this->assertSame(['first order', 'second order', 'single order'], $receivedPayloads); + } + + public function test_batch_message_published_synchronously_from_command_handler_is_delivered(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $commandHandler = new class () { + #[CommandHandler('order.placeBatch')] + public function handle(string $order, #[Reference(MessagePublisher::class)] MessagePublisher $publisher): void + { + $publisher->convertAndSend( + BatchMessage::constructEmpty() + ->append($order . ' first order') + ->append($order . ' second order') + ); + } + }; + $messaging = EcotoneLite::bootstrapFlowTesting( + [$commandHandler::class], + [RedisConnectionFactory::class => $this->getConnectionFactory(), $commandHandler], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::REDIS_PACKAGE])) + ->withExtensionObjects([ + RedisMessagePublisherConfiguration::create(queueName: $queueName) + ->withAsyncPublishing(), + RedisBackedMessageChannelBuilder::create($queueName), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + $messaging->sendCommandWithRoutingKey('order.placeBatch', 'espresso'); + + $receivedPayloads = []; + while ($message = $messaging->getMessageChannel($queueName)->receive()) { + $receivedPayloads[] = $message->getPayload(); + } + sort($receivedPayloads); + $this->assertSame(['espresso first order', 'espresso second order'], $receivedPayloads); + } + + public function test_delayed_entry_of_published_batch_lands_in_delayed_set(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: true); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('immediate order') + ->append('delayed order', [MessageHeaders::DELIVERY_DELAY => 60000]) + )->resolve(); + + $receivedPayloads = []; + while ($message = $messaging->getMessageChannel($queueName)->receive()) { + $receivedPayloads[] = $message->getPayload(); + } + $this->assertSame(['immediate order'], $receivedPayloads); + + /** @var RedisContext $context */ + $context = $this->getConnectionFactory()->createContext(); + $this->assertSame(1, $context->getRedis()->eval('return redis.call("zcard", KEYS[1])', [$queueName . ':delayed'])); + } + + public function test_expired_entry_of_published_batch_is_not_delivered(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: true); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('expiring order', [MessageHeaders::TIME_TO_LIVE => 1000]) + ->append('kept order') + )->resolve(); + + sleep(2); + + $channel = $messaging->getMessageChannel($queueName); + $this->assertSame('kept order', $channel->receive()->getPayload()); + $this->assertNull($channel->receive()); + } + + public function test_publishing_to_wrong_type_key_throws(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: true); + /** @var RedisContext $context */ + $context = $this->getConnectionFactory()->createContext(); + $context->getRedis()->eval('redis.call("set", KEYS[1], "blocked") return 1', [$queueName]); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $publishFailed = false; + try { + $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('first order') + ->append('second order') + ); + } catch (Throwable) { + $publishFailed = true; + } + + $this->assertTrue($publishFailed); + } + + public function test_partially_applied_mixed_batch_throws_while_immediate_entries_remain(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: true); + /** @var RedisContext $context */ + $context = $this->getConnectionFactory()->createContext(); + $context->getRedis()->lpush($queueName . ':delayed', 'poison entry forcing wrong type on the delayed set'); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $publishFailed = false; + try { + $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('immediate order') + ->append('delayed order', [MessageHeaders::DELIVERY_DELAY => 60000]) + ); + } catch (Throwable) { + $publishFailed = true; + } + + $this->assertTrue($publishFailed); + + $context->getRedis()->del($queueName . ':delayed'); + $this->assertSame('immediate order', $messaging->getMessageChannel($queueName)->receive()->getPayload()); + } + + private function createOrderService(): object + { + return new class () { + /** @var string[] */ + private array $receivedEvents = []; + + #[CommandHandler('order.place')] + public function placeOrder(string $order, EventBus $eventBus): void + { + $eventBus->publish(new OrderWasPlaced($order . '-1')); + $eventBus->publish(new OrderWasPlaced($order . '-2')); + $eventBus->publish(new OrderWasPlaced($order . '-3')); + } + + #[Asynchronous('asyncOrdersChannel')] + #[EventHandler(endpointId: 'async_redis_order_collector')] + public function collect(OrderWasPlaced $event): void + { + $this->receivedEvents[] = $event->order; + } + + #[QueryHandler('order.getReceived')] + public function getReceived(): array + { + return $this->receivedEvents; + } + }; + } + + private function bootstrapEcotoneWithChannel(object $orderService, ?string $licenceKey): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + [$orderService::class], + [RedisConnectionFactory::class => $this->getConnectionFactory(), $orderService], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::REDIS_PACKAGE])) + ->withExtensionObjects([ + RedisBackedMessageChannelBuilder::create('asyncOrdersChannel') + ->withAsyncPublishing(), + ]), + licenceKey: $licenceKey, + ); + } + + private function bootstrapPublisher(string $queueName, bool $asyncPublishing): FlowTestSupport + { + $publisherConfiguration = RedisMessagePublisherConfiguration::create(queueName: $queueName); + if ($asyncPublishing) { + $publisherConfiguration = $publisherConfiguration->withAsyncPublishing(); + } + + return EcotoneLite::bootstrapFlowTesting( + [], + [RedisConnectionFactory::class => $this->getConnectionFactory()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::REDIS_PACKAGE])) + ->withExtensionObjects([ + $publisherConfiguration, + RedisBackedMessageChannelBuilder::create($queueName), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + } +} diff --git a/packages/Sqs/src/Configuration/SqsMessagePublisherConfiguration.php b/packages/Sqs/src/Configuration/SqsMessagePublisherConfiguration.php index 34df22145..f38f4a1e3 100644 --- a/packages/Sqs/src/Configuration/SqsMessagePublisherConfiguration.php +++ b/packages/Sqs/src/Configuration/SqsMessagePublisherConfiguration.php @@ -5,6 +5,7 @@ namespace Ecotone\Sqs\Configuration; use Ecotone\Messaging\MessagePublisher; +use Ecotone\Messaging\Support\Assert; use Enqueue\Sqs\SqsConnectionFactory; /** @@ -14,6 +15,8 @@ final class SqsMessagePublisherConfiguration { private bool $autoDeclareOnSend = true; private string $headerMapper = ''; + private bool $asyncPublishing = false; + private ?int $asyncPublishingTimeout = null; private function __construct(private string $connectionReference, private string $queueName, private ?string $outputDefaultConversionMediaType, private string $referenceName) { @@ -71,4 +74,25 @@ public function getReferenceName(): string { return $this->referenceName; } + + public function withAsyncPublishing(bool $asyncPublishing = true, ?int $timeoutInMilliseconds = null): self + { + Assert::isTrue($timeoutInMilliseconds === null || $timeoutInMilliseconds > 0, 'Async publishing timeout must be a positive amount of milliseconds.'); + $this->asyncPublishing = $asyncPublishing; + if ($timeoutInMilliseconds !== null) { + $this->asyncPublishingTimeout = $timeoutInMilliseconds; + } + + return $this; + } + + public function isAsyncPublishingEnabled(): bool + { + return $this->asyncPublishing; + } + + public function getAsyncPublishingTimeout(): ?int + { + return $this->asyncPublishingTimeout; + } } diff --git a/packages/Sqs/src/Configuration/SqsMessagePublisherModule.php b/packages/Sqs/src/Configuration/SqsMessagePublisherModule.php index db11f707f..b65b234ad 100644 --- a/packages/Sqs/src/Configuration/SqsMessagePublisherModule.php +++ b/packages/Sqs/src/Configuration/SqsMessagePublisherModule.php @@ -6,6 +6,7 @@ use Ecotone\AnnotationFinder\AnnotationFinder; use Ecotone\Messaging\Attribute\ModuleAnnotation; +use Ecotone\Messaging\Channel\AsyncPublishing\Config\AsyncPublishGatewayRegistration; use Ecotone\Messaging\Config\Annotation\AnnotationModule; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ExtensionObjectResolver; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\NoExternalConfigurationModule; @@ -81,7 +82,10 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO ->withAutoDeclareOnSend($messagePublisher->isAutoDeclareOnSend()) ->withHeaderMapper($messagePublisher->getHeaderMapper()) ->withDefaultConversionMediaType($mediaType) + ->withAsyncPublishing($messagePublisher->isAsyncPublishingEnabled(), $messagePublisher->getAsyncPublishingTimeout()) ); + + AsyncPublishGatewayRegistration::registerFor($messagingConfiguration, $messagePublisher->getReferenceName(), $messagePublisher->isAsyncPublishingEnabled()); } } diff --git a/packages/Sqs/src/SqsBackedMessageChannelBuilder.php b/packages/Sqs/src/SqsBackedMessageChannelBuilder.php index 74efabaf8..c12326cba 100644 --- a/packages/Sqs/src/SqsBackedMessageChannelBuilder.php +++ b/packages/Sqs/src/SqsBackedMessageChannelBuilder.php @@ -32,4 +32,21 @@ public static function create(string $channelName, string $connectionReferenceNa { return new self($channelName, $connectionReferenceName); } + + public function withAsyncPublishing(bool $asyncPublishing = true, ?int $timeoutInMilliseconds = null): self + { + $this->getSqsOutboundChannelAdapter()->withAsyncPublishing($asyncPublishing, $timeoutInMilliseconds); + + return $this; + } + + protected function supportsBatchMessages(): bool + { + return $this->getSqsOutboundChannelAdapter()->isAsyncPublishingEnabled(); + } + + private function getSqsOutboundChannelAdapter(): SqsOutboundChannelAdapterBuilder + { + return $this->outboundChannelAdapter; + } } diff --git a/packages/Sqs/src/SqsOutboundChannelAdapter.php b/packages/Sqs/src/SqsOutboundChannelAdapter.php index 151d233be..4cd75e82c 100644 --- a/packages/Sqs/src/SqsOutboundChannelAdapter.php +++ b/packages/Sqs/src/SqsOutboundChannelAdapter.php @@ -6,24 +6,48 @@ use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Enqueue\EnqueueOutboundChannelAdapter; +use Ecotone\Messaging\BatchMessage; +use Ecotone\Messaging\Channel\AsyncPublishing\AsyncPublishingRegistry; +use Ecotone\Messaging\Channel\AsyncPublishing\PublishingFailedException; use Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessageConverter; +use Ecotone\Messaging\Config\ConfigurationException; use Ecotone\Messaging\Conversion\ConversionService; +use Ecotone\Messaging\Message; +use Ecotone\Messaging\MessageHeaders; use Enqueue\Sqs\SqsContext; use Enqueue\Sqs\SqsDestination; +use Interop\Queue\Exception\InvalidMessageException; /** * licence Apache-2.0 */ final class SqsOutboundChannelAdapter extends EnqueueOutboundChannelAdapter { - public function __construct(CachedConnectionFactory $connectionFactory, private string $queueName, bool $autoDeclare, OutboundMessageConverter $outboundMessageConverter, ConversionService $conversionService) - { + private const MAX_ENTRIES_PER_BATCH_REQUEST = 10; + private const BATCH_REQUEST_PAYLOAD_BUDGET_IN_BYTES = 204800; + + private SqsRequestDispatchPool $requestDispatchPool; + + public function __construct( + CachedConnectionFactory $connectionFactory, + private string $queueName, + bool $autoDeclare, + OutboundMessageConverter $outboundMessageConverter, + ConversionService $conversionService, + private AsyncPublishingRegistry $asyncPublishingRegistry, + private bool $asyncPublishing = false, + private int $asyncPublishingTimeout = SqsOutboundChannelAdapterBuilder::DEFAULT_ASYNC_PUBLISHING_TIMEOUT, + ) { + $this->requestDispatchPool = new SqsRequestDispatchPool(); parent::__construct( $connectionFactory, new SqsDestination($queueName), $autoDeclare, $outboundMessageConverter, - $conversionService + $conversionService, + $asyncPublishingRegistry, + $asyncPublishing, + $queueName, ); } @@ -34,4 +58,131 @@ public function initialize(): void $context->declareQueue($context->createQueue($this->queueName)); } + + public function handle(Message $message): void + { + if ($message->getPayload() instanceof BatchMessage && ! $this->asyncPublishing) { + throw ConfigurationException::create(sprintf('Sending BatchMessage over `%s` requires async publishing to be enabled. Enable it with withAsyncPublishing(), available as part of Ecotone Enterprise.', $this->queueName)); + } + + /** @var SqsContext $context */ + $context = $this->createOutboundContext(); + + $payload = $message->getPayload(); + $messagesToPublish = $payload instanceof BatchMessage + ? array_map(fn (array $entry): Message => $this->convertBatchEntryToMessage($entry), $payload->getEntries()) + : [$message]; + + if ($messagesToPublish === []) { + return; + } + + $awsSqsClient = $context->getAwsSqsClient(); + $sendRequestPromises = []; + $trackedMessagesPerRequest = []; + foreach ($this->buildBatchRequests($messagesToPublish, $context) as $batchRequest) { + $requestArguments = $batchRequest['arguments']; + $sendRequestPromises[] = $this->requestDispatchPool->dispatch(fn () => $awsSqsClient->sendMessageBatchAsync($requestArguments)); + $trackedMessagesPerRequest[] = $batchRequest['trackedMessages']; + } + + $pendingDelivery = new SqsPendingDelivery($sendRequestPromises, $trackedMessagesPerRequest, $this->queueName); + + if ($this->asyncPublishing && $this->asyncPublishingRegistry->isScopeActive()) { + $this->asyncPublishingRegistry->register($this->queueName, $pendingDelivery); + + return; + } + + $deliveryResult = $pendingDelivery->awaitDelivery(); + if (! $deliveryResult->isSuccessful()) { + throw PublishingFailedException::withFailedDeliveries($deliveryResult->getFailedDeliveries()); + } + } + + /** + * @param Message[] $messagesToPublish + * @return array}> + */ + private function buildBatchRequests(array $messagesToPublish, SqsContext $context): array + { + /** @var SqsDestination $destination */ + $destination = $this->destination; + $queueUrl = $context->getQueueUrl($destination); + + $batchRequests = []; + $entries = []; + $trackedMessages = []; + $payloadSizeInBytes = 0; + + foreach ($messagesToPublish as $messageIndex => $messageToPublish) { + $entry = $this->buildBatchEntry((string) $messageIndex, $messageToPublish, $context); + $entrySizeInBytes = strlen($entry['MessageBody']) + strlen($entry['MessageAttributes']['Headers']['StringValue']); + + $currentBatchIsFull = count($entries) >= self::MAX_ENTRIES_PER_BATCH_REQUEST + || ($entries !== [] && $payloadSizeInBytes + $entrySizeInBytes > self::BATCH_REQUEST_PAYLOAD_BUDGET_IN_BYTES); + if ($currentBatchIsFull) { + $batchRequests[] = $this->buildBatchRequest($destination, $queueUrl, $entries, $trackedMessages); + $entries = []; + $trackedMessages = []; + $payloadSizeInBytes = 0; + } + + $entries[] = $entry; + $trackedMessages[$entry['Id']] = $messageToPublish; + $payloadSizeInBytes += $entrySizeInBytes; + } + + if ($entries !== []) { + $batchRequests[] = $this->buildBatchRequest($destination, $queueUrl, $entries, $trackedMessages); + } + + return $batchRequests; + } + + private function buildBatchEntry(string $entryId, Message $messageToPublish, SqsContext $context): array + { + $outboundMessage = $this->prepareOutboundMessage($messageToPublish); + $headers = $outboundMessage->getHeaders(); + $headers[MessageHeaders::CONTENT_TYPE] = $outboundMessage->getContentType(); + + $sqsMessage = $context->createMessage($outboundMessage->getPayload(), $headers, []); + if (empty($sqsMessage->getBody())) { + throw new InvalidMessageException('The message body must be a non-empty string.'); + } + + $entry = [ + 'Id' => $entryId, + 'MessageBody' => $sqsMessage->getBody(), + 'MessageAttributes' => [ + 'Headers' => [ + 'DataType' => 'String', + 'StringValue' => json_encode([$sqsMessage->getHeaders(), $sqsMessage->getProperties()]), + ], + ], + ]; + + if ($outboundMessage->getDeliveryDelay()) { + $entry['DelaySeconds'] = (int) ceil($outboundMessage->getDeliveryDelay() / 1000); + } + + return $entry; + } + + /** + * @param array $trackedMessages + * @return array{arguments: array, trackedMessages: array} + */ + private function buildBatchRequest(SqsDestination $destination, string $queueUrl, array $entries, array $trackedMessages): array + { + $arguments = [ + '@region' => $destination->getRegion(), + 'QueueUrl' => $queueUrl, + 'Entries' => $entries, + ]; + + $arguments['@http'] = ['timeout' => $this->asyncPublishingTimeout / 1000]; + + return ['arguments' => $arguments, 'trackedMessages' => $trackedMessages]; + } } diff --git a/packages/Sqs/src/SqsOutboundChannelAdapterBuilder.php b/packages/Sqs/src/SqsOutboundChannelAdapterBuilder.php index 20d773753..d3b1cbe42 100644 --- a/packages/Sqs/src/SqsOutboundChannelAdapterBuilder.php +++ b/packages/Sqs/src/SqsOutboundChannelAdapterBuilder.php @@ -7,11 +7,14 @@ use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Enqueue\EnqueueOutboundChannelAdapterBuilder; use Ecotone\Enqueue\HttpReconnectableConnectionFactory; +use Ecotone\Messaging\Channel\AsyncPublishing\AsyncPublishingRegistry; use Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessageConverter; use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\Container\MessagingContainerBuilder; use Ecotone\Messaging\Config\Container\Reference; use Ecotone\Messaging\Conversion\ConversionService; +use Ecotone\Messaging\Support\Assert; +use Ecotone\Messaging\Support\LicensingException; use Enqueue\Sqs\SqsConnectionFactory; /** @@ -19,6 +22,11 @@ */ final class SqsOutboundChannelAdapterBuilder extends EnqueueOutboundChannelAdapterBuilder { + public const DEFAULT_ASYNC_PUBLISHING_TIMEOUT = 25000; + + private bool $asyncPublishing = false; + private int $asyncPublishingTimeout = self::DEFAULT_ASYNC_PUBLISHING_TIMEOUT; + private function __construct(private string $queueName, private string $connectionFactoryReferenceName) { $this->initialize($connectionFactoryReferenceName); @@ -29,8 +37,28 @@ public static function create(string $queueName, string $connectionFactoryRefere return new self($queueName, $connectionFactoryReferenceName); } + public function withAsyncPublishing(bool $asyncPublishing = true, ?int $timeoutInMilliseconds = null): self + { + Assert::isTrue($timeoutInMilliseconds === null || $timeoutInMilliseconds > 0, 'Async publishing timeout must be a positive amount of milliseconds.'); + $this->asyncPublishing = $asyncPublishing; + if ($timeoutInMilliseconds !== null) { + $this->asyncPublishingTimeout = $timeoutInMilliseconds; + } + + return $this; + } + + public function isAsyncPublishingEnabled(): bool + { + return $this->asyncPublishing; + } + public function compile(MessagingContainerBuilder $builder): Definition { + if ($this->asyncPublishing && ! $builder->getServiceConfiguration()->isRunningForEnterprise()) { + throw LicensingException::create('Asynchronous publishing is available only with Ecotone Enterprise licence.'); + } + $connectionFactory = new Definition(CachedConnectionFactory::class, [ new Definition(HttpReconnectableConnectionFactory::class, [ new Reference($this->connectionFactoryReferenceName), @@ -52,6 +80,9 @@ public function compile(MessagingContainerBuilder $builder): Definition $this->autoDeclare, $outboundMessageConverter, new Reference(ConversionService::REFERENCE_NAME), + new Reference(AsyncPublishingRegistry::class), + $this->asyncPublishing, + $this->asyncPublishingTimeout, ]); } } diff --git a/packages/Sqs/src/SqsPendingDelivery.php b/packages/Sqs/src/SqsPendingDelivery.php new file mode 100644 index 000000000..5d1e3ca6e --- /dev/null +++ b/packages/Sqs/src/SqsPendingDelivery.php @@ -0,0 +1,100 @@ +> $trackedMessagesPerRequest keyed by request index, then by batch entry id + */ + public function __construct( + private array $sendRequestPromises, + private array $trackedMessagesPerRequest, + private string $channelName, + ) { + } + + public function awaitDelivery(): DeliveryResult + { + if ($this->deliveryResult !== null) { + return $this->deliveryResult; + } + + $settledResults = Utils::settle($this->sendRequestPromises)->wait(); + $this->awaited = true; + + $failedDeliveries = []; + foreach ($this->trackedMessagesPerRequest as $requestIndex => $trackedMessages) { + $settledResult = $settledResults[$requestIndex] ?? ['state' => PromiseInterface::REJECTED, 'reason' => 'SQS send request was never dispatched']; + + if ($settledResult['state'] !== PromiseInterface::FULFILLED) { + $failureReason = $settledResult['reason'] instanceof Throwable + ? $settledResult['reason']->getMessage() + : (string) $settledResult['reason']; + + foreach ($trackedMessages as $trackedMessage) { + $failedDeliveries[] = new FailedDelivery($trackedMessage, $failureReason, $this->channelName); + } + + continue; + } + + /** @var Result $awsResult */ + $awsResult = $settledResult['value']; + $unaccountedEntryIds = array_map('strval', array_keys($trackedMessages)); + + foreach ($awsResult->get('Successful') ?? [] as $successfulEntry) { + $unaccountedEntryIds = array_diff($unaccountedEntryIds, [(string) $successfulEntry['Id']]); + } + + foreach ($awsResult->get('Failed') ?? [] as $failedEntry) { + $entryId = (string) $failedEntry['Id']; + $unaccountedEntryIds = array_diff($unaccountedEntryIds, [$entryId]); + + if (isset($trackedMessages[$entryId])) { + $failedDeliveries[] = new FailedDelivery( + $trackedMessages[$entryId], + sprintf('%s: %s', $failedEntry['Code'] ?? 'Unknown', $failedEntry['Message'] ?? 'SQS rejected batch entry'), + $this->channelName, + ); + } + } + + foreach ($unaccountedEntryIds as $unaccountedEntryId) { + $failedDeliveries[] = new FailedDelivery( + $trackedMessages[$unaccountedEntryId], + 'SQS did not confirm delivery of the batch entry', + $this->channelName, + ); + } + } + + return $this->deliveryResult = ($failedDeliveries === [] + ? DeliveryResult::successful() + : DeliveryResult::withFailedDeliveries($failedDeliveries)); + } + + public function isAwaited(): bool + { + return $this->awaited; + } +} diff --git a/packages/Sqs/src/SqsRequestDispatchPool.php b/packages/Sqs/src/SqsRequestDispatchPool.php new file mode 100644 index 000000000..aa0c78299 --- /dev/null +++ b/packages/Sqs/src/SqsRequestDispatchPool.php @@ -0,0 +1,105 @@ + */ + private array $trackedRequests = []; + + /** @var array */ + private array $awaitingDispatch = []; + + /** @var array */ + private array $inFlightRequests = []; + + private int $nextRequestIndex = 0; + + public function __construct(private int $maxConcurrentRequests = self::DEFAULT_MAX_CONCURRENT_REQUESTS) + { + } + + public function dispatch(Closure $dispatchSendRequest): PromiseInterface + { + $requestIndex = $this->nextRequestIndex++; + $proxy = new Promise(fn () => $this->driveUntilSettled($requestIndex)); + $this->trackedRequests[$requestIndex] = ['proxy' => $proxy, 'underlying' => null]; + $this->awaitingDispatch[$requestIndex] = $dispatchSendRequest; + $this->dispatchWithinBudget(); + + return $proxy; + } + + private function dispatchWithinBudget(): void + { + while (count($this->inFlightRequests) < $this->maxConcurrentRequests && $this->awaitingDispatch !== []) { + $requestIndex = array_key_first($this->awaitingDispatch); + $dispatchSendRequest = $this->awaitingDispatch[$requestIndex]; + unset($this->awaitingDispatch[$requestIndex]); + + try { + $underlying = $dispatchSendRequest(); + } catch (Throwable $dispatchFailure) { + $proxy = $this->trackedRequests[$requestIndex]['proxy']; + unset($this->trackedRequests[$requestIndex]); + $proxy->reject($dispatchFailure); + + continue; + } + + $this->trackedRequests[$requestIndex]['underlying'] = $underlying; + $this->inFlightRequests[$requestIndex] = $underlying; + + $underlying->then( + function (mixed $value) use ($requestIndex): void { + $this->settleProxy($requestIndex, fn (Promise $proxy) => $proxy->resolve($value)); + }, + function (mixed $reason) use ($requestIndex): void { + $this->settleProxy($requestIndex, fn (Promise $proxy) => $proxy->reject($reason)); + }, + ); + } + } + + private function settleProxy(int $requestIndex, Closure $settle): void + { + unset($this->inFlightRequests[$requestIndex]); + $this->dispatchWithinBudget(); + + $proxy = $this->trackedRequests[$requestIndex]['proxy']; + unset($this->trackedRequests[$requestIndex]); + $settle($proxy); + } + + private function driveUntilSettled(int $requestIndex): void + { + while (isset($this->trackedRequests[$requestIndex]) && $this->trackedRequests[$requestIndex]['proxy']->getState() === PromiseInterface::PENDING) { + $underlying = $this->trackedRequests[$requestIndex]['underlying']; + if ($underlying !== null) { + $underlying->wait(false); + + continue; + } + + if ($this->inFlightRequests !== []) { + $this->inFlightRequests[array_key_first($this->inFlightRequests)]->wait(false); + + continue; + } + + $this->dispatchWithinBudget(); + } + } +} diff --git a/packages/Sqs/tests/Fixture/AsyncPublishing/OrderWasPlaced.php b/packages/Sqs/tests/Fixture/AsyncPublishing/OrderWasPlaced.php new file mode 100644 index 000000000..1cf3396e4 --- /dev/null +++ b/packages/Sqs/tests/Fixture/AsyncPublishing/OrderWasPlaced.php @@ -0,0 +1,15 @@ +bootstrapPublisher(Uuid::v7()->toRfc4122()); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $future = $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('valid order') + ->append(str_repeat('x', 300_000)) + ); + + $this->expectException(PublishingFailedException::class); + + $future->resolve(); + } + + public function test_broker_rejected_batch_sent_without_active_scope_throws_immediately(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapChannel($queueName); + + $this->expectException(PublishingFailedException::class); + + $messaging->getMessageChannel($queueName)->send( + MessageBuilder::withPayload( + BatchMessage::constructEmpty()->append(str_repeat('x', 300_000)) + )->build() + ); + } + + public function test_one_failing_batch_among_many_published_in_command_handler_fails_before_commit(): void + { + $commandHandler = new class () { + #[CommandHandler('order.placeAllBatches')] + public function handle(string $order, #[Reference(MessagePublisher::class)] MessagePublisher $publisher): void + { + $publisher->asyncPublish(BatchMessage::constructEmpty()->append($order . ' first valid order')); + $publisher->asyncPublish(BatchMessage::constructEmpty()->append(str_repeat('x', 300_000))); + $publisher->asyncPublish(BatchMessage::constructEmpty()->append($order . ' third valid order')); + } + }; + $messaging = EcotoneLite::bootstrapFlowTesting( + [$commandHandler::class], + [SqsConnectionFactory::class => $this->getConnectionFactory(), $commandHandler], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::SQS_PACKAGE])) + ->withExtensionObjects([ + SqsMessagePublisherConfiguration::create(queueName: Uuid::v7()->toRfc4122()) + ->withAsyncPublishing(timeoutInMilliseconds: 10000), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + $this->expectException(PublishingFailedException::class); + + $messaging->sendCommandWithRoutingKey('order.placeAllBatches', 'espresso'); + } + + private function bootstrapPublisher(string $queueName): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + [], + [SqsConnectionFactory::class => $this->getConnectionFactory()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::SQS_PACKAGE])) + ->withExtensionObjects([ + SqsMessagePublisherConfiguration::create(queueName: $queueName) + ->withAsyncPublishing(timeoutInMilliseconds: 10000), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + } + + private function bootstrapChannel(string $channelName): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + [], + [SqsConnectionFactory::class => $this->getConnectionFactory()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::SQS_PACKAGE])) + ->withExtensionObjects([ + SqsBackedMessageChannelBuilder::create($channelName) + ->withAsyncPublishing(), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + } +} diff --git a/packages/Sqs/tests/Integration/AsyncPublishingTest.php b/packages/Sqs/tests/Integration/AsyncPublishingTest.php new file mode 100644 index 000000000..ae441c798 --- /dev/null +++ b/packages/Sqs/tests/Integration/AsyncPublishingTest.php @@ -0,0 +1,268 @@ +createOrderService(); + $messaging = $this->bootstrapEcotoneWithChannel($orderService, LicenceTesting::VALID_LICENCE); + + $messaging->sendCommandWithRoutingKey('order.place', 'espresso'); + + $this->assertSame([], $messaging->sendQueryWithRouting('order.getReceived')); + + $messaging->run('asyncOrdersChannel', ExecutionPollingMetadata::createWithTestingSetup(amountOfMessagesToHandle: 3, maxExecutionTimeInMilliseconds: 20000)); + + $receivedEvents = $messaging->sendQueryWithRouting('order.getReceived'); + sort($receivedEvents); + $this->assertSame(['espresso-1', 'espresso-2', 'espresso-3'], $receivedEvents); + } + + public function test_async_publishing_requires_enterprise_licence(): void + { + $orderService = $this->createOrderService(); + + $this->expectException(LicensingException::class); + + $this->bootstrapEcotoneWithChannel($orderService, licenceKey: null); + } + + public function test_async_publishing_via_message_publisher_requires_enterprise_licence(): void + { + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [], + [SqsConnectionFactory::class => $this->getConnectionFactory()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::SQS_PACKAGE])) + ->withExtensionObjects([ + SqsMessagePublisherConfiguration::create(queueName: Uuid::v7()->toRfc4122()) + ->withAsyncPublishing(), + ]), + ); + } + + public function test_async_publish_on_publisher_without_async_configuration_throws_before_publishing(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: false); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $publishFailed = false; + try { + $publisher->asyncPublish('order that must not be published'); + } catch (PublishingFailedException) { + $publishFailed = true; + } + + $this->assertTrue($publishFailed); + $this->assertNull($messaging->getMessageChannel($queueName)->receive()); + } + + public function test_message_publisher_async_publish_confirms_delivery_on_future_resolve(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: true); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $singleFuture = $publisher->asyncPublish('single order'); + $batchFuture = $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('first order') + ->append('second order', ['priority' => '5']) + ); + + $this->assertNull($singleFuture->resolve()); + $this->assertNull($batchFuture->resolve()); + + $receivedPayloads = []; + while ($message = $messaging->getMessageChannel($queueName)->receive()) { + $receivedPayloads[] = $message->getPayload(); + } + sort($receivedPayloads); + $this->assertSame(['first order', 'second order', 'single order'], $receivedPayloads); + } + + public function test_batch_message_published_synchronously_from_command_handler_is_delivered(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $commandHandler = new class () { + #[CommandHandler('order.placeBatch')] + public function handle(string $order, #[Reference(MessagePublisher::class)] MessagePublisher $publisher): void + { + $publisher->convertAndSend( + BatchMessage::constructEmpty() + ->append($order . ' first order') + ->append($order . ' second order') + ); + } + }; + $messaging = EcotoneLite::bootstrapFlowTesting( + [$commandHandler::class], + [SqsConnectionFactory::class => $this->getConnectionFactory(), $commandHandler], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::SQS_PACKAGE])) + ->withExtensionObjects([ + SqsMessagePublisherConfiguration::create(queueName: $queueName) + ->withAsyncPublishing(timeoutInMilliseconds: 10000), + SqsBackedMessageChannelBuilder::create($queueName), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + $messaging->sendCommandWithRoutingKey('order.placeBatch', 'espresso'); + + $receivedPayloads = []; + while ($message = $messaging->getMessageChannel($queueName)->receive()) { + $receivedPayloads[] = $message->getPayload(); + } + sort($receivedPayloads); + $this->assertSame(['espresso first order', 'espresso second order'], $receivedPayloads); + } + + public function test_batch_larger_than_ten_messages_is_chunked_and_delivered(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: true); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $batch = BatchMessage::constructEmpty(); + for ($orderNumber = 1; $orderNumber <= 25; $orderNumber++) { + $batch = $batch->append('order ' . $orderNumber); + } + + $this->assertNull($publisher->asyncPublish($batch)->resolve()); + + $receivedPayloads = []; + while ($message = $messaging->getMessageChannel($queueName)->receive()) { + $receivedPayloads[] = $message->getPayload(); + } + $this->assertCount(25, $receivedPayloads); + } + + public function test_delayed_entry_of_published_batch_is_delivered_after_delay(): void + { + $queueName = Uuid::v7()->toRfc4122(); + $messaging = $this->bootstrapPublisher($queueName, asyncPublishing: true); + $publisher = $messaging->getGateway(MessagePublisher::class); + + $publishedAt = microtime(true); + $publisher->asyncPublish( + BatchMessage::constructEmpty() + ->append('immediate order') + ->append('delayed order', [MessageHeaders::DELIVERY_DELAY => 1000]) + )->resolve(); + + $channel = $messaging->getMessageChannel($queueName); + $receivedAt = []; + $deadline = microtime(true) + 15; + while (count($receivedAt) < 2 && microtime(true) < $deadline) { + if ($message = $channel->receive()) { + $receivedAt[$message->getPayload()] = microtime(true); + } else { + usleep(100000); + } + } + + $this->assertArrayHasKey('immediate order', $receivedAt); + $this->assertArrayHasKey('delayed order', $receivedAt); + $this->assertGreaterThanOrEqual(1.0, $receivedAt['delayed order'] - $publishedAt); + } + + private function createOrderService(): object + { + return new class () { + /** @var string[] */ + private array $receivedEvents = []; + + #[CommandHandler('order.place')] + public function placeOrder(string $order, EventBus $eventBus): void + { + $eventBus->publish(new OrderWasPlaced($order . '-1')); + $eventBus->publish(new OrderWasPlaced($order . '-2')); + $eventBus->publish(new OrderWasPlaced($order . '-3')); + } + + #[Asynchronous('asyncOrdersChannel')] + #[EventHandler(endpointId: 'async_sqs_order_collector')] + public function collect(OrderWasPlaced $event): void + { + $this->receivedEvents[] = $event->order; + } + + #[QueryHandler('order.getReceived')] + public function getReceived(): array + { + return $this->receivedEvents; + } + }; + } + + private function bootstrapEcotoneWithChannel(object $orderService, ?string $licenceKey): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + [$orderService::class], + [SqsConnectionFactory::class => $this->getConnectionFactory(), $orderService], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::SQS_PACKAGE])) + ->withExtensionObjects([ + SqsBackedMessageChannelBuilder::create('asyncOrdersChannel') + ->withAsyncPublishing(), + ]), + licenceKey: $licenceKey, + ); + } + + private function bootstrapPublisher(string $queueName, bool $asyncPublishing): FlowTestSupport + { + $publisherConfiguration = SqsMessagePublisherConfiguration::create(queueName: $queueName); + if ($asyncPublishing) { + $publisherConfiguration = $publisherConfiguration->withAsyncPublishing(); + } + + return EcotoneLite::bootstrapFlowTesting( + [], + [SqsConnectionFactory::class => $this->getConnectionFactory()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::SQS_PACKAGE])) + ->withExtensionObjects([ + $publisherConfiguration, + SqsBackedMessageChannelBuilder::create($queueName), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + } +} diff --git a/packages/Sqs/tests/Unit/SqsPendingDeliveryTest.php b/packages/Sqs/tests/Unit/SqsPendingDeliveryTest.php new file mode 100644 index 000000000..2b2a9f095 --- /dev/null +++ b/packages/Sqs/tests/Unit/SqsPendingDeliveryTest.php @@ -0,0 +1,111 @@ +build(); + $rejectedMessage = MessageBuilder::withPayload('rejected order')->build(); + $pendingDelivery = new SqsPendingDelivery( + [new FulfilledPromise(new Result([ + 'Successful' => [['Id' => '0', 'MessageId' => 'aws-message-id']], + 'Failed' => [['Id' => '1', 'Code' => 'InternalError', 'Message' => 'server hiccup', 'SenderFault' => false]], + ]))], + [['0' => $deliveredMessage, '1' => $rejectedMessage]], + 'orders', + ); + + $deliveryResult = $pendingDelivery->awaitDelivery(); + + $this->assertFalse($deliveryResult->isSuccessful()); + $failedDeliveries = $deliveryResult->getFailedDeliveries(); + $this->assertCount(1, $failedDeliveries); + $this->assertSame($rejectedMessage, $failedDeliveries[0]->getMessage()); + $this->assertStringContainsString('InternalError', $failedDeliveries[0]->getFailureReason()); + $this->assertSame('orders', $failedDeliveries[0]->getChannelName()); + } + + public function test_rejected_request_reports_all_messages_of_that_request_as_failed(): void + { + $firstMessage = MessageBuilder::withPayload('first order')->build(); + $secondMessage = MessageBuilder::withPayload('second order')->build(); + $pendingDelivery = new SqsPendingDelivery( + [new RejectedPromise(new RuntimeException('connection refused'))], + [['0' => $firstMessage, '1' => $secondMessage]], + 'orders', + ); + + $deliveryResult = $pendingDelivery->awaitDelivery(); + + $this->assertFalse($deliveryResult->isSuccessful()); + $this->assertCount(2, $deliveryResult->getFailedDeliveries()); + $this->assertStringContainsString('connection refused', $deliveryResult->getFailedDeliveries()[0]->getFailureReason()); + } + + public function test_entries_missing_from_successful_and_failed_lists_are_reported_as_failed(): void + { + $confirmedMessage = MessageBuilder::withPayload('confirmed order')->build(); + $unaccountedMessage = MessageBuilder::withPayload('unaccounted order')->build(); + $pendingDelivery = new SqsPendingDelivery( + [new FulfilledPromise(new Result([ + 'Successful' => [['Id' => '0', 'MessageId' => 'aws-message-id']], + ]))], + [['0' => $confirmedMessage, '1' => $unaccountedMessage]], + 'orders', + ); + + $deliveryResult = $pendingDelivery->awaitDelivery(); + + $this->assertFalse($deliveryResult->isSuccessful()); + $this->assertCount(1, $deliveryResult->getFailedDeliveries()); + $this->assertSame($unaccountedMessage, $deliveryResult->getFailedDeliveries()[0]->getMessage()); + } + + public function test_fully_confirmed_batch_reports_success_and_is_marked_as_awaited(): void + { + $pendingDelivery = new SqsPendingDelivery( + [new FulfilledPromise(new Result([ + 'Successful' => [['Id' => '0', 'MessageId' => 'first-id'], ['Id' => '1', 'MessageId' => 'second-id']], + ]))], + [['0' => MessageBuilder::withPayload('first order')->build(), '1' => MessageBuilder::withPayload('second order')->build()]], + 'orders', + ); + + $this->assertFalse($pendingDelivery->isAwaited()); + + $deliveryResult = $pendingDelivery->awaitDelivery(); + + $this->assertTrue($deliveryResult->isSuccessful()); + $this->assertTrue($pendingDelivery->isAwaited()); + } + + public function test_second_await_returns_memoized_result(): void + { + $pendingDelivery = new SqsPendingDelivery( + [new FulfilledPromise(new Result(['Successful' => [['Id' => '0', 'MessageId' => 'aws-message-id']]]))], + [['0' => MessageBuilder::withPayload('order')->build()]], + 'orders', + ); + + $firstResult = $pendingDelivery->awaitDelivery(); + $secondResult = $pendingDelivery->awaitDelivery(); + + $this->assertSame($firstResult, $secondResult); + } +} diff --git a/packages/Sqs/tests/Unit/SqsRequestDispatchPoolTest.php b/packages/Sqs/tests/Unit/SqsRequestDispatchPoolTest.php new file mode 100644 index 000000000..39b57f90b --- /dev/null +++ b/packages/Sqs/tests/Unit/SqsRequestDispatchPoolTest.php @@ -0,0 +1,107 @@ +dispatch($dispatcher); + $pool->dispatch($dispatcher); + $pool->dispatch($dispatcher); + + $this->assertSame(2, $dispatchedRequests); + } + + public function test_queued_request_is_dispatched_when_earlier_request_settles(): void + { + $pool = new SqsRequestDispatchPool(maxConcurrentRequests: 1); + $firstUnderlying = new Promise(); + $dispatchedSecond = false; + + $pool->dispatch(fn () => $firstUnderlying); + $pool->dispatch(function () use (&$dispatchedSecond) { + $dispatchedSecond = true; + + return new Promise(); + }); + + $this->assertFalse($dispatchedSecond); + + $firstUnderlying->resolve('confirmed'); + \GuzzleHttp\Promise\Utils::queue()->run(); + + $this->assertTrue($dispatchedSecond); + } + + public function test_proxy_resolves_with_underlying_value_and_rejects_with_underlying_reason(): void + { + $pool = new SqsRequestDispatchPool(maxConcurrentRequests: 2); + $fulfilledUnderlying = new Promise(); + $rejectedUnderlying = new Promise(); + + $fulfilledProxy = $pool->dispatch(fn () => $fulfilledUnderlying); + $rejectedProxy = $pool->dispatch(fn () => $rejectedUnderlying); + + $fulfilledUnderlying->resolve('confirmed'); + $rejectedUnderlying->reject(new RuntimeException('connection refused')); + \GuzzleHttp\Promise\Utils::queue()->run(); + + $this->assertSame(PromiseInterface::FULFILLED, $fulfilledProxy->getState()); + $this->assertSame('confirmed', $fulfilledProxy->wait()); + $this->assertSame(PromiseInterface::REJECTED, $rejectedProxy->getState()); + } + + public function test_synchronously_throwing_dispatcher_rejects_only_its_proxy_and_frees_the_slot(): void + { + $pool = new SqsRequestDispatchPool(maxConcurrentRequests: 1); + $dispatchedSecond = false; + + $throwingProxy = $pool->dispatch(fn () => throw new RuntimeException('curl init failed')); + $pool->dispatch(function () use (&$dispatchedSecond) { + $dispatchedSecond = true; + + return new Promise(); + }); + + $this->assertSame(PromiseInterface::REJECTED, $throwingProxy->getState()); + $this->assertTrue($dispatchedSecond); + } + + public function test_waiting_on_queued_proxy_drives_earlier_requests_until_slot_frees(): void + { + $pool = new SqsRequestDispatchPool(maxConcurrentRequests: 1); + $firstUnderlying = new Promise(function () use (&$firstUnderlying) { + $firstUnderlying->resolve('first confirmed'); + }); + $secondUnderlying = new Promise(function () use (&$secondUnderlying) { + $secondUnderlying->resolve('second confirmed'); + }); + + $pool->dispatch(fn () => $firstUnderlying); + $queuedProxy = $pool->dispatch(fn () => $secondUnderlying); + + $this->assertSame('second confirmed', $queuedProxy->wait()); + $this->assertSame(PromiseInterface::FULFILLED, $firstUnderlying->getState()); + } +} diff --git a/phpbench.json b/phpbench.json index ae44447e0..7d08e3e21 100644 --- a/phpbench.json +++ b/phpbench.json @@ -1,5 +1,5 @@ { - "$schema":"./vendor/phpbench/phpbench/phpbench.schema.json", + "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", "runner.bootstrap": "./vendor/autoload.php", "runner.file_pattern": "*Benchmark.php", "runner.path": "Monorepo/Benchmark", @@ -9,7 +9,8 @@ "opcache_disabled": { "runner.php_config": { "opcache.enable": 0, - "opcache.enable_cli": 0 + "opcache.enable_cli": 0, + "display_errors": "0" } }, "opcache_enabled": { @@ -20,15 +21,31 @@ "opcache.validate_timestamps": 0, "opcache.max_accelerated_files": 20000, "opcache.memory_consumption": 256, - "opcache.jit_buffer_size": "0" + "opcache.jit_buffer_size": "0", + "display_errors": "0" } } }, "report.generators": { "github-report": { "generator": "expression", - "aggregate": ["benchmark_class", "subject_name", "variant_name"], - "cols": ["benchmark", "subject", "revs", "its", "mem_peak", "mode", "rstdev"] + "aggregate": [ + "benchmark_class", + "subject_name", + "variant_name" + ], + "cols": [ + "benchmark", + "subject", + "revs", + "its", + "mem_peak", + "mode", + "rstdev" + ] } + }, + "runner.php_config": { + "display_errors": "0" } } \ No newline at end of file diff --git a/quickstart-examples/RefactorToReactiveSystem/run_example.php b/quickstart-examples/RefactorToReactiveSystem/run_example.php index e62ef78cd..045153b7d 100644 --- a/quickstart-examples/RefactorToReactiveSystem/run_example.php +++ b/quickstart-examples/RefactorToReactiveSystem/run_example.php @@ -34,5 +34,5 @@ ]))); if ($stageToRun !== 'Stage_1') { - $messagingSystem->run("asynchronous", ExecutionPollingMetadata::createWithDefaults()->withTestingSetup(2)); + $messagingSystem->run("asynchronous", ExecutionPollingMetadata::createWithDefaults()->withTestingSetup(amountOfMessagesToHandle: 2, maxExecutionTimeInMilliseconds: 60000)); } \ No newline at end of file