diff --git a/docs/docs/bridges/symfony-framework.rst b/docs/docs/bridges/symfony-framework.rst index 1f551d3e..e514ef53 100644 --- a/docs/docs/bridges/symfony-framework.rst +++ b/docs/docs/bridges/symfony-framework.rst @@ -16,11 +16,11 @@ you will be able to register these using configuration: # config/packages/yokai_batch.yaml yokai_batch: - launcher: - default: simple - launchers: - simple: ... - async: ... + launcher: + default: simple + launchers: + simple: ... + async: ... .. note:: If you do not configure anything here, you will be using the @@ -46,6 +46,31 @@ All ``launchers`` are configured using a DSN, every scheme has it’s own associ * ``service``: the id of the service to use (required, an exception will be thrown otherwise) + +| You might define multiple job launchers, and will want to configure the relation between job and launcher. +| For instance, you might prefer running some jobs with an async job launcher, but not all. +| You can configure this routing like the following: + +.. code-block:: yaml + + # config/packages/yokai_batch.yaml + yokai_batch: + launcher: + default: simple + launchers: + simple: simple://simple + console: console://console + routing: + export_job_name: simple + import_job_name: console + +.. note:: + It is not required to configure every single job in the ``routing``. + The ``default`` will be the fallback for all jobs you did not not configured in the ``routing``. + +.. note:: + If you configure ``config.launcher.routing``, it will replace your configured default from autowiring perspective. + .. seealso:: | :doc:`What is a job launcher? ` @@ -64,12 +89,12 @@ You can have only one storage for your ``JobExecution``, and you have several op # config/packages/yokai_batch.yaml yokai_batch: - storage: - filesystem: ~ - # Or with yokai/batch-doctrine-dbal (& doctrine/dbal) - # dbal: ~ - # Or with a service of yours - # service: ~ + storage: + filesystem: ~ + # Or with yokai/batch-doctrine-dbal (& doctrine/dbal) + # dbal: ~ + # Or with a service of yours + # service: ~ .. note:: | The default storage is ``filesystem``, because it only requires a writeable filesystem. @@ -98,11 +123,11 @@ You can configure what your id will be like: # config/packages/yokai_batch.yaml yokai_batch: - id: uniqid - # Or with yokai/batch-symfony-uid (& symfony/uid) - # id: symfony.uuid.random - # id: symfony.uuid.time - # id: symfony.ulid + id: uniqid + # Or with yokai/batch-symfony-uid (& symfony/uid) + # id: symfony.uuid.random + # id: symfony.uuid.time + # id: symfony.ulid User interface to visualize ``JobExecution`` ------------------------------------------------------------ diff --git a/docs/docs/bridges/symfony-messenger.rst b/docs/docs/bridges/symfony-messenger.rst index 9d40ef4b..31c7a73b 100644 --- a/docs/docs/bridges/symfony-messenger.rst +++ b/docs/docs/bridges/symfony-messenger.rst @@ -61,11 +61,11 @@ and will not want to run all jobs on the same transport. # config/packages/yokai_batch.yaml yokai_batch: - launchers: - messenger: - routing: - export_job_name: async_with_low_priority - import_job_name: async_with_high_priority + launchers: + messenger: + routing: + export_job_name: async_with_low_priority + import_job_name: async_with_high_priority .. seealso:: | :doc:`What is a job launcher? ` diff --git a/docs/docs/core-concepts/job-launcher.rst b/docs/docs/core-concepts/job-launcher.rst index 55855d11..dfa1c8fd 100644 --- a/docs/docs/core-concepts/job-launcher.rst +++ b/docs/docs/core-concepts/job-launcher.rst @@ -65,6 +65,9 @@ What types of launcher exists? * `SimpleJobLauncher `__: execute the job directly in the same PHP process. +* `RoutingJobLauncher `__: + pick the appropriate job launcher for each job, based on the configuration you provide. + **Launchers from bridges:** * From ``symfony/console`` bridge: diff --git a/src/batch-symfony-framework/src/DependencyInjection/Configuration.php b/src/batch-symfony-framework/src/DependencyInjection/Configuration.php index ce9eac78..0944def9 100644 --- a/src/batch-symfony-framework/src/DependencyInjection/Configuration.php +++ b/src/batch-symfony-framework/src/DependencyInjection/Configuration.php @@ -33,6 +33,7 @@ * @phpstan-type LauncherConfig array{ * default: string|null, * launchers: array, + * routing?: array, * messenger?: array{ * routing: array, * }, @@ -138,6 +139,11 @@ private function launcher(): ArrayNodeDefinition ->end() ->end() ->end() + ->arrayNode('routing') + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->scalarPrototype()->end() + ->end() ->arrayNode('messenger') ->children() ->arrayNode('routing') diff --git a/src/batch-symfony-framework/src/DependencyInjection/JobLauncherDefinitionFactory.php b/src/batch-symfony-framework/src/DependencyInjection/JobLauncherDefinitionFactory.php index 5b95fd4d..f023c702 100644 --- a/src/batch-symfony-framework/src/DependencyInjection/JobLauncherDefinitionFactory.php +++ b/src/batch-symfony-framework/src/DependencyInjection/JobLauncherDefinitionFactory.php @@ -4,6 +4,8 @@ namespace Yokai\Batch\Bridge\Symfony\Framework\DependencyInjection; +use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Reference; @@ -13,6 +15,7 @@ use Yokai\Batch\Bridge\Symfony\Messenger\DispatchMessageJobLauncher; use Yokai\Batch\Bridge\Symfony\Messenger\MessengerJobsConfiguration; use Yokai\Batch\Launcher\JobLauncherInterface; +use Yokai\Batch\Launcher\RoutingJobLauncher; use Yokai\Batch\Launcher\SimpleJobLauncher; use Yokai\Batch\Storage\JobExecutionStorageInterface; @@ -40,6 +43,25 @@ public static function fromDsn(string $dsn): Definition|Reference }; } + /** + * Create the {@see RoutingJobLauncher} service definition when has configuration for it. + * + * @param array $launchers + * @param array $routing + */ + public static function routing( + ContainerBuilder $container, + array $launchers, + string $default, + array $routing, + ): Definition { + return new Definition(RoutingJobLauncher::class, [ + '$launchers' => ServiceLocatorTagPass::register($container, $launchers), + '$default' => new Reference($default), + '$routing' => $routing, + ]); + } + private static function simple(): Definition { return new Definition(SimpleJobLauncher::class, [ diff --git a/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php b/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php index d4cc5a7b..5b460547 100644 --- a/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php +++ b/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php @@ -146,7 +146,7 @@ private function configureLauncher(ContainerBuilder $container, array $config): $container->setParameter('yokai_batch.launcher.messenger_routing', $config['messenger']['routing'] ?? []); - $launcherIdPerLauncherName = []; + $idPerLauncherName = []; foreach ($config['launchers'] as $name => $dsn) { $definitionOrReference = JobLauncherDefinitionFactory::fromDsn($dsn); if ($definitionOrReference instanceof Definition) { @@ -156,15 +156,23 @@ private function configureLauncher(ContainerBuilder $container, array $config): $launcherId = (string)$definitionOrReference; } - $launcherIdPerLauncherName[$name] = $launcherId; + $idPerLauncherName[$name] = $launcherId; $parameterName = $name . 'JobLauncher'; $container->registerAliasForArgument($launcherId, JobLauncherInterface::class, $parameterName); } - $container->setAlias( - JobLauncherInterface::class, - $launcherIdPerLauncherName[$config['default']], - ); + $default = $idPerLauncherName[$config['default']]; + + $routing = $config['routing'] ?? []; + if ($routing !== []) { + $container->setDefinition( + $launcherId = 'yokai_batch.job_launcher.routing', + JobLauncherDefinitionFactory::routing($container, $idPerLauncherName, $default, $routing), + ); + $default = $launcherId; + } + + $container->setAlias(JobLauncherInterface::class, $default); } /** diff --git a/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php b/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php index fd336edb..db9e9012 100644 --- a/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php +++ b/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php @@ -30,6 +30,7 @@ use Yokai\Batch\Factory\JobExecutionParametersBuilderInterface; use Yokai\Batch\Factory\UniqidJobExecutionIdGenerator; use Yokai\Batch\Launcher\JobLauncherInterface; +use Yokai\Batch\Launcher\RoutingJobLauncher; use Yokai\Batch\Launcher\SimpleJobLauncher; use Yokai\Batch\Storage\FilesystemJobExecutionStorage; use Yokai\Batch\Storage\JobExecutionStorageInterface; @@ -77,14 +78,23 @@ public function storage(): \Generator /** * @dataProvider launcher */ - public function testLauncher(array $config, \Closure|null $configure, array $launchers, string $default): void - { + public function testLauncher( + array $config, + \Closure|null $configure, + array $launchers, + string $default, + \Closure|null $assert = null, + ): void { $container = $this->createContainer($config, $configure); self::assertSame($default, (string)$container->getAlias(JobLauncherInterface::class)); foreach ($launchers as $id => $class) { self::assertSame($class, $container->getDefinition($id)->getClass()); } + + if ($assert) { + $assert($container); + } } public function launcher(): \Generator @@ -112,6 +122,43 @@ public function launcher(): \Generator ], 'yokai_batch.job_launcher.messenger', ]; + yield 'Messenger launcher routing' => [ + [ + 'launcher' => [ + 'default' => 'messenger', + 'launchers' => [ + 'messenger' => 'messenger://messenger', + ], + 'messenger' => [ + 'routing' => [ + 'job1' => 'async', + 'job2' => 'sync', + ], + ], + ], + ], + null, + [ + 'yokai_batch.job_launcher.messenger' => DispatchMessageJobLauncher::class, + ], + 'yokai_batch.job_launcher.messenger', + function (ContainerBuilder $container) { + $messengerJobsConfiguration = $container->getDefinition('yokai_batch.job_launcher.messenger') + ->getArgument('$messengerJobsConfiguration'); + self::assertInstanceOf(Definition::class, $messengerJobsConfiguration); + self::assertSame( + '%yokai_batch.launcher.messenger_routing%', + $messengerJobsConfiguration->getArgument('$routing'), + ); + self::assertSame( + [ + 'job1' => 'async', + 'job2' => 'sync', + ], + $container->getParameter('yokai_batch.launcher.messenger_routing'), + ); + }, + ]; yield 'Service launcher' => [ [ 'launcher' => [ @@ -128,6 +175,39 @@ public function launcher(): \Generator ['app.job_launcher' => BufferingJobLauncher::class], 'app.job_launcher', ]; + yield 'Routing launcher' => [ + [ + 'launcher' => [ + 'default' => 'simple', + 'launchers' => [ + 'simple' => 'simple://simple', + 'messenger' => 'messenger://messenger', + 'console' => 'console://console', + ], + 'routing' => [ + 'job1' => 'messenger', + 'job2' => 'console', + ], + ], + ], + null, + [ + 'yokai_batch.job_launcher.simple' => SimpleJobLauncher::class, + 'yokai_batch.job_launcher.messenger' => DispatchMessageJobLauncher::class, + 'yokai_batch.job_launcher.console' => RunCommandJobLauncher::class, + 'yokai_batch.job_launcher.routing' => RoutingJobLauncher::class, + ], + 'yokai_batch.job_launcher.routing', + function (ContainerBuilder $container) { + self::assertSame( + [ + 'job1' => 'messenger', + 'job2' => 'console', + ], + $container->getDefinition('yokai_batch.job_launcher.routing')->getArgument('$routing'), + ); + }, + ]; } /** diff --git a/src/batch/src/Launcher/RoutingJobLauncher.php b/src/batch/src/Launcher/RoutingJobLauncher.php new file mode 100644 index 00000000..63739dd2 --- /dev/null +++ b/src/batch/src/Launcher/RoutingJobLauncher.php @@ -0,0 +1,46 @@ + + */ + private array $routing, + ) { + } + + public function launch(string $name, array $configuration = []): JobExecution + { + $launcherName = $this->routing[$name] ?? null; + if ($launcherName === null) { + $launcher = $this->default; + } else { + $launcher = $this->launchers->get($launcherName); + if (!$launcher instanceof JobLauncherInterface) { + throw UnexpectedValueException::type(JobLauncherInterface::class, $launcher); + } + } + + return $launcher->launch($name, $configuration); + } +} diff --git a/src/batch/src/Test/ArrayContainer.php b/src/batch/src/Test/ArrayContainer.php new file mode 100644 index 00000000..7f44fa1c --- /dev/null +++ b/src/batch/src/Test/ArrayContainer.php @@ -0,0 +1,40 @@ + + */ + private array $container, + ) { + } + + public function get(string $id): mixed + { + if (!isset($this->container[$id])) { + $message = \sprintf('You have requested a non-existent container entry "%s".', $id); + + throw new class($message) extends Exception implements NotFoundExceptionInterface { + }; + } + + return $this->container[$id]; + } + + public function has(string $id): bool + { + return isset($this->container[$id]); + } +} diff --git a/src/batch/tests/Launcher/RoutingJobLauncherTest.php b/src/batch/tests/Launcher/RoutingJobLauncherTest.php new file mode 100644 index 00000000..306f41ff --- /dev/null +++ b/src/batch/tests/Launcher/RoutingJobLauncherTest.php @@ -0,0 +1,80 @@ + $launcher1, + 'launcher2' => $launcher2, + ]), + $defaultLauncher, + [ + 'job1' => 'launcher1', + 'job2' => 'launcher2', + ], + ); + + $executionJob1 = $launcher->launch('job1'); + self::assertSame('job1', $executionJob1->getJobName()); + self::assertSame('123', $executionJob1->getId()); + $executionJob2 = $launcher->launch('job2'); + self::assertSame('job2', $executionJob2->getJobName()); + self::assertSame('abc', $executionJob2->getId()); + $executionJob2 = $launcher->launch('job3'); + self::assertSame('job3', $executionJob2->getJobName()); + self::assertSame('def1', $executionJob2->getId()); + self::assertCount(1, $launcher1->getExecutions()); + self::assertCount(1, $launcher2->getExecutions()); + self::assertCount(1, $defaultLauncher->getExecutions()); + } + + public function testConfiguredWithUnknownJobLauncher(): void + { + $launcher = new RoutingJobLauncher( + new ArrayContainer([ + 'launcher1' => new BufferingJobLauncher(new SequenceJobExecutionIdGenerator(['123'])), + ]), + new BufferingJobLauncher(new SequenceJobExecutionIdGenerator(['def1'])), + [ + 'job1' => 'unknown_launcher', + ], + ); + + self::expectException(NotFoundExceptionInterface::class); + $launcher->launch('job1'); + } + + public function testConfiguredWithNoJobLauncher(): void + { + $launcher = new RoutingJobLauncher( + new ArrayContainer([ + 'launcher1' => new \DateTime(), + ]), + new BufferingJobLauncher(new SequenceJobExecutionIdGenerator(['def1'])), + [ + 'job1' => 'launcher1', + ], + ); + + self::expectException(UnexpectedValueException::class); + $launcher->launch('job1'); + } +} diff --git a/src/batch/tests/Test/ArrayContainerTest.php b/src/batch/tests/Test/ArrayContainerTest.php new file mode 100644 index 00000000..acbae1ac --- /dev/null +++ b/src/batch/tests/Test/ArrayContainerTest.php @@ -0,0 +1,37 @@ + 'FOO', 'bar' => 'BAR']); + + self::assertSame('FOO', $container->get('foo')); + self::assertSame('BAR', $container->get('bar')); + } + + public function testGetNotFound(): void + { + $container = new ArrayContainer(['foo' => 'FOO', 'bar' => 'BAR']); + + self::expectException(NotFoundExceptionInterface::class); + $container->get('baz'); + } + + public function testHas(): void + { + $container = new ArrayContainer(['foo' => 'FOO', 'bar' => 'BAR']); + + self::assertSame(true, $container->has('foo')); + self::assertSame(true, $container->has('bar')); + self::assertSame(false, $container->has('baz')); + } +}