diff --git a/composer.json b/composer.json index 2fe43a17..7543cb25 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "SchedulerBundle\\Test\\Constraint\\Scheduler\\": "src/Test/Constraint/Scheduler/", "SchedulerBundle\\Transport\\": "src/Transport/", "SchedulerBundle\\Transport\\Configuration\\": "src/Transport/Configuration/", + "SchedulerBundle\\Trigger\\": "src/Trigger/", "SchedulerBundle\\Worker\\": "src/Worker/" } }, @@ -88,6 +89,7 @@ "Tests\\SchedulerBundle\\Transport\\": "tests/Transport/", "Tests\\SchedulerBundle\\Transport\\Assets\\": "tests/Transport/Assets/", "Tests\\SchedulerBundle\\Transport\\Configuration\\": "tests/Transport/Configuration/", + "Tests\\SchedulerBundle\\Trigger\\": "tests/Trigger/", "Tests\\SchedulerBundle\\Worker\\": "tests/Worker/", "Tests\\SchedulerBundle\\Worker\\Assets\\": "tests/Worker/Assets/" } @@ -142,11 +144,14 @@ "symfony/framework-bundle": "^5.2", "symfony/http-client": "^5.2", "symfony/http-kernel": "^5.2", + "symfony/mailer": "^5.2", "symfony/mercure": "^0.5.3", "symfony/messenger": "^5.2", "symfony/notifier": "^5.2", "symfony/phpunit-bridge": "^5.2", "symfony/rate-limiter": "^5.2", + "symfony/translation": "^5.2", + "symfony/twig-bundle": "^5.3", "thecodingmachine/safe": "^1.3.3" }, "suggest": { diff --git a/src/DependencyInjection/SchedulerBundleConfiguration.php b/src/DependencyInjection/SchedulerBundleConfiguration.php index 11eed084..7251fb61 100644 --- a/src/DependencyInjection/SchedulerBundleConfiguration.php +++ b/src/DependencyInjection/SchedulerBundleConfiguration.php @@ -89,6 +89,64 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->arrayNode('triggers') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('enabled') + ->info('Enable the triggers support') + ->defaultValue(false) + ->end() + ->arrayNode('email') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('enabled') + ->info('Enable the email triggers') + ->defaultValue(false) + ->end() + ->arrayNode('on_failure') + ->children() + ->scalarNode('triggered_at') + ->info('The amount of task that need to fail before the email is sent') + ->defaultValue(1) + ->end() + ->scalarNode('to') + ->info('The receiver email') + ->defaultValue(null) + ->end() + ->scalarNode('from') + ->info('The sender email') + ->defaultValue(null) + ->end() + ->scalarNode('subject') + ->info('The subject of the email') + ->defaultValue('An task failed during its execution') + ->end() + ->end() + ->end() + ->arrayNode('on_success') + ->children() + ->scalarNode('triggered_at') + ->info('The amount of task that need to succeed before the email is sent') + ->defaultValue(1) + ->end() + ->scalarNode('to') + ->info('The receiver email') + ->defaultValue(null) + ->end() + ->scalarNode('from') + ->info('The sender email') + ->defaultValue(null) + ->end() + ->scalarNode('subject') + ->info('The subject of the email') + ->defaultValue('An task succeed during its execution') + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() ->arrayNode('probe') ->children() ->scalarNode('enabled') diff --git a/src/DependencyInjection/SchedulerBundleExtension.php b/src/DependencyInjection/SchedulerBundleExtension.php index 1d868369..4385168e 100644 --- a/src/DependencyInjection/SchedulerBundleExtension.php +++ b/src/DependencyInjection/SchedulerBundleExtension.php @@ -52,6 +52,7 @@ use SchedulerBundle\Middleware\TaskExecutionMiddleware; use SchedulerBundle\Middleware\TaskLockBagMiddleware; use SchedulerBundle\Middleware\TaskUpdateMiddleware; +use SchedulerBundle\Middleware\TriggerMiddleware; use SchedulerBundle\Middleware\WorkerMiddlewareStack; use SchedulerBundle\Middleware\PostExecutionMiddlewareInterface; use SchedulerBundle\Middleware\PreExecutionMiddlewareInterface; @@ -115,6 +116,10 @@ use SchedulerBundle\Transport\TransportFactory; use SchedulerBundle\Transport\TransportFactoryInterface; use SchedulerBundle\Transport\TransportInterface; +use SchedulerBundle\Trigger\EmailTriggerConfiguration; +use SchedulerBundle\Trigger\TriggerConfigurationInterface; +use SchedulerBundle\Trigger\TriggerConfigurationRegistry; +use SchedulerBundle\Trigger\TriggerConfigurationRegistryInterface; use SchedulerBundle\Worker\Worker; use SchedulerBundle\Worker\WorkerInterface; use Symfony\Bundle\FrameworkBundle\Console\Application; @@ -126,6 +131,7 @@ use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mercure\Hub; use Symfony\Component\Mercure\Jwt\StaticTokenProvider; use Symfony\Component\Lock\LockFactory; @@ -158,6 +164,7 @@ final class SchedulerBundleExtension extends Extension private const SCHEDULER_SCHEDULE_POLICY = 'scheduler.schedule_policy'; private const TRANSPORT_CONFIGURATION_TAG = 'scheduler.configuration'; private const TRANSPORT_CONFIGURATION_FACTORY_TAG = 'scheduler.configuration_factory'; + private const SCHEDULER_TRIGGER_CONFIGURATION_TAG = 'scheduler.trigger_configuration'; public function load(array $configs, ContainerBuilder $container): void { @@ -192,6 +199,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerMiddlewareStacks($container, $config); $this->registerProbeContext($container, $config); $this->registerMercureSupport($container, $config); + $this->registerTriggers($container, $config); $this->registerDataCollector($container); } @@ -206,6 +214,8 @@ private function registerParameters(ContainerBuilder $container, array $configur $container->setParameter('scheduler.probe_enabled', $configuration['probe']['enabled'] ?? false); $container->setParameter('scheduler.mercure_support', $configuration['mercure']['enabled']); $container->setParameter('scheduler.pool_support', $configuration['pool']['enabled']); + $container->setParameter('scheduler.trigger_support', $configuration['triggers']['enabled']); + $container->setParameter('scheduler.trigger_support.emails_enabled', $configuration['triggers']['email']['enabled']); } private function registerAutoConfigure(ContainerBuilder $container): void @@ -226,6 +236,7 @@ private function registerAutoConfigure(ContainerBuilder $container): void $container->registerForAutoconfiguration(BuilderInterface::class)->addTag(self::SCHEDULER_TASK_BUILDER_TAG); $container->registerForAutoconfiguration(ProbeInterface::class)->addTag(self::SCHEDULER_PROBE_TAG); $container->registerForAutoconfiguration(TaskBagInterface::class)->addTag('scheduler.task_bag'); + $container->registerForAutoconfiguration(TriggerConfigurationInterface::class)->addTag(self::SCHEDULER_TRIGGER_CONFIGURATION_TAG); } private function registerConfigurationFactories(ContainerBuilder $container): void @@ -1328,6 +1339,61 @@ private function registerMercureSupport(ContainerBuilder $container, array $conf ; } + private function registerTriggers(ContainerBuilder $container, array $config): void + { + if (!$container->getParameter('scheduler.trigger_support')) { + return; + } + + $container->register(TriggerConfigurationRegistry::class, TriggerConfigurationRegistry::class) + ->setArguments([ + new TaggedIteratorArgument(self::SCHEDULER_TRIGGER_CONFIGURATION_TAG), + ]) + ->setPublic(false) + ->addTag('container.preload', [ + 'class' => TriggerConfigurationRegistry::class, + ]) + ; + $container->setAlias(TriggerConfigurationRegistryInterface::class, TriggerConfigurationRegistry::class); + + $container->register(TriggerMiddleware::class, TriggerMiddleware::class) + ->setArguments([ + new Reference(EventDispatcherInterface::class, ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE), + new Reference(TriggerConfigurationRegistryInterface::class, ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE), + new Reference(LoggerInterface::class, ContainerInterface::NULL_ON_INVALID_REFERENCE), + new Reference(MailerInterface::class, ContainerInterface::NULL_ON_INVALID_REFERENCE), + ]) + ->setPublic(false) + ->addTag(self::SCHEDULER_WORKER_MIDDLEWARE_TAG) + ->addTag('container.preload', [ + 'class' => TriggerMiddleware::class, + ]) + ; + + if (!$container->getParameter('scheduler.trigger_support.emails_enabled')) { + return; + } + + $container->register(EmailTriggerConfiguration::class, EmailTriggerConfiguration::class) + ->setArguments([ + $container->getParameter('scheduler.trigger_support'), + $config['triggers']['email']['on_failure']['triggered_at'], + $config['triggers']['email']['on_success']['triggered_at'], + $config['triggers']['email']['on_failure']['subject'], + $config['triggers']['email']['on_success']['subject'], + $config['triggers']['email']['on_failure']['from'], + $config['triggers']['email']['on_success']['from'], + $config['triggers']['email']['on_failure']['to'], + $config['triggers']['email']['on_success']['to'], + ]) + ->addTag(self::SCHEDULER_TRIGGER_CONFIGURATION_TAG) + ->setPublic(false) + ->addTag('container.preload', [ + 'class' => EmailTriggerConfiguration::class, + ]) + ; + } + private function registerDataCollector(ContainerBuilder $container): void { $container->register(SchedulerDataCollector::class, SchedulerDataCollector::class) diff --git a/src/EventListener/EmailTaskLifecycleSubscriber.php b/src/EventListener/EmailTaskLifecycleSubscriber.php new file mode 100644 index 00000000..cca15faf --- /dev/null +++ b/src/EventListener/EmailTaskLifecycleSubscriber.php @@ -0,0 +1,125 @@ + + */ +final class EmailTaskLifecycleSubscriber implements EventSubscriberInterface +{ + private TaskListInterface $failedTasksList; + private TaskListInterface $succeedTasksList; + private ?MailerInterface $mailer; + private EmailTriggerConfiguration $emailTriggerConfiguration; + + public function __construct( + EmailTriggerConfiguration $emailTriggerConfiguration, + ?MailerInterface $mailer = null + ) { + $this->emailTriggerConfiguration = $emailTriggerConfiguration; + $this->mailer = $mailer; + + $this->failedTasksList = new TaskList(); + $this->succeedTasksList = new TaskList(); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + TaskExecutedEvent::class => 'onTaskExecuted', + TaskFailedEvent::class => 'onTaskFailed', + ]; + } + + public function onTaskFailed(FailedTask $failedTask): void + { + $this->failedTasksList->add($failedTask->getTask()); + } + + public function onTaskExecuted(TaskExecutedEvent $event): void + { + $task = $event->getTask(); + $output = $event->getOutput(); + + $this->handleTaskFailure($task, $output); + $this->handleTaskSuccess($task, $output); + } + + private function handleTaskFailure(TaskInterface $task, Output $output): void + { + if ($this->failedTasksList->count() !== $this->emailTriggerConfiguration->getFailureTriggeredAt()) { + return; + } + + if (null === $this->emailTriggerConfiguration->getFailureTo()) { + return; + } + + $this->send( + (new TemplatedEmail()) + ->from($this->emailTriggerConfiguration->getFailureFrom()) + ->to(new Address($this->emailTriggerConfiguration->getFailureTo())) + ->subject($this->emailTriggerConfiguration->getFailureSubject()) + ->htmlTemplate('emails/task_failure.html.twig') + ->context([ + 'tasks' => $this->failedTasksList->toArray(false), + ]) + ); + } + + private function handleTaskSuccess(TaskInterface $task, Output $output): void + { + if ($task->getExecutionState() !== TaskInterface::SUCCEED) { + return; + } + + $this->succeedTasksList->add($task); + + if ($this->succeedTasksList->count() !== $this->emailTriggerConfiguration->getSuccessTriggeredAt()) { + return; + } + + if (null === $this->emailTriggerConfiguration->getSuccessTo()) { + return; + } + + $this->send( + (new TemplatedEmail()) + ->from($this->emailTriggerConfiguration->getSuccessFrom()) + ->to(new Address($this->emailTriggerConfiguration->getSuccessTo())) + ->subject($this->emailTriggerConfiguration->getFailureSubject()) + ->htmlTemplate('emails/task_success.html.twig') + ->context([ + 'tasks' => $this->succeedTasksList->toArray(false), + ]) + ); + } + + private function send(Email $email): void + { + if (null === $this->mailer) { + return; + } + + $this->mailer->send($email); + } +} diff --git a/src/EventListener/MercureEventSubscriber.php b/src/EventListener/MercureEventSubscriber.php index 700b354a..5651fba5 100644 --- a/src/EventListener/MercureEventSubscriber.php +++ b/src/EventListener/MercureEventSubscriber.php @@ -41,6 +41,24 @@ public function __construct( $this->serializer = $serializer; } + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + TaskScheduledEvent::class => ['onTaskScheduled', -255], + TaskUnscheduledEvent::class => ['onTaskUnscheduled', -255], + TaskExecutedEvent::class => ['onTaskExecuted', -255], + TaskFailedEvent::class => ['onTaskFailed', -255], + WorkerPausedEvent::class => ['onWorkerPaused', -255], + WorkerStartedEvent::class => ['onWorkerStarted', -255], + WorkerStoppedEvent::class => ['onWorkerStopped', -255], + WorkerForkedEvent::class => ['onWorkerForked', -255], + WorkerRestartedEvent::class => ['onWorkerRestarted', -255], + ]; + } + /** * @throws JsonException {@see json_encode()} */ @@ -185,22 +203,4 @@ public function onWorkerRestarted(WorkerRestartedEvent $event): void ], ], JSON_THROW_ON_ERROR))); } - - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents(): array - { - return [ - TaskScheduledEvent::class => ['onTaskScheduled', -255], - TaskUnscheduledEvent::class => ['onTaskUnscheduled', -255], - TaskExecutedEvent::class => ['onTaskExecuted', -255], - TaskFailedEvent::class => ['onTaskFailed', -255], - WorkerPausedEvent::class => ['onWorkerPaused', -255], - WorkerStartedEvent::class => ['onWorkerStarted', -255], - WorkerStoppedEvent::class => ['onWorkerStopped', -255], - WorkerForkedEvent::class => ['onWorkerForked', -255], - WorkerRestartedEvent::class => ['onWorkerRestarted', -255], - ]; - } } diff --git a/src/EventListener/ProbeStateSubscriber.php b/src/EventListener/ProbeStateSubscriber.php index ec036b57..7161f395 100644 --- a/src/EventListener/ProbeStateSubscriber.php +++ b/src/EventListener/ProbeStateSubscriber.php @@ -28,6 +28,16 @@ public function __construct(ProbeInterface $probe, string $path = '/_probe') $this->path = $path; } + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => [['onKernelRequest', 50]], + ]; + } + /** * @throws Throwable {@see SchedulerInterface::getTasks()} */ @@ -49,14 +59,4 @@ public function onKernelRequest(RequestEvent $event): void 'failedTasks' => $this->probe->getFailedTasks(), ])); } - - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents(): array - { - return [ - KernelEvents::REQUEST => [['onKernelRequest', 50]], - ]; - } } diff --git a/src/EventListener/StopWorkerOnFailureLimitSubscriber.php b/src/EventListener/StopWorkerOnFailureLimitSubscriber.php index b6d74b34..92c86613 100644 --- a/src/EventListener/StopWorkerOnFailureLimitSubscriber.php +++ b/src/EventListener/StopWorkerOnFailureLimitSubscriber.php @@ -33,6 +33,17 @@ public function __construct( } } + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + TaskFailedEvent::class => 'onTaskFailedEvent', + WorkerRunningEvent::class => 'onWorkerStarted', + ]; + } + public function onTaskFailedEvent(): void { ++$this->failedTasks; @@ -56,15 +67,4 @@ public function onWorkerStarted(WorkerRunningEvent $workerRunningEvent): void )); } } - - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents(): array - { - return [ - TaskFailedEvent::class => 'onTaskFailedEvent', - WorkerRunningEvent::class => 'onWorkerStarted', - ]; - } } diff --git a/src/EventListener/StopWorkerOnSignalSubscriber.php b/src/EventListener/StopWorkerOnSignalSubscriber.php index f1c3a30c..3bcbf6b7 100644 --- a/src/EventListener/StopWorkerOnSignalSubscriber.php +++ b/src/EventListener/StopWorkerOnSignalSubscriber.php @@ -33,6 +33,23 @@ public function __construct(?LoggerInterface $logger = null) $this->logger = $logger ?? new NullLogger(); } + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + if (!function_exists('pcntl_signal')) { + return []; + } + + return [ + TaskExecutingEvent::class => ['onTaskExecuting', 100], + WorkerStartedEvent::class => ['onWorkerStarted', 100], + WorkerRunningEvent::class => ['onWorkerRunning', 100], + WorkerSleepingEvent::class => ['onWorkerSleeping', 100], + ]; + } + public function onTaskExecuting(TaskExecutingEvent $taskExecutingEvent): void { foreach ([SIGTERM, SIGINT] as $signal) { @@ -69,23 +86,6 @@ public function onWorkerSleeping(WorkerSleepingEvent $workerSleepingEvent): void $this->stopWorker($workerSleepingEvent); } - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents(): array - { - if (!function_exists('pcntl_signal')) { - return []; - } - - return [ - TaskExecutingEvent::class => ['onTaskExecuting', 100], - WorkerStartedEvent::class => ['onWorkerStarted', 100], - WorkerRunningEvent::class => ['onWorkerRunning', 100], - WorkerSleepingEvent::class => ['onWorkerSleeping', 100], - ]; - } - private function stopWorker(WorkerEventInterface $event): void { foreach ([SIGTERM, SIGINT, SIGQUIT, SIGHUP] as $signal) { diff --git a/src/EventListener/StopWorkerOnTaskLimitSubscriber.php b/src/EventListener/StopWorkerOnTaskLimitSubscriber.php index dbcb9069..978cc023 100644 --- a/src/EventListener/StopWorkerOnTaskLimitSubscriber.php +++ b/src/EventListener/StopWorkerOnTaskLimitSubscriber.php @@ -26,6 +26,16 @@ public function __construct( $this->logger = $logger ?? new NullLogger(); } + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + WorkerRunningEvent::class => 'onWorkerRunning', + ]; + } + public function onWorkerRunning(WorkerRunningEvent $workerRunningEvent): void { if (!$workerRunningEvent->isIdle() && ++$this->consumedTasks >= $this->maximumTasks) { @@ -38,14 +48,4 @@ public function onWorkerRunning(WorkerRunningEvent $workerRunningEvent): void ]); } } - - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents(): array - { - return [ - WorkerRunningEvent::class => 'onWorkerRunning', - ]; - } } diff --git a/src/EventListener/StopWorkerOnTimeLimitSubscriber.php b/src/EventListener/StopWorkerOnTimeLimitSubscriber.php index 85945c8b..378418e6 100644 --- a/src/EventListener/StopWorkerOnTimeLimitSubscriber.php +++ b/src/EventListener/StopWorkerOnTimeLimitSubscriber.php @@ -30,6 +30,17 @@ public function __construct( $this->logger = $logger ?? new NullLogger(); } + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + WorkerStartedEvent::class => 'onWorkerStarted', + WorkerRunningEvent::class => 'onWorkerRunning', + ]; + } + public function onWorkerStarted(): void { $this->endTime = microtime(true) + $this->timeLimitInSeconds; @@ -47,15 +58,4 @@ public function onWorkerRunning(WorkerRunningEvent $workerRunningEvent): void ]); } } - - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents(): array - { - return [ - WorkerStartedEvent::class => 'onWorkerStarted', - WorkerRunningEvent::class => 'onWorkerRunning', - ]; - } } diff --git a/src/EventListener/TaskLifecycleSubscriber.php b/src/EventListener/TaskLifecycleSubscriber.php index 595ebe48..0b73f5e3 100644 --- a/src/EventListener/TaskLifecycleSubscriber.php +++ b/src/EventListener/TaskLifecycleSubscriber.php @@ -24,6 +24,19 @@ public function __construct(LoggerInterface $logger = null) $this->logger = $logger ?? new NullLogger(); } + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + TaskScheduledEvent::class => 'onTaskScheduled', + TaskUnscheduledEvent::class => 'onTaskUnscheduled', + TaskExecutedEvent::class => 'onTaskExecuted', + TaskFailedEvent::class => 'onTaskFailed', + ]; + } + public function onTaskScheduled(TaskScheduledEvent $taskScheduledEvent): void { $this->logger->info('A task has been scheduled', [ @@ -51,17 +64,4 @@ public function onTaskFailed(TaskFailedEvent $taskFailedEvent): void 'task' => $taskFailedEvent->getTask()->getTask()->getName(), ]); } - - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents(): array - { - return [ - TaskScheduledEvent::class => 'onTaskScheduled', - TaskUnscheduledEvent::class => 'onTaskUnscheduled', - TaskExecutedEvent::class => 'onTaskExecuted', - TaskFailedEvent::class => 'onTaskFailed', - ]; - } } diff --git a/src/EventListener/TaskLoggerSubscriber.php b/src/EventListener/TaskLoggerSubscriber.php index ab5bbc75..8f66bf15 100644 --- a/src/EventListener/TaskLoggerSubscriber.php +++ b/src/EventListener/TaskLoggerSubscriber.php @@ -25,16 +25,6 @@ public function __construct() $this->events = new TaskEventList(); } - public function onTask(TaskEventInterface $taskEvent): void - { - $this->events->addEvent($taskEvent); - } - - public function getEvents(): TaskEventList - { - return $this->events; - } - /** * {@inheritdoc} */ @@ -48,4 +38,14 @@ public static function getSubscribedEvents(): array TaskUnscheduledEvent::class => ['onTask', -255], ]; } + + public function onTask(TaskEventInterface $taskEvent): void + { + $this->events->addEvent($taskEvent); + } + + public function getEvents(): TaskEventList + { + return $this->events; + } } diff --git a/src/EventListener/TaskSubscriber.php b/src/EventListener/TaskSubscriber.php index 1753a2ee..c76b1a31 100644 --- a/src/EventListener/TaskSubscriber.php +++ b/src/EventListener/TaskSubscriber.php @@ -55,6 +55,16 @@ public function __construct( $this->tasksPath = $tasksPath; } + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => [['onKernelRequest', 50]], + ]; + } + /** * @throws Throwable {@see SchedulerInterface::getTasks()} * @throws ExceptionInterface {@see SerializerInterface::serialize()} @@ -104,14 +114,4 @@ public function onKernelRequest(RequestEvent $requestEvent): void 'tasks' => $this->serializer->normalize($tasks, 'json'), ])); } - - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents(): array - { - return [ - KernelEvents::REQUEST => [['onKernelRequest', 50]], - ]; - } } diff --git a/src/EventListener/WorkerLifecycleSubscriber.php b/src/EventListener/WorkerLifecycleSubscriber.php index cdbfebb2..0a91dbf0 100644 --- a/src/EventListener/WorkerLifecycleSubscriber.php +++ b/src/EventListener/WorkerLifecycleSubscriber.php @@ -27,6 +27,21 @@ public function __construct(LoggerInterface $logger = null) $this->logger = $logger ?? new NullLogger(); } + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + WorkerForkedEvent::class => 'onWorkerForked', + WorkerPausedEvent::class => 'onWorkerPaused', + WorkerRestartedEvent::class => 'onWorkerRestarted', + WorkerRunningEvent::class => 'onWorkerRunning', + WorkerStartedEvent::class => 'onWorkerStarted', + WorkerStoppedEvent::class => 'onWorkerStopped', + ]; + } + public function onWorkerForked(WorkerForkedEvent $workerForkedEvent): void { $forkedWorker = $workerForkedEvent->getForkedWorker(); @@ -95,19 +110,4 @@ public function onWorkerStopped(WorkerStoppedEvent $workerStoppedEvent): void 'lastExecutedTask' => $lastExecutedTask instanceof TaskInterface ? $lastExecutedTask->getName() : null, ]); } - - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents(): array - { - return [ - WorkerForkedEvent::class => 'onWorkerForked', - WorkerPausedEvent::class => 'onWorkerPaused', - WorkerRestartedEvent::class => 'onWorkerRestarted', - WorkerRunningEvent::class => 'onWorkerRunning', - WorkerStartedEvent::class => 'onWorkerStarted', - WorkerStoppedEvent::class => 'onWorkerStopped', - ]; - } } diff --git a/src/Exception/TriggerConfigurationNotFoundException.php b/src/Exception/TriggerConfigurationNotFoundException.php new file mode 100644 index 00000000..10938326 --- /dev/null +++ b/src/Exception/TriggerConfigurationNotFoundException.php @@ -0,0 +1,14 @@ + + */ +final class TriggerConfigurationNotFoundException extends RuntimeException +{ +} diff --git a/src/Middleware/TriggerMiddleware.php b/src/Middleware/TriggerMiddleware.php new file mode 100644 index 00000000..345a8eab --- /dev/null +++ b/src/Middleware/TriggerMiddleware.php @@ -0,0 +1,59 @@ + + */ +final class TriggerMiddleware implements PreExecutionMiddlewareInterface +{ + private EventDispatcherInterface $eventDispatcher; + private LoggerInterface $logger; + private TriggerConfigurationRegistryInterface $triggerConfigurationRegistry; + private ?MailerInterface $mailer; + + public function __construct( + EventDispatcherInterface $eventDispatcher, + TriggerConfigurationRegistryInterface $triggerConfigurationRegistry, + ?LoggerInterface $logger = null, + ?MailerInterface $mailer = null + ) { + $this->eventDispatcher = $eventDispatcher; + $this->triggerConfigurationRegistry = $triggerConfigurationRegistry; + $this->logger = $logger ?? new NullLogger(); + + $this->mailer = $mailer; + } + + /** + * {@inheritdoc} + */ + public function preExecute(TaskInterface $task): void + { + $this->enableEmailsTrigger(); + } + + private function enableEmailsTrigger(): void + { + try { + $emailTriggerConfiguration = $this->triggerConfigurationRegistry->get('emails'); + + $this->eventDispatcher->addSubscriber(new EmailTaskLifecycleSubscriber($emailTriggerConfiguration, $this->mailer)); + } catch (TriggerConfigurationNotFoundException $exception) { + $this->logger->warning('The "emails" trigger cannot be registered'); + + return; + } + } +} diff --git a/src/Trigger/EmailTriggerConfiguration.php b/src/Trigger/EmailTriggerConfiguration.php new file mode 100644 index 00000000..48f59788 --- /dev/null +++ b/src/Trigger/EmailTriggerConfiguration.php @@ -0,0 +1,99 @@ + + */ +final class EmailTriggerConfiguration implements TriggerConfigurationInterface +{ + private bool $enabled; + private int $failureTriggeredAt; + private int $successTriggeredAt; + private ?string $failureFrom; + private ?string $successFrom; + private ?string $failureTo; + private ?string $successTo; + private string $failureSubject; + private string $successSubject; + + public function __construct( + bool $enabled, + int $failureTriggeredAt, + int $successTriggeredAt, + string $failureSubject, + string $successSubject, + ?string $failureFrom = null, + ?string $successFrom = null, + ?string $failureTo = null, + ?string $successTo = null + ) { + $this->enabled = $enabled; + $this->failureTriggeredAt = $failureTriggeredAt; + $this->successTriggeredAt = $successTriggeredAt; + $this->failureFrom = $failureFrom; + $this->successFrom = $successFrom; + $this->failureTo = $failureTo; + $this->successTo = $successTo; + $this->failureSubject = $failureSubject; + $this->successSubject = $successSubject; + } + + /** + * {@inheritdoc} + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * {@inheritdoc} + */ + public function support(string $trigger): bool + { + return 'emails' === $trigger; + } + + public function getFailureTriggeredAt(): int + { + return $this->failureTriggeredAt; + } + + public function getSuccessTriggeredAt(): int + { + return $this->successTriggeredAt; + } + + public function getFailureFrom(): ?string + { + return $this->failureFrom; + } + + public function getSuccessFrom(): ?string + { + return $this->successFrom; + } + + public function getFailureTo(): ?string + { + return $this->failureTo; + } + + public function getSuccessTo(): ?string + { + return $this->successTo; + } + + public function getFailureSubject(): string + { + return $this->failureSubject; + } + + public function getSuccessSubject(): string + { + return $this->successSubject; + } +} diff --git a/src/Trigger/TriggerConfigurationInterface.php b/src/Trigger/TriggerConfigurationInterface.php new file mode 100644 index 00000000..edf14361 --- /dev/null +++ b/src/Trigger/TriggerConfigurationInterface.php @@ -0,0 +1,18 @@ + + */ +interface TriggerConfigurationInterface +{ + /** + * Specify if the configuration is enabled. + */ + public function isEnabled(): bool; + + public function support(string $trigger): bool; +} diff --git a/src/Trigger/TriggerConfigurationRegistry.php b/src/Trigger/TriggerConfigurationRegistry.php new file mode 100644 index 00000000..6d45591a --- /dev/null +++ b/src/Trigger/TriggerConfigurationRegistry.php @@ -0,0 +1,77 @@ + + */ +final class TriggerConfigurationRegistry implements TriggerConfigurationRegistryInterface +{ + /** + * @var TriggerConfigurationInterface[] + */ + private iterable $configurationList; + + /** + * @param TriggerConfigurationInterface[] $configurationList + */ + public function __construct(iterable $configurationList) + { + $this->configurationList = $configurationList; + } + + /** + * {@inheritdoc} + */ + public function filter(Closure $func): TriggerConfigurationRegistryInterface + { + return new self(array_filter($this->configurationList, $func)); + } + + /** + * {@inheritdoc} + */ + public function get(string $triggerName): TriggerConfigurationInterface + { + $list = $this->filter(static fn (TriggerConfigurationInterface $configuration): bool => $configuration->support($triggerName)); + if (0 === $list->count()) { + throw new InvalidArgumentException('No configuration found for this trigger'); + } + + if (1 < $list->count()) { + throw new InvalidArgumentException('More than one configuration found, consider improving the trigger discriminator'); + } + + return $list->current(); + } + + /** + * {@inheritdoc} + */ + public function current(): TriggerConfigurationInterface + { + $currentConfiguration = current($this->configurationList); + if (false === $currentConfiguration) { + throw new TriggerConfigurationNotFoundException('The current configuration cannot be found'); + } + + return $currentConfiguration; + } + + /** + * {@inheritdoc} + */ + public function count(): int + { + return count($this->configurationList); + } +} diff --git a/src/Trigger/TriggerConfigurationRegistryInterface.php b/src/Trigger/TriggerConfigurationRegistryInterface.php new file mode 100644 index 00000000..833eb1c3 --- /dev/null +++ b/src/Trigger/TriggerConfigurationRegistryInterface.php @@ -0,0 +1,36 @@ + + */ +interface TriggerConfigurationRegistryInterface extends Countable +{ + /** + * Allow to filter the configuration list using @param Closure $func. + * + * A new {@see TriggerConfigurationRegistryInterface} will be returned. + */ + public function filter(Closure $func): TriggerConfigurationRegistryInterface; + + /** + * Return a {@see TriggerConfigurationInterface} depending on @param string $triggerName. + * + * @throws TriggerConfigurationNotFoundException {@see TriggerConfigurationRegistryInterface::current()} + */ + public function get(string $triggerName): TriggerConfigurationInterface; + + /** + * Return the current trigger configuration. + * + * @throws TriggerConfigurationNotFoundException + */ + public function current(): TriggerConfigurationInterface; +} diff --git a/tests/DependencyInjection/SchedulerBundleConfigurationTest.php b/tests/DependencyInjection/SchedulerBundleConfigurationTest.php index 4b0d5acf..92ac8fa9 100644 --- a/tests/DependencyInjection/SchedulerBundleConfigurationTest.php +++ b/tests/DependencyInjection/SchedulerBundleConfigurationTest.php @@ -25,6 +25,7 @@ public function testConfigurationCanBeEmpty(): void self::assertArrayHasKey('tasks', $configuration); self::assertArrayNotHasKey('probe', $configuration); self::assertArrayHasKey('lock_store', $configuration); + self::assertArrayHasKey('triggers', $configuration); } public function testConfigurationCannotDefineTasksWithoutTransport(): void @@ -514,4 +515,40 @@ public function testConfigurationCanDefineConfigurationTransportWithLazyMode(): self::assertArrayHasKey('mode', $configuration['configuration']); self::assertSame('lazy', $configuration['configuration']['mode']); } + + public function testConfigurationCanDefineTriggers(): void + { + $configuration = (new Processor())->processConfiguration(new SchedulerBundleConfiguration(), [ + 'scheduler_bundle' => [ + 'transport' => [ + 'dsn' => 'cache://app', + ], + 'triggers' => [ + 'enabled' => true, + 'email' => [ + 'enabled' => true, + 'on_failure' => [ + 'triggered_at' => 10, + 'to' => 'foo@foo.foo', + 'from' => 'bar@bar.bar', + 'subject' => 'An error occurred', + ], + 'on_success' => [ + 'triggered_at' => 10, + 'to' => 'foo@foo.foo', + 'from' => 'bar@bar.bar', + 'subject' => 'An task succeed', + ], + ], + ], + ], + ]); + + self::assertCount(2, $configuration['triggers']); + self::assertTrue($configuration['triggers']['enabled']); + self::assertCount(3, $configuration['triggers']['email']); + self::assertTrue($configuration['triggers']['email']['enabled']); + self::assertCount(4, $configuration['triggers']['email']['on_failure']); + self::assertCount(4, $configuration['triggers']['email']['on_success']); + } } diff --git a/tests/DependencyInjection/SchedulerBundleExtensionTest.php b/tests/DependencyInjection/SchedulerBundleExtensionTest.php index 5bd708e3..af443b3c 100644 --- a/tests/DependencyInjection/SchedulerBundleExtensionTest.php +++ b/tests/DependencyInjection/SchedulerBundleExtensionTest.php @@ -57,6 +57,7 @@ use SchedulerBundle\Middleware\TaskExecutionMiddleware; use SchedulerBundle\Middleware\TaskLockBagMiddleware; use SchedulerBundle\Middleware\TaskUpdateMiddleware; +use SchedulerBundle\Middleware\TriggerMiddleware; use SchedulerBundle\Middleware\WorkerMiddlewareStack; use SchedulerBundle\Probe\Probe; use SchedulerBundle\Probe\ProbeInterface; @@ -116,6 +117,10 @@ use SchedulerBundle\Transport\TransportFactory; use SchedulerBundle\Transport\TransportFactoryInterface; use SchedulerBundle\Transport\TransportInterface; +use SchedulerBundle\Trigger\EmailTriggerConfiguration; +use SchedulerBundle\Trigger\TriggerConfigurationInterface; +use SchedulerBundle\Trigger\TriggerConfigurationRegistry; +use SchedulerBundle\Trigger\TriggerConfigurationRegistryInterface; use SchedulerBundle\Worker\Worker; use SchedulerBundle\Worker\WorkerInterface; use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; @@ -125,6 +130,7 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mercure\Hub; use Symfony\Component\Mercure\Jwt\StaticTokenProvider; use Symfony\Component\Lock\LockFactory; @@ -216,6 +222,8 @@ public function testInterfacesForAutoconfigureAreRegistered(): void self::assertTrue($autoconfigurationInterfaces[ProbeInterface::class]->hasTag('scheduler.probe')); self::assertArrayHasKey(TaskBagInterface::class, $autoconfigurationInterfaces); self::assertTrue($autoconfigurationInterfaces[TaskBagInterface::class]->hasTag('scheduler.task_bag')); + self::assertArrayHasKey(TriggerConfigurationInterface::class, $autoconfigurationInterfaces); + self::assertTrue($autoconfigurationInterfaces[TriggerConfigurationInterface::class]->hasTag('scheduler.trigger_configuration')); } public function testConfigurationFactoriesAreRegistered(): void @@ -1733,6 +1741,86 @@ public function testPoolSupportCanBeRegistered(): void self::assertTrue($container->getParameter('scheduler.pool_support')); } + public function testTriggersCanBeRegistered(): void + { + $container = $this->getContainer([ + 'path' => '/_foo', + 'timezone' => 'Europe/Paris', + 'transport' => [ + 'dsn' => 'memory://first_in_first_out', + ], + 'triggers' => [ + 'enabled' => true, + ], + ]); + + self::assertTrue($container->hasAlias(TriggerConfigurationRegistryInterface::class)); + self::assertTrue($container->hasDefinition(TriggerConfigurationRegistry::class)); + self::assertCount(1, $container->getDefinition(TriggerConfigurationRegistry::class)->getArguments()); + self::assertInstanceOf(TaggedIteratorArgument::class, $container->getDefinition(TriggerConfigurationRegistry::class)->getArgument(0)); + self::assertSame('scheduler.trigger_configuration', $container->getDefinition(TriggerConfigurationRegistry::class)->getArgument(0)->getTag()); + self::assertFalse($container->getDefinition(TriggerConfigurationRegistry::class)->isPublic()); + self::assertCount(1, $container->getDefinition(TriggerConfigurationRegistry::class)->getTags()); + self::assertTrue($container->getDefinition(TriggerConfigurationRegistry::class)->hasTag('container.preload')); + self::assertSame(TriggerConfigurationRegistry::class, $container->getDefinition(TriggerConfigurationRegistry::class)->getTag('container.preload')[0]['class']); + + self::assertTrue($container->hasDefinition(TriggerMiddleware::class)); + self::assertCount(4, $container->getDefinition(TriggerMiddleware::class)->getArguments()); + self::assertInstanceOf(Reference::class, $container->getDefinition(TriggerMiddleware::class)->getArgument(0)); + self::assertSame(EventDispatcherInterface::class, (string) $container->getDefinition(TriggerMiddleware::class)->getArgument(0)); + self::assertSame(ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $container->getDefinition(TriggerMiddleware::class)->getArgument(0)->getInvalidBehavior()); + self::assertInstanceOf(Reference::class, $container->getDefinition(TriggerMiddleware::class)->getArgument(1)); + self::assertSame(TriggerConfigurationRegistryInterface::class, (string) $container->getDefinition(TriggerMiddleware::class)->getArgument(1)); + self::assertSame(ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $container->getDefinition(TriggerMiddleware::class)->getArgument(1)->getInvalidBehavior()); + self::assertInstanceOf(Reference::class, $container->getDefinition(TriggerMiddleware::class)->getArgument(2)); + self::assertSame(LoggerInterface::class, (string) $container->getDefinition(TriggerMiddleware::class)->getArgument(2)); + self::assertSame(ContainerInterface::NULL_ON_INVALID_REFERENCE, $container->getDefinition(TriggerMiddleware::class)->getArgument(2)->getInvalidBehavior()); + self::assertInstanceOf(Reference::class, $container->getDefinition(TriggerMiddleware::class)->getArgument(3)); + self::assertSame(MailerInterface::class, (string) $container->getDefinition(TriggerMiddleware::class)->getArgument(3)); + self::assertSame(ContainerInterface::NULL_ON_INVALID_REFERENCE, $container->getDefinition(TriggerMiddleware::class)->getArgument(3)->getInvalidBehavior()); + self::assertFalse($container->getDefinition(TriggerMiddleware::class)->isPublic()); + self::assertCount(2, $container->getDefinition(TriggerMiddleware::class)->getTags()); + self::assertTrue($container->getDefinition(TriggerMiddleware::class)->hasTag('scheduler.worker_middleware')); + self::assertTrue($container->getDefinition(TriggerMiddleware::class)->hasTag('container.preload')); + self::assertSame(TriggerMiddleware::class, $container->getDefinition(TriggerMiddleware::class)->getTag('container.preload')[0]['class']); + + self::assertFalse($container->hasDefinition(EmailTriggerConfiguration::class)); + } + + public function testEmailsTriggersCanBeRegistered(): void + { + $container = $this->getContainer([ + 'path' => '/_foo', + 'timezone' => 'Europe/Paris', + 'transport' => [ + 'dsn' => 'memory://first_in_first_out', + ], + 'triggers' => [ + 'enabled' => true, + 'email' => [ + 'enabled' => true, + 'on_failure' => [ + 'triggered_at' => 10, + 'to' => 'foo@foo.foo', + 'from' => 'bar@bar.bar', + 'subject' => 'An error occurred', + ], + 'on_success' => [ + 'triggered_at' => 10, + 'to' => 'foo@foo.foo', + 'from' => 'bar@bar.bar', + 'subject' => 'An task succeed', + ], + ], + ], + ]); + + self::assertTrue($container->hasDefinition(TriggerMiddleware::class)); + self::assertTrue($container->hasDefinition(TriggerConfigurationRegistry::class)); + + self::assertTrue($container->hasDefinition(EmailTriggerConfiguration::class)); + } + public function testConfiguration(): void { $extension = new SchedulerBundleExtension(); diff --git a/tests/EventListener/EmailTaskLifecycleSubscriberTest.php b/tests/EventListener/EmailTaskLifecycleSubscriberTest.php new file mode 100644 index 00000000..0ec70373 --- /dev/null +++ b/tests/EventListener/EmailTaskLifecycleSubscriberTest.php @@ -0,0 +1,65 @@ + + */ +final class EmailTaskLifecycleSubscriberTest extends TestCase +{ + public function testSubscriberIsConfigured(): void + { + self::assertCount(2, EmailTaskLifecycleSubscriber::getSubscribedEvents()); + + self::assertArrayHasKey(TaskExecutedEvent::class, EmailTaskLifecycleSubscriber::getSubscribedEvents()); + self::assertSame('onTaskExecuted', EmailTaskLifecycleSubscriber::getSubscribedEvents()[TaskExecutedEvent::class]); + + self::assertArrayHasKey(TaskFailedEvent::class, EmailTaskLifecycleSubscriber::getSubscribedEvents()); + self::assertSame('onTaskFailed', EmailTaskLifecycleSubscriber::getSubscribedEvents()[TaskFailedEvent::class]); + } + + public function testSubscriberCanListenTaskFailureWithoutSendingAnEmail(): void + { + $mailer = $this->createMock(MailerInterface::class); + $mailer->expects(self::never())->method('send'); + + $task = new NullTask('foo'); + + $configuration = new EmailTriggerConfiguration(true, 2, 2, 'foo.foo@foo.com', 'bar.bar@bar.com'); + + $subscriber = new EmailTaskLifecycleSubscriber($configuration, $mailer); + + $subscriber->onTaskFailed(new FailedTask($task, 'why not')); + $subscriber->onTaskExecuted(new TaskExecutedEvent($task, new Output($task))); + } + + public function testSubscriberCanListenTaskFailureThenSendingAnEmail(): void + { + $mailer = $this->createMock(MailerInterface::class); + $mailer->expects(self::once())->method('send'); + + $task = new NullTask('foo'); + $configuration = new EmailTriggerConfiguration(true, 1, 1, 'foo.foo@foo.com', 'bar.bar@bar.com'); + + $subscriber = new EmailTaskLifecycleSubscriber($configuration, $mailer); + + $subscriber->onTaskFailed(new FailedTask($task, 'why not')); + $subscriber->onTaskExecuted(new TaskExecutedEvent($task, new Output($task))); + } + + public function testSubscriberCanListenTaskExecution(): void + { + } +} diff --git a/tests/Middleware/TriggerMiddlewareTest.php b/tests/Middleware/TriggerMiddlewareTest.php new file mode 100644 index 00000000..5b92fc13 --- /dev/null +++ b/tests/Middleware/TriggerMiddlewareTest.php @@ -0,0 +1,14 @@ + + */ +final class TriggerMiddlewareTest extends TestCase +{ +} diff --git a/tests/Trigger/TriggerConfigurationRegistryTest.php b/tests/Trigger/TriggerConfigurationRegistryTest.php new file mode 100644 index 00000000..aa1a7fe7 --- /dev/null +++ b/tests/Trigger/TriggerConfigurationRegistryTest.php @@ -0,0 +1,14 @@ + + */ +final class TriggerConfigurationRegistryTest extends TestCase +{ +}