Skip to content

Commit 513e13a

Browse files
committed
feature #51525 [Messenger][Scheduler] Add AsCronTask & AsPeriodicTask attributes (valtzu)
This PR was squashed before being merged into the 6.4 branch. Discussion ---------- [Messenger][Scheduler] Add AsCronTask & AsPeriodicTask attributes | Q | A | ------------- | --- | Branch? | 6.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | Fix #51432 | License | MIT | Doc PR | symfony/symfony-docs#... _// todo_ Simplify scheduler usage by allowing to declare an attribute `AsCronTask` / `AsPeriodicTask` on any registered & autoconfigured service. Example usage: ```php #[AsPeriodicTask(frequency: 5, jitter: 1, arguments: ['Hello from periodic trigger'])] class BusinessLogic { public function __invoke(string $message): void { echo "$message\n"; } } ``` ```php #[AsCronTask('* * * * *', arguments: 'hello -v')] #[AsCommand('app:do-stuff')] class DoStuffCommand extends Command { // ... } ``` ```yaml services: some_other_service: class: # ... tags: - name: scheduler.task trigger: cron expression: '0 9-17 * * *' method: 'someMethod' transports: [async] ``` `bin/console debug:schedule` output: ```bash Scheduler ========= default ------- ------------------------------------------- ---------------------------------------- --------------------------------- Message Trigger Next Run ------------------------------------------- ---------------------------------------- --------------------------------- `@App`\BusinessLogic every 5 seconds with 0-1 second jitter Sat, 02 Sep 2023 10:55:36 +0000 app:do-stuff hello -v * * * * * Sat, 02 Sep 2023 13:56:00 +0300 `@some_other_service`::someMethod via async 0 9-17 * * * Sat, 02 Sep 2023 14:00:00 +0300 ------------------------------------------- ---------------------------------------- --------------------------------- ``` And then run `bin/console messenger:consume scheduler_default` to run the scheduler, like the usual. --- **To-do (help needed):** 1. tests 2. docs 3. validate the approach (creating `RecurringMessage`s using DI) Commits ------- ed27b20 [Messenger][Scheduler] Add AsCronTask & AsPeriodicTask attributes
2 parents 8700763 + ed27b20 commit 513e13a

File tree

11 files changed

+279
-3
lines changed

11 files changed

+279
-3
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ class UnusedTagsPass implements CompilerPassInterface
8484
'routing.loader',
8585
'routing.route_loader',
8686
'scheduler.schedule_provider',
87+
'scheduler.task',
8788
'security.authenticator.login_linker',
8889
'security.expression_language_provider',
8990
'security.remember_me_handler',

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@
145145
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
146146
use Symfony\Component\RemoteEvent\RemoteEvent;
147147
use Symfony\Component\Routing\Loader\AnnotationClassLoader;
148+
use Symfony\Component\Scheduler\Attribute\AsCronTask;
149+
use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;
148150
use Symfony\Component\Scheduler\Attribute\AsSchedule;
149151
use Symfony\Component\Scheduler\Messenger\SchedulerTransportFactory;
150152
use Symfony\Component\Security\Core\AuthenticationEvents;
@@ -702,6 +704,26 @@ public function load(array $configs, ContainerBuilder $container)
702704
$container->registerAttributeForAutoconfiguration(AsSchedule::class, static function (ChildDefinition $definition, AsSchedule $attribute): void {
703705
$definition->addTag('scheduler.schedule_provider', ['name' => $attribute->name]);
704706
});
707+
foreach ([AsPeriodicTask::class, AsCronTask::class] as $taskAttributeClass) {
708+
$container->registerAttributeForAutoconfiguration(
709+
$taskAttributeClass,
710+
static function (ChildDefinition $definition, AsPeriodicTask|AsCronTask $attribute, \ReflectionClass|\ReflectionMethod $reflector): void {
711+
$tagAttributes = get_object_vars($attribute) + [
712+
'trigger' => match ($attribute::class) {
713+
AsPeriodicTask::class => 'every',
714+
AsCronTask::class => 'cron',
715+
},
716+
];
717+
if ($reflector instanceof \ReflectionMethod) {
718+
if (isset($tagAttributes['method'])) {
719+
throw new LogicException(sprintf('%s attribute cannot declare a method on "%s::%s()".', $attribute::class, $reflector->class, $reflector->name));
720+
}
721+
$tagAttributes['method'] = $reflector->getName();
722+
}
723+
$definition->addTag('scheduler.task', $tagAttributes);
724+
}
725+
);
726+
}
705727

706728
if (!$container->getParameter('kernel.debug')) {
707729
// remove tagged iterator argument for resource checkers

src/Symfony/Bundle/FrameworkBundle/Resources/config/scheduler.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,15 @@
1212
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
1313

1414
use Symfony\Component\Scheduler\Messenger\SchedulerTransportFactory;
15+
use Symfony\Component\Scheduler\Messenger\ServiceCallMessageHandler;
1516

1617
return static function (ContainerConfigurator $container) {
1718
$container->services()
19+
->set('scheduler.messenger.service_call_message_handler', ServiceCallMessageHandler::class)
20+
->args([
21+
tagged_locator('scheduler.task'),
22+
])
23+
->tag('messenger.message_handler')
1824
->set('scheduler.messenger_transport_factory', SchedulerTransportFactory::class)
1925
->args([
2026
tagged_locator('scheduler.schedule_provider', 'name'),
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Messenger;
4+
5+
use Symfony\Component\Scheduler\Attribute\AsCronTask;
6+
use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;
7+
8+
#[AsCronTask(expression: '* * * * *', arguments: [1], schedule: 'dummy')]
9+
#[AsCronTask(expression: '0 * * * *', timezone: 'Europe/Berlin', arguments: ['2'], schedule: 'dummy', method: 'method2')]
10+
#[AsPeriodicTask(frequency: 5, arguments: [3], schedule: 'dummy')]
11+
#[AsPeriodicTask(frequency: 'every day', from: '00:00:00', jitter: 60, arguments: ['4'], schedule: 'dummy', method: 'method4')]
12+
class DummyTask
13+
{
14+
public static array $calls = [];
15+
16+
#[AsPeriodicTask(frequency: 'every hour', from: '09:00:00', until: '17:00:00', arguments: ['b' => 6, 'a' => '5'], schedule: 'dummy')]
17+
#[AsCronTask(expression: '0 0 * * *', arguments: ['7', 8], schedule: 'dummy')]
18+
public function attributesOnMethod(string $a, int $b): void
19+
{
20+
self::$calls[__FUNCTION__][] = [$a, $b];
21+
}
22+
23+
public function __call(string $name, array $arguments)
24+
{
25+
self::$calls[$name][] = $arguments;
26+
}
27+
}

src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Scheduler/config.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ services:
1010
Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Messenger\DummySchedule:
1111
autoconfigure: true
1212

13+
Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Messenger\DummyTask:
14+
autoconfigure: true
15+
1316
clock:
1417
synthetic: true
1518

src/Symfony/Component/Messenger/Message/RedispatchMessage.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,22 @@
1313

1414
use Symfony\Component\Messenger\Envelope;
1515

16-
final class RedispatchMessage
16+
final class RedispatchMessage implements \Stringable
1717
{
1818
/**
19-
* @param object|Envelope $message The message or the message pre-wrapped in an envelope
19+
* @param object|Envelope $envelope The message or the message pre-wrapped in an envelope
2020
* @param string[]|string $transportNames Transport names to be used for the message
2121
*/
2222
public function __construct(
2323
public readonly object $envelope,
2424
public readonly array|string $transportNames = [],
2525
) {
2626
}
27+
28+
public function __toString(): string
29+
{
30+
$message = $this->envelope instanceof Envelope ? $this->envelope->getMessage() : $this->envelope;
31+
32+
return sprintf('%s via %s', $message instanceof \Stringable ? (string) $message : $message::class, implode(', ', (array) $this->transportNames));
33+
}
2734
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Scheduler\Attribute;
13+
14+
/**
15+
* A marker to call a service method from scheduler.
16+
*
17+
* @author valtzu <valtzu@gmail.com>
18+
*/
19+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
20+
class AsCronTask
21+
{
22+
public function __construct(
23+
public readonly string $expression,
24+
public readonly ?string $timezone = null,
25+
public readonly ?int $jitter = null,
26+
public readonly array|string|null $arguments = null,
27+
public readonly string $schedule = 'default',
28+
public readonly ?string $method = null,
29+
public readonly array|string|null $transports = null,
30+
) {
31+
}
32+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Scheduler\Attribute;
13+
14+
/**
15+
* A marker to call a service method from scheduler.
16+
*
17+
* @author valtzu <valtzu@gmail.com>
18+
*/
19+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
20+
class AsPeriodicTask
21+
{
22+
public function __construct(
23+
public readonly string|int $frequency,
24+
public readonly ?string $from = null,
25+
public readonly ?string $until = null,
26+
public readonly ?int $jitter = null,
27+
public readonly array|string|null $arguments = null,
28+
public readonly string $schedule = 'default',
29+
public readonly ?string $method = null,
30+
public readonly array|string|null $transports = null,
31+
) {
32+
}
33+
}

src/Symfony/Component/Scheduler/DependencyInjection/AddScheduleMessengerPass.php

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,17 @@
1111

1212
namespace Symfony\Component\Scheduler\DependencyInjection;
1313

14+
use Symfony\Component\Console\Messenger\RunCommandMessage;
1415
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
1516
use Symfony\Component\DependencyInjection\ContainerBuilder;
1617
use Symfony\Component\DependencyInjection\Definition;
18+
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
1719
use Symfony\Component\DependencyInjection\Reference;
20+
use Symfony\Component\Messenger\Message\RedispatchMessage;
1821
use Symfony\Component\Messenger\Transport\TransportInterface;
22+
use Symfony\Component\Scheduler\Messenger\ServiceCallMessage;
23+
use Symfony\Component\Scheduler\RecurringMessage;
24+
use Symfony\Component\Scheduler\Schedule;
1925

2026
/**
2127
* @internal
@@ -29,8 +35,69 @@ public function process(ContainerBuilder $container): void
2935
$receivers[$tags[0]['alias']] = true;
3036
}
3137

32-
foreach ($container->findTaggedServiceIds('scheduler.schedule_provider') as $tags) {
38+
$scheduleProviderIds = [];
39+
foreach ($container->findTaggedServiceIds('scheduler.schedule_provider') as $serviceId => $tags) {
3340
$name = $tags[0]['name'];
41+
$scheduleProviderIds[$name] = $serviceId;
42+
}
43+
44+
$tasksPerSchedule = [];
45+
foreach ($container->findTaggedServiceIds('scheduler.task') as $serviceId => $tags) {
46+
foreach ($tags as $tagAttributes) {
47+
$serviceDefinition = $container->getDefinition($serviceId);
48+
$scheduleName = $tagAttributes['schedule'] ?? 'default';
49+
50+
if ($serviceDefinition->hasTag('console.command')) {
51+
$message = new Definition(RunCommandMessage::class, [$serviceDefinition->getClass()::getDefaultName().(empty($tagAttributes['arguments']) ? '' : " {$tagAttributes['arguments']}")]);
52+
} else {
53+
$message = new Definition(ServiceCallMessage::class, [$serviceId, $tagAttributes['method'] ?? '__invoke', (array) ($tagAttributes['arguments'] ?? [])]);
54+
}
55+
56+
if ($tagAttributes['transports'] ?? null) {
57+
$message = new Definition(RedispatchMessage::class, [$message, $tagAttributes['transports']]);
58+
}
59+
60+
$taskArguments = [
61+
'$message' => $message,
62+
] + array_filter(match ($tagAttributes['trigger'] ?? throw new InvalidArgumentException("Tag 'scheduler.task' is missing attribute 'trigger' on service $serviceId.")) {
63+
'every' => [
64+
'$frequency' => $tagAttributes['frequency'] ?? throw new InvalidArgumentException("Tag 'scheduler.task' is missing attribute 'frequency' on service $serviceId."),
65+
'$from' => $tagAttributes['from'] ?? null,
66+
'$until' => $tagAttributes['until'] ?? null,
67+
],
68+
'cron' => [
69+
'$expression' => $tagAttributes['expression'] ?? throw new InvalidArgumentException("Tag 'scheduler.task' is missing attribute 'expression' on service $serviceId."),
70+
'$timezone' => $tagAttributes['timezone'] ?? null,
71+
],
72+
}, fn ($value) => null !== $value);
73+
74+
$tasksPerSchedule[$scheduleName][] = $taskDefinition = (new Definition(RecurringMessage::class))
75+
->setFactory([RecurringMessage::class, $tagAttributes['trigger']])
76+
->setArguments($taskArguments);
77+
78+
if ($tagAttributes['jitter'] ?? false) {
79+
$taskDefinition->addMethodCall('withJitter', [$tagAttributes['jitter']], true);
80+
}
81+
}
82+
}
83+
84+
foreach ($tasksPerSchedule as $scheduleName => $tasks) {
85+
$id = "scheduler.provider.$scheduleName";
86+
$schedule = (new Definition(Schedule::class))->addMethodCall('add', $tasks);
87+
88+
if (isset($scheduleProviderIds[$scheduleName])) {
89+
$schedule
90+
->setFactory([new Reference('.inner'), 'getSchedule'])
91+
->setDecoratedService($scheduleProviderIds[$scheduleName]);
92+
} else {
93+
$schedule->addTag('scheduler.schedule_provider', ['name' => $scheduleName]);
94+
$scheduleProviderIds[$scheduleName] = $id;
95+
}
96+
97+
$container->setDefinition($id, $schedule);
98+
}
99+
100+
foreach (array_keys($scheduleProviderIds) as $name) {
34101
$transportName = 'scheduler_'.$name;
35102

36103
// allows to override the default transport registration
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Scheduler\Messenger;
13+
14+
/**
15+
* Represents a service call.
16+
*
17+
* @author valtzu <valtzu@gmail.com>
18+
*/
19+
class ServiceCallMessage implements \Stringable
20+
{
21+
public function __construct(
22+
private readonly string $serviceId,
23+
private readonly string $method = '__invoke',
24+
private readonly array $arguments = [],
25+
) {
26+
}
27+
28+
public function getServiceId(): string
29+
{
30+
return $this->serviceId;
31+
}
32+
33+
public function getMethod(): string
34+
{
35+
return $this->method;
36+
}
37+
38+
public function getArguments(): array
39+
{
40+
return $this->arguments;
41+
}
42+
43+
public function __toString(): string
44+
{
45+
return "@$this->serviceId".('__invoke' !== $this->method ? "::$this->method" : '');
46+
}
47+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy