diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index b04516410fbf4..2d1c8f041309b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -84,6 +84,7 @@ class UnusedTagsPass implements CompilerPassInterface 'routing.loader', 'routing.route_loader', 'scheduler.schedule_provider', + 'scheduler.task', 'security.authenticator.login_linker', 'security.expression_language_provider', 'security.remember_me_handler', diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 2df22c3af6cb7..9116b34c9454c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -145,6 +145,8 @@ use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer; use Symfony\Component\RemoteEvent\RemoteEvent; use Symfony\Component\Routing\Loader\AnnotationClassLoader; +use Symfony\Component\Scheduler\Attribute\AsCronTask; +use Symfony\Component\Scheduler\Attribute\AsPeriodicTask; use Symfony\Component\Scheduler\Attribute\AsSchedule; use Symfony\Component\Scheduler\Messenger\SchedulerTransportFactory; use Symfony\Component\Security\Core\AuthenticationEvents; @@ -701,6 +703,26 @@ public function load(array $configs, ContainerBuilder $container) $container->registerAttributeForAutoconfiguration(AsSchedule::class, static function (ChildDefinition $definition, AsSchedule $attribute): void { $definition->addTag('scheduler.schedule_provider', ['name' => $attribute->name]); }); + foreach ([AsPeriodicTask::class, AsCronTask::class] as $taskAttributeClass) { + $container->registerAttributeForAutoconfiguration( + $taskAttributeClass, + static function (ChildDefinition $definition, AsPeriodicTask|AsCronTask $attribute, \ReflectionClass|\ReflectionMethod $reflector): void { + $tagAttributes = get_object_vars($attribute) + [ + 'trigger' => match ($attribute::class) { + AsPeriodicTask::class => 'every', + AsCronTask::class => 'cron', + }, + ]; + if ($reflector instanceof \ReflectionMethod) { + if (isset($tagAttributes['method'])) { + throw new LogicException(sprintf('%s attribute cannot declare a method on "%s::%s()".', $attribute::class, $reflector->class, $reflector->name)); + } + $tagAttributes['method'] = $reflector->getName(); + } + $definition->addTag('scheduler.task', $tagAttributes); + } + ); + } if (!$container->getParameter('kernel.debug')) { // remove tagged iterator argument for resource checkers diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/scheduler.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/scheduler.php index 9ad64c56a051d..7dad84b465f4d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/scheduler.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/scheduler.php @@ -12,9 +12,15 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\Component\Scheduler\Messenger\SchedulerTransportFactory; +use Symfony\Component\Scheduler\Messenger\ServiceCallMessageHandler; return static function (ContainerConfigurator $container) { $container->services() + ->set('scheduler.messenger.service_call_message_handler', ServiceCallMessageHandler::class) + ->args([ + tagged_locator('scheduler.task'), + ]) + ->tag('messenger.message_handler') ->set('scheduler.messenger_transport_factory', SchedulerTransportFactory::class) ->args([ tagged_locator('scheduler.schedule_provider', 'name'), diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Messenger/DummyTask.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Messenger/DummyTask.php new file mode 100644 index 0000000000000..bc9f7f20d6910 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Messenger/DummyTask.php @@ -0,0 +1,27 @@ + 6, 'a' => '5'], schedule: 'dummy')] + #[AsCronTask(expression: '0 0 * * *', arguments: ['7', 8], schedule: 'dummy')] + public function attributesOnMethod(string $a, int $b): void + { + self::$calls[__FUNCTION__][] = [$a, $b]; + } + + public function __call(string $name, array $arguments) + { + self::$calls[$name][] = $arguments; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Scheduler/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Scheduler/config.yml index e39d423f4f4cd..90016381be1c1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Scheduler/config.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Scheduler/config.yml @@ -10,6 +10,9 @@ services: Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Messenger\DummySchedule: autoconfigure: true + Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Messenger\DummyTask: + autoconfigure: true + clock: synthetic: true diff --git a/src/Symfony/Component/Messenger/Message/RedispatchMessage.php b/src/Symfony/Component/Messenger/Message/RedispatchMessage.php index 31ba78a9acf74..c9bcbfa49490e 100644 --- a/src/Symfony/Component/Messenger/Message/RedispatchMessage.php +++ b/src/Symfony/Component/Messenger/Message/RedispatchMessage.php @@ -13,10 +13,10 @@ use Symfony\Component\Messenger\Envelope; -final class RedispatchMessage +final class RedispatchMessage implements \Stringable { /** - * @param object|Envelope $message The message or the message pre-wrapped in an envelope + * @param object|Envelope $envelope The message or the message pre-wrapped in an envelope * @param string[]|string $transportNames Transport names to be used for the message */ public function __construct( @@ -24,4 +24,11 @@ public function __construct( public readonly array|string $transportNames = [], ) { } + + public function __toString(): string + { + $message = $this->envelope instanceof Envelope ? $this->envelope->getMessage() : $this->envelope; + + return sprintf('%s via %s', $message instanceof \Stringable ? (string) $message : $message::class, implode(', ', (array) $this->transportNames)); + } } diff --git a/src/Symfony/Component/Scheduler/Attribute/AsCronTask.php b/src/Symfony/Component/Scheduler/Attribute/AsCronTask.php new file mode 100644 index 0000000000000..076d99169c74f --- /dev/null +++ b/src/Symfony/Component/Scheduler/Attribute/AsCronTask.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Scheduler\Attribute; + +/** + * A marker to call a service method from scheduler. + * + * @author valtzu + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class AsCronTask +{ + public function __construct( + public readonly string $expression, + public readonly ?string $timezone = null, + public readonly ?int $jitter = null, + public readonly array|string|null $arguments = null, + public readonly string $schedule = 'default', + public readonly ?string $method = null, + public readonly array|string|null $transports = null, + ) { + } +} diff --git a/src/Symfony/Component/Scheduler/Attribute/AsPeriodicTask.php b/src/Symfony/Component/Scheduler/Attribute/AsPeriodicTask.php new file mode 100644 index 0000000000000..560a36fb75b71 --- /dev/null +++ b/src/Symfony/Component/Scheduler/Attribute/AsPeriodicTask.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Scheduler\Attribute; + +/** + * A marker to call a service method from scheduler. + * + * @author valtzu + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class AsPeriodicTask +{ + public function __construct( + public readonly string|int $frequency, + public readonly ?string $from = null, + public readonly ?string $until = null, + public readonly ?int $jitter = null, + public readonly array|string|null $arguments = null, + public readonly string $schedule = 'default', + public readonly ?string $method = null, + public readonly array|string|null $transports = null, + ) { + } +} diff --git a/src/Symfony/Component/Scheduler/DependencyInjection/AddScheduleMessengerPass.php b/src/Symfony/Component/Scheduler/DependencyInjection/AddScheduleMessengerPass.php index b36a40f6548c5..11bd0a2705cab 100644 --- a/src/Symfony/Component/Scheduler/DependencyInjection/AddScheduleMessengerPass.php +++ b/src/Symfony/Component/Scheduler/DependencyInjection/AddScheduleMessengerPass.php @@ -11,11 +11,17 @@ namespace Symfony\Component\Scheduler\DependencyInjection; +use Symfony\Component\Console\Messenger\RunCommandMessage; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Messenger\Message\RedispatchMessage; use Symfony\Component\Messenger\Transport\TransportInterface; +use Symfony\Component\Scheduler\Messenger\ServiceCallMessage; +use Symfony\Component\Scheduler\RecurringMessage; +use Symfony\Component\Scheduler\Schedule; /** * @internal @@ -29,8 +35,69 @@ public function process(ContainerBuilder $container): void $receivers[$tags[0]['alias']] = true; } - foreach ($container->findTaggedServiceIds('scheduler.schedule_provider') as $tags) { + $scheduleProviderIds = []; + foreach ($container->findTaggedServiceIds('scheduler.schedule_provider') as $serviceId => $tags) { $name = $tags[0]['name']; + $scheduleProviderIds[$name] = $serviceId; + } + + $tasksPerSchedule = []; + foreach ($container->findTaggedServiceIds('scheduler.task') as $serviceId => $tags) { + foreach ($tags as $tagAttributes) { + $serviceDefinition = $container->getDefinition($serviceId); + $scheduleName = $tagAttributes['schedule'] ?? 'default'; + + if ($serviceDefinition->hasTag('console.command')) { + $message = new Definition(RunCommandMessage::class, [$serviceDefinition->getClass()::getDefaultName().(empty($tagAttributes['arguments']) ? '' : " {$tagAttributes['arguments']}")]); + } else { + $message = new Definition(ServiceCallMessage::class, [$serviceId, $tagAttributes['method'] ?? '__invoke', (array) ($tagAttributes['arguments'] ?? [])]); + } + + if ($tagAttributes['transports'] ?? null) { + $message = new Definition(RedispatchMessage::class, [$message, $tagAttributes['transports']]); + } + + $taskArguments = [ + '$message' => $message, + ] + array_filter(match ($tagAttributes['trigger'] ?? throw new InvalidArgumentException("Tag 'scheduler.task' is missing attribute 'trigger' on service $serviceId.")) { + 'every' => [ + '$frequency' => $tagAttributes['frequency'] ?? throw new InvalidArgumentException("Tag 'scheduler.task' is missing attribute 'frequency' on service $serviceId."), + '$from' => $tagAttributes['from'] ?? null, + '$until' => $tagAttributes['until'] ?? null, + ], + 'cron' => [ + '$expression' => $tagAttributes['expression'] ?? throw new InvalidArgumentException("Tag 'scheduler.task' is missing attribute 'expression' on service $serviceId."), + '$timezone' => $tagAttributes['timezone'] ?? null, + ], + }, fn ($value) => null !== $value); + + $tasksPerSchedule[$scheduleName][] = $taskDefinition = (new Definition(RecurringMessage::class)) + ->setFactory([RecurringMessage::class, $tagAttributes['trigger']]) + ->setArguments($taskArguments); + + if ($tagAttributes['jitter'] ?? false) { + $taskDefinition->addMethodCall('withJitter', [$tagAttributes['jitter']], true); + } + } + } + + foreach ($tasksPerSchedule as $scheduleName => $tasks) { + $id = "scheduler.provider.$scheduleName"; + $schedule = (new Definition(Schedule::class))->addMethodCall('add', $tasks); + + if (isset($scheduleProviderIds[$scheduleName])) { + $schedule + ->setFactory([new Reference('.inner'), 'getSchedule']) + ->setDecoratedService($scheduleProviderIds[$scheduleName]); + } else { + $schedule->addTag('scheduler.schedule_provider', ['name' => $scheduleName]); + $scheduleProviderIds[$scheduleName] = $id; + } + + $container->setDefinition($id, $schedule); + } + + foreach (array_keys($scheduleProviderIds) as $name) { $transportName = 'scheduler_'.$name; // allows to override the default transport registration diff --git a/src/Symfony/Component/Scheduler/Messenger/ServiceCallMessage.php b/src/Symfony/Component/Scheduler/Messenger/ServiceCallMessage.php new file mode 100644 index 0000000000000..d3b6e0894ef75 --- /dev/null +++ b/src/Symfony/Component/Scheduler/Messenger/ServiceCallMessage.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Scheduler\Messenger; + +/** + * Represents a service call. + * + * @author valtzu + */ +class ServiceCallMessage implements \Stringable +{ + public function __construct( + private readonly string $serviceId, + private readonly string $method = '__invoke', + private readonly array $arguments = [], + ) { + } + + public function getServiceId(): string + { + return $this->serviceId; + } + + public function getMethod(): string + { + return $this->method; + } + + public function getArguments(): array + { + return $this->arguments; + } + + public function __toString(): string + { + return "@$this->serviceId".('__invoke' !== $this->method ? "::$this->method" : ''); + } +} diff --git a/src/Symfony/Component/Scheduler/Messenger/ServiceCallMessageHandler.php b/src/Symfony/Component/Scheduler/Messenger/ServiceCallMessageHandler.php new file mode 100644 index 0000000000000..ae990efc7dc92 --- /dev/null +++ b/src/Symfony/Component/Scheduler/Messenger/ServiceCallMessageHandler.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Scheduler\Messenger; + +use Psr\Container\ContainerInterface; + +/** + * Handler to call any service. + * + * @author valtzu + */ +class ServiceCallMessageHandler +{ + public function __construct(private readonly ContainerInterface $serviceLocator) + { + } + + public function __invoke(ServiceCallMessage $message): void + { + $this->serviceLocator->get($message->getServiceId())->{$message->getMethod()}(...$message->getArguments()); + } +}