diff --git a/src/batch-symfony-framework/composer.json b/src/batch-symfony-framework/composer.json index 48871e9f..b22f9191 100644 --- a/src/batch-symfony-framework/composer.json +++ b/src/batch-symfony-framework/composer.json @@ -38,6 +38,7 @@ "suggest": { "sonata-project/admin-bundle": "If you want a SonataAdmin like rendering in the user interface", "symfony/form": "If you want the JobExecution form filter in the user interface", + "symfony/messenger": "If you to launch jobs via messenger", "symfony/security-bundle": "If you want to secure the access to JobExecution in the user interface", "symfony/translation": "Required if you want to enable the user interface", "symfony/twig-bundle": "Required if you want to enable the user interface" diff --git a/src/batch-symfony-framework/docs/getting-started.md b/src/batch-symfony-framework/docs/getting-started.md index 153b4415..60f625e1 100644 --- a/src/batch-symfony-framework/docs/getting-started.md +++ b/src/batch-symfony-framework/docs/getting-started.md @@ -10,10 +10,57 @@ return [ ]; ``` -There is few things that can be configured in the bundle at the moment. -But the most important one will be the `JobExecution` storage: +### Job launcher + +You can use many different job launcher in your application, you will be able to register these using configuration: + +```yaml +# config/packages/yokai_batch.yaml +yokai_batch: + launcher: + default: simple + launchers: + simple: ... + async: ... +``` + +> **note**: if you do not configure anything here, you will be using the [`SimpleJobLauncher`](https://github.com/yokai-php/batch/blob/0.x/src/Launcher/SimpleJobLauncher.php). + +The `default` job launcher, must reference a launcher name, defined in the `launchers` list. +The `default` job launcher will be the autowired instance of job launcher when you ask for one. +All `launchers` will be registered as a service, and an autowire named alias will be configured for it. +For instance, in the example below, you will be able to register all these launchers like this: + +```php + **note**: the default storage is `filesystem`, because it only requires a writeable filesystem. > But if you already have `doctrine/dbal` in your project, it is highly recommended to use it instead. > Because querying `JobExecution` in a filesystem might be slow, specially if you are planing to add UIs on top. +### Job as a service + As Symfony supports registering all classes in `src/` as a service, we can leverage this behaviour to register all jobs in `src/`. We will add a tag to every found class in `src/` that implements `Yokai\Batch\Job\JobInterface`: @@ -88,13 +139,13 @@ use Yokai\Batch\Storage\JobExecutionStorageInterface; final class MyClass { public function __construct( - private JobLauncherInterface $executionStorage, + private JobLauncherInterface $jobLauncher, ) { } public function method(): void { - $this->launcher->launch('job.import_users'); + $this->jobLauncher->launch('job.name'); } } ``` @@ -122,14 +173,13 @@ final readonly class YourService private LoggerInterface $yokaiBatchLogger, ) { } - + public function method() { $this->yokaiBatchLogger->error(...); } } ``` -``` ## On the same subject diff --git a/src/batch-symfony-framework/src/DependencyInjection/Configuration.php b/src/batch-symfony-framework/src/DependencyInjection/Configuration.php index 92e1779b..da4a56f0 100644 --- a/src/batch-symfony-framework/src/DependencyInjection/Configuration.php +++ b/src/batch-symfony-framework/src/DependencyInjection/Configuration.php @@ -13,6 +13,7 @@ * * @phpstan-type Config array{ * storage: StorageConfig, + * launcher: LauncherConfig, * ui: UserInterfaceConfig, * } * @phpstan-type StorageConfig array{ @@ -26,6 +27,10 @@ * dir: string, * }, * } + * @phpstan-type LauncherConfig array{ + * default: string|null, + * launchers: array, + * } * @phpstan-type UserInterfaceConfig array{ * enabled: bool, * security: array{ @@ -53,6 +58,7 @@ public function getConfigTreeBuilder(): TreeBuilder $root ->children() ->append($this->storage()) + ->append($this->launcher()) ->append($this->ui()) ->end() ; @@ -97,6 +103,34 @@ private function storage(): ArrayNodeDefinition return $node; } + private function launcher(): ArrayNodeDefinition + { + /** @var ArrayNodeDefinition $node */ + $node = (new TreeBuilder('launcher'))->getRootNode(); + + $isInvalidDsn = fn(string $value) => \parse_url($value) === false + || (\parse_url($value)['scheme'] ?? null) === null; + + $node + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('default') + ->defaultValue('simple') + ->end() + ->arrayNode('launchers') + ->defaultValue(['simple' => 'simple://simple']) + ->useAttributeAsKey('name') + ->scalarPrototype() + ->validate() + ->ifTrue($isInvalidDsn)->thenInvalid('Invalid job launcher DSN.') + ->end() + ->end() + ->end() + ; + + return $node; + } + private function ui(): ArrayNodeDefinition { /** @var ArrayNodeDefinition $node */ diff --git a/src/batch-symfony-framework/src/DependencyInjection/JobLauncherDefinitionFactory.php b/src/batch-symfony-framework/src/DependencyInjection/JobLauncherDefinitionFactory.php new file mode 100644 index 00000000..a0524fe5 --- /dev/null +++ b/src/batch-symfony-framework/src/DependencyInjection/JobLauncherDefinitionFactory.php @@ -0,0 +1,89 @@ + $launcherConfig */ + + return match ($launcherType) { + 'simple' => self::simple(), + 'console' => self::console($launcherConfig), + 'messenger' => self::messenger(), + 'service' => self::service($container, $launcherConfig), + default => throw new LogicException('Unsupported job launcher type "' . $launcherType . '".'), + }; + } + + private static function simple(): Definition + { + return new Definition(SimpleJobLauncher::class, [ + '$jobExecutionAccessor' => new Reference('yokai_batch.job_execution_accessor'), + '$jobExecutor' => new Reference('yokai_batch.job_executor'), + ]); + } + + /** + * @param array $config + */ + private static function console(array $config): Definition + { + $log = $config['log'] ?? 'batch_execute.log'; + + return new Definition(RunCommandJobLauncher::class, [ + '$jobExecutionFactory' => new Reference('yokai_batch.job_execution_factory'), + '$commandRunner' => new Definition(CommandRunner::class, [ + '$binDir' => '%kernel.project_dir%/bin', + '$logDir' => '%kernel.logs_dir%', + ]), + '$jobExecutionStorage' => new Reference(JobExecutionStorageInterface::class), + '$logFilename' => $log, + ]); + } + + private static function messenger(): Definition + { + return new Definition(DispatchMessageJobLauncher::class, [ + '$jobExecutionFactory' => new Reference('yokai_batch.job_execution_factory'), + '$jobExecutionStorage' => new Reference(JobExecutionStorageInterface::class), + '$messageBus' => new Reference(MessageBusInterface::class), + ]); + } + + /** + * @param array $config + */ + private static function service(ContainerBuilder $container, array $config): Definition + { + $service = $config['service'] ?? throw new LogicException( + 'Missing "service" parameter to configure the job launcher.', + ); + + return $container->getDefinition($service); + } +} diff --git a/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php b/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php index 70d2ab0a..b5d087cc 100644 --- a/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php +++ b/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php @@ -33,6 +33,7 @@ * * @phpstan-import-type Config from Configuration * @phpstan-import-type StorageConfig from Configuration + * @phpstan-import-type LauncherConfig from Configuration * @phpstan-import-type UserInterfaceConfig from Configuration */ final class YokaiBatchExtension extends Extension @@ -62,16 +63,9 @@ public function load(array $configs, ContainerBuilder $container): void } $this->configureStorage($container, $config['storage']); + $this->configureLauncher($container, $config['launcher']); $this->configureUserInterface($container, $loader, $config['ui']); - $launchers = [ - 'yokai_batch.job_launcher.dispatch_message' => $this->installed('symfony-messenger'), - 'yokai_batch.job_launcher.run_command' => $this->installed('symfony-console'), - ]; - $container->setAlias( - JobLauncherInterface::class, - \array_keys(\array_filter($launchers))[0] ?? 'yokai_batch.job_launcher.simple' - ); $container->registerAliasForArgument('yokai_batch.logger', LoggerInterface::class, 'yokaiBatchLogger'); } @@ -168,6 +162,33 @@ private function configureStorage(ContainerBuilder $container, array $config): v } } + /** + * @param LauncherConfig $config + */ + private function configureLauncher(ContainerBuilder $container, array $config): void + { + if (!isset($config['launchers'][$config['default']])) { + throw new LogicException(\sprintf( + "Default job launcher \"%s\" was not registered in launchers config. Available launchers are %s.", + $config['default'], + \json_encode(\array_keys($config['launchers']), flags: \JSON_THROW_ON_ERROR), + )); + } + + foreach ($config['launchers'] as $name => $dsn) { + $definition = JobLauncherDefinitionFactory::fromDsn($container, $dsn); + $launcherId = 'yokai_batch.job_launcher.' . $name; + $container->setDefinition($launcherId, $definition); + $parameterName = $name . 'JobLauncher'; + $container->registerAliasForArgument($launcherId, LoggerInterface::class, $parameterName); + } + + $container->setAlias( + JobLauncherInterface::class, + 'yokai_batch.job_launcher.' . $config['default'], + ); + } + /** * @param UserInterfaceConfig $config */ diff --git a/src/batch-symfony-framework/src/Resources/services/global/launcher.xml b/src/batch-symfony-framework/src/Resources/services/global/launcher.xml deleted file mode 100644 index fb396e5b..00000000 --- a/src/batch-symfony-framework/src/Resources/services/global/launcher.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/batch-symfony-framework/src/Resources/services/symfony/console/launcher.xml b/src/batch-symfony-framework/src/Resources/services/symfony/console/launcher.xml deleted file mode 100644 index 2ed9fbde..00000000 --- a/src/batch-symfony-framework/src/Resources/services/symfony/console/launcher.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - batch_execute.log - - - - - - - - - - %yokai_batch.console.output_log_filename% - - - diff --git a/src/batch-symfony-framework/src/Resources/services/symfony/console/util.xml b/src/batch-symfony-framework/src/Resources/services/symfony/console/util.xml deleted file mode 100644 index 985c8fdf..00000000 --- a/src/batch-symfony-framework/src/Resources/services/symfony/console/util.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - %kernel.project_dir%/bin - %kernel.logs_dir% - - - diff --git a/src/batch-symfony-framework/src/Resources/services/symfony/messenger/launcher.xml b/src/batch-symfony-framework/src/Resources/services/symfony/messenger/launcher.xml deleted file mode 100644 index 15f202f5..00000000 --- a/src/batch-symfony-framework/src/Resources/services/symfony/messenger/launcher.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php b/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php index 85ba6539..15ace87c 100644 --- a/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php +++ b/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php @@ -6,16 +6,22 @@ use Exception; use PHPUnit\Framework\TestCase; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; +use Yokai\Batch\Bridge\Symfony\Console\RunCommandJobLauncher; use Yokai\Batch\Bridge\Symfony\Framework\DependencyInjection\YokaiBatchExtension; use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\ConfigurableTemplating; use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\SonataAdminTemplating; use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\TemplatingInterface; +use Yokai\Batch\Bridge\Symfony\Messenger\DispatchMessageJobLauncher; use Yokai\Batch\Launcher\JobLauncherInterface; +use Yokai\Batch\Launcher\SimpleJobLauncher; use Yokai\Batch\Storage\JobExecutionStorageInterface; use Yokai\Batch\Storage\NullJobExecutionStorage; +use Yokai\Batch\Test\Launcher\BufferingJobLauncher; class YokaiBatchExtensionTest extends TestCase { @@ -26,6 +32,7 @@ public function test( array $config, ?callable $configure, string $storage, + ?string $launcher, ?callable $templating, ?array $security ): void { @@ -36,9 +43,10 @@ public function test( (new YokaiBatchExtension())->load([$config], $container); + $launcherActualService = (string)$container->getAlias(JobLauncherInterface::class); self::assertSame( - 'yokai_batch.job_launcher.dispatch_message', - (string)$container->getAlias(JobLauncherInterface::class) + $launcher ?? SimpleJobLauncher::class, + $container->getDefinition($launcherActualService)->getClass() ); self::assertSame( $storage, @@ -81,6 +89,7 @@ public function configs(): \Generator 'yokai_batch.storage.filesystem', null, null, + null, ]; yield [ ['storage' => ['filesystem' => null]], @@ -88,6 +97,7 @@ public function configs(): \Generator 'yokai_batch.storage.filesystem', null, null, + null, ]; yield [ ['storage' => ['dbal' => null]], @@ -95,6 +105,7 @@ public function configs(): \Generator 'yokai_batch.storage.dbal', null, null, + null, ]; yield [ ['storage' => ['service' => 'app.yokai_batch.storage']], @@ -105,11 +116,13 @@ public function configs(): \Generator 'app.yokai_batch.storage', null, null, + null, ]; yield [ ['ui' => ['enabled' => true]], null, 'yokai_batch.storage.filesystem', + null, function (Definition $templating) { self::assertSame(ConfigurableTemplating::class, $templating->getClass()); self::assertSame('@YokaiBatch/bootstrap4', $templating->getArgument(0)); @@ -126,6 +139,7 @@ function (Definition $templating) { ['ui' => ['enabled' => true, 'templating' => 'bootstrap4']], null, 'yokai_batch.storage.filesystem', + null, function (Definition $templating) { self::assertSame(ConfigurableTemplating::class, $templating->getClass()); self::assertSame('@YokaiBatch/bootstrap4', $templating->getArgument(0)); @@ -147,6 +161,7 @@ function (Definition $templating) { ], null, 'yokai_batch.storage.filesystem', + null, function (Definition $templating) { self::assertSame(ConfigurableTemplating::class, $templating->getClass()); self::assertSame('yokai-batch/tailwind', $templating->getArgument(0)); @@ -166,6 +181,7 @@ function (Definition $templating) { ConfigurableTemplating::class, ), 'yokai_batch.storage.filesystem', + null, function (Definition $templating, string $id) { self::assertSame($id, 'app.yokai_batch_templating'); }, @@ -193,6 +209,7 @@ function (Definition $templating, string $id) { ], null, 'yokai_batch.storage.filesystem', + null, function (Definition $templating) { self::assertSame(SonataAdminTemplating::class, $templating->getClass()); }, @@ -203,6 +220,69 @@ function (Definition $templating) { 'logs' => 'ROLE_SUPERADMIN', ], ]; + yield [ + [ + 'launcher' => [ + 'default' => 'simple', + 'launchers' => [ + 'simple' => 'simple://simple', + ], + ], + ], + null, + 'yokai_batch.storage.filesystem', + SimpleJobLauncher::class, + null, + null, + ]; + yield [ + [ + 'launcher' => [ + 'default' => 'console', + 'launchers' => [ + 'console' => 'console://console', + ], + ], + ], + null, + 'yokai_batch.storage.filesystem', + RunCommandJobLauncher::class, + null, + null, + ]; + yield [ + [ + 'launcher' => [ + 'default' => 'messenger', + 'launchers' => [ + 'messenger' => 'messenger://messenger', + ], + ], + ], + null, + 'yokai_batch.storage.filesystem', + DispatchMessageJobLauncher::class, + null, + null, + ]; + yield [ + [ + 'launcher' => [ + 'default' => 'service', + 'launchers' => [ + 'service' => 'service://service?service=app.job_launcher', + ], + ], + ], + fn(ContainerBuilder $container) => $container->register( + 'app.job_launcher', + BufferingJobLauncher::class, + ), + 'yokai_batch.storage.filesystem', + BufferingJobLauncher::class, + null, + null, + ]; } /** @@ -275,5 +355,36 @@ public function errors(): \Generator ' "Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\TemplatingInterface".', ), ]; + yield 'Job Launcher : Empty DSN' => [ + ['launcher' => ['default' => 'invalid', 'launchers' => ['invalid' => '']]], + null, + new InvalidConfigurationException( + 'Invalid configuration for path "yokai_batch.launcher.launchers.invalid": Invalid job launcher DSN.' + ), + ]; + yield 'Job Launcher : Invalid DSN' => [ + ['launcher' => ['default' => 'invalid', 'launchers' => ['invalid' => 'not a DSN']]], + null, + new InvalidConfigurationException( + 'Invalid configuration for path "yokai_batch.launcher.launchers.invalid": Invalid job launcher DSN.' + ), + ]; + yield 'Job Launcher : Unregistered launcher' => [ + ['launcher' => ['default' => 'unknown', 'launchers' => ['simple' => 'simple://simple']]], + null, + new LogicException( + 'Default job launcher "unknown" was not registered in launchers config. Available launchers are ["simple"].' + ), + ]; + yield 'Job Launcher : Unsupported launcher type' => [ + ['launcher' => ['default' => 'invalid', 'launchers' => ['invalid' => 'unknown://unknown']]], + null, + new LogicException('Unsupported job launcher type "unknown".'), + ]; + yield 'Job Launcher : Unknown service' => [ + ['launcher' => ['default' => 'service', 'launchers' => ['service' => 'service://service?service=app.unknown']]], + null, + new ServiceNotFoundException('app.unknown'), + ]; } } diff --git a/tests/symfony/src/Kernel.php b/tests/symfony/src/Kernel.php index f795324c..a6402dec 100644 --- a/tests/symfony/src/Kernel.php +++ b/tests/symfony/src/Kernel.php @@ -19,6 +19,7 @@ use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; use Yokai\Batch\Bridge\Symfony\Framework\YokaiBatchBundle; use Yokai\Batch\Job\JobInterface; +use Yokai\Batch\Launcher\JobLauncherInterface; final class Kernel extends BaseKernel implements CompilerPassInterface { @@ -130,6 +131,6 @@ protected function configureRoutes(RoutingConfigurator $routes): void public function process(ContainerBuilder $container): void { - $container->getDefinition('yokai_batch.job_launcher.simple')->setPublic(true); + $container->getAlias(JobLauncherInterface::class)->setPublic(true); } } diff --git a/tests/symfony/tests/JobTest.php b/tests/symfony/tests/JobTest.php index 0f2834b3..615d8e38 100644 --- a/tests/symfony/tests/JobTest.php +++ b/tests/symfony/tests/JobTest.php @@ -50,7 +50,7 @@ public function testUsingLauncher(string $job, callable $assert, callable $setup } /** @var JobLauncherInterface $launcher */ - $launcher = $container->get('yokai_batch.job_launcher.simple'); + $launcher = $container->get(JobLauncherInterface::class); $execution = $launcher->launch($job, $config ?? []); diff --git a/tests/symfony/tests/UserInterfaceTest.php b/tests/symfony/tests/UserInterfaceTest.php index a3c912d6..7f3f286e 100644 --- a/tests/symfony/tests/UserInterfaceTest.php +++ b/tests/symfony/tests/UserInterfaceTest.php @@ -30,7 +30,7 @@ public static function setUpBeforeClass(): void $container = self::getContainer(); /** @var JobLauncherInterface $launcher */ - $launcher = $container->get('yokai_batch.job_launcher.simple'); + $launcher = $container->get(JobLauncherInterface::class); /** @var array $set */ foreach (JobTest::configs() as $set) { /**