Skip to content

[Scheduler] Trigger unique messages at runtime #51542

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Symfony/Component/Scheduler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ CHANGELOG
* Make `ScheduledStamp` "send-able"
* Add `ScheduledStamp` to `RedispatchMessage`
* Allow modifying Schedule instances at runtime
* Add `MessageProviderInterface` to trigger unique messages at runtime

6.3
---
Expand Down
11 changes: 2 additions & 9 deletions src/Symfony/Component/Scheduler/Command/DebugCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Service\ServiceProviderInterface;
Expand Down Expand Up @@ -95,7 +94,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
continue;
}
$io->table(
['Message', 'Trigger', 'Next Run'],
['Trigger', 'Provider', 'Next Run'],
array_filter(array_map(self::renderRecurringMessage(...), $messages, array_fill(0, \count($messages), $date), array_fill(0, \count($messages), $input->getOption('all')))),
);
}
Expand All @@ -108,19 +107,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
*/
private static function renderRecurringMessage(RecurringMessage $recurringMessage, \DateTimeImmutable $date, bool $all): ?array
{
$message = $recurringMessage->getMessage();
$trigger = $recurringMessage->getTrigger();

if ($message instanceof Envelope) {
$message = $message->getMessage();
}

$next = $trigger->getNextRunDate($date)?->format('r') ?? '-';
if ('-' === $next && !$all) {
return null;
}
$name = $message instanceof \Stringable ? (string) $message : (new \ReflectionClass($message))->getShortName();

return [$name, (string) $trigger, $next];
return [(string) $trigger, $recurringMessage->getProvider()::class, $next];
}
}
10 changes: 8 additions & 2 deletions src/Symfony/Component/Scheduler/Generator/MessageGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public function __construct(
$this->waitUntil = new \DateTimeImmutable('@0');
}

/**
* @return \Generator<MessageContext, object>
*/
public function getMessages(): \Generator
{
$checkpoint = $this->checkpoint();
Expand Down Expand Up @@ -61,7 +64,6 @@ public function getMessages(): \Generator
/** @var RecurringMessage $recurringMessage */
[$time, $index, $recurringMessage] = $heap->extract();
$id = $recurringMessage->getId();
$message = $recurringMessage->getMessage();
$trigger = $recurringMessage->getTrigger();
$yield = true;

Expand All @@ -77,7 +79,11 @@ public function getMessages(): \Generator
}

if ($yield) {
yield (new MessageContext($this->name, $id, $trigger, $time, $nextTime)) => $message;
$context = new MessageContext($this->name, $id, $trigger, $time, $nextTime);
foreach ($recurringMessage->getMessages($context) as $message) {
yield $context => $message;
}

$checkpoint->save($time, $index);
}
}
Expand Down
54 changes: 37 additions & 17 deletions src/Symfony/Component/Scheduler/RecurringMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,21 @@
namespace Symfony\Component\Scheduler;

use Symfony\Component\Scheduler\Exception\InvalidArgumentException;
use Symfony\Component\Scheduler\Generator\MessageContext;
use Symfony\Component\Scheduler\Trigger\CronExpressionTrigger;
use Symfony\Component\Scheduler\Trigger\JitterTrigger;
use Symfony\Component\Scheduler\Trigger\MessageProviderInterface;
use Symfony\Component\Scheduler\Trigger\PeriodicalTrigger;
use Symfony\Component\Scheduler\Trigger\StaticMessageProvider;
use Symfony\Component\Scheduler\Trigger\TriggerInterface;

final class RecurringMessage
final class RecurringMessage implements MessageProviderInterface
{
private string $id;

private function __construct(
private readonly TriggerInterface $trigger,
private readonly object $message,
private readonly MessageProviderInterface $provider,
) {
}

Expand All @@ -37,35 +40,53 @@ private function __construct(
* * A relative date format as supported by \DateInterval;
* * A \DateInterval instance.
*
* @param MessageProviderInterface|object $message A message provider that yields messages or a static message that will be dispatched on every trigger
*
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations
* @see https://php.net/datetime.formats.relative
*/
public static function every(string|int|\DateInterval $frequency, object $message, string|\DateTimeImmutable $from = null, string|\DateTimeImmutable $until = new \DateTimeImmutable('3000-01-01')): self
{
return new self(new PeriodicalTrigger($frequency, $from, $until), $message);
return self::trigger(new PeriodicalTrigger($frequency, $from, $until), $message);
}

/**
* @param MessageProviderInterface|object $message A message provider that yields messages or a static message that will be dispatched on every trigger
*/
public static function cron(string $expression, object $message, \DateTimeZone|string $timezone = null): self
{
if (!str_contains($expression, '#')) {
return new self(CronExpressionTrigger::fromSpec($expression, null, $timezone), $message);
return self::trigger(CronExpressionTrigger::fromSpec($expression, null, $timezone), $message);
}

if (!$message instanceof \Stringable) {
throw new InvalidArgumentException('A message must be stringable to use "hashed" cron expressions.');
}

return new self(CronExpressionTrigger::fromSpec($expression, (string) $message, $timezone), $message);
return self::trigger(CronExpressionTrigger::fromSpec($expression, (string) $message, $timezone), $message);
}

/**
* @param MessageProviderInterface|object $message A message provider that yields messages or a static message that will be dispatched on every trigger
*/
public static function trigger(TriggerInterface $trigger, object $message): self
{
return new self($trigger, $message);
if ($message instanceof MessageProviderInterface) {
return new self($trigger, $message);
}

try {
$description = $message instanceof \Stringable ? (string) $message : serialize($message);
} catch (\Exception) {
$description = $message::class;
}

return new self($trigger, new StaticMessageProvider([$message], $description));
}

public function withJitter(int $maxSeconds = 60): self
{
return new self(new JitterTrigger($this->trigger, $maxSeconds), $this->message);
return new self(new JitterTrigger($this->trigger, $maxSeconds), $this->provider);
}

/**
Expand All @@ -77,23 +98,22 @@ public function getId(): string
return $this->id;
}

try {
$message = $this->message instanceof \Stringable ? (string) $this->message : serialize($this->message);
} catch (\Exception) {
$message = '';
}

return $this->id = hash('crc32c', implode('', [
$this->message::class,
$message,
$this->provider::class,
$this->provider->getId(),
$this->trigger::class,
(string) $this->trigger,
]));
}

public function getMessage(): object
public function getMessages(MessageContext $context): iterable
{
return $this->provider->getMessages($context);
}

public function getProvider(): MessageProviderInterface
{
return $this->message;
return $this->provider;
}

public function getTrigger(): TriggerInterface
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ public function testExecuteWithScheduleWithoutTriggerDoesNotDisplayMessage()
"schedule_name\n".
"-------------\n".
"\n".
" --------- --------- ---------- \n".
" Message Trigger Next Run \n".
" --------- --------- ---------- \n".
" --------- ---------- ---------- \n".
" Trigger Provider Next Run \n".
" --------- ---------- ---------- \n".
"\n", $tester->getDisplay(true));
}

Expand Down Expand Up @@ -106,11 +106,11 @@ public function testExecuteWithScheduleWithoutTriggerShowingNoNextRunWithAllOpti
"schedule_name\n".
"-------------\n".
"\n".
" ---------- --------- ---------- \n".
" Message Trigger Next Run \n".
" ---------- --------- ---------- \n".
" stdClass test - \n".
" ---------- --------- ---------- \n".
" --------- ----------------------------------------------------------- ---------- \n".
" Trigger Provider Next Run \n".
" --------- ----------------------------------------------------------- ---------- \n".
" test Symfony\Component\Scheduler\Trigger\StaticMessageProvider - \n".
" --------- ----------------------------------------------------------- ---------- \n".
"\n", $tester->getDisplay(true));
}

Expand Down Expand Up @@ -143,11 +143,11 @@ public function testExecuteWithSchedule()
"schedule_name\n".
"-------------\n".
"\n".
" ---------- ------------------------------- --------------------------------- \n".
" Message Trigger Next Run \n".
" ---------- ------------------------------- --------------------------------- \n".
" stdClass every first day of next month \w{3}, \d{1,2} \w{3} \d{4} \d{2}:\d{2}:\d{2} (\+|-)\d{4} \n".
" ---------- ------------------------------- --------------------------------- \n".
" ------------------------------- ----------------------------------------------------------- --------------------------------- \n".
" Trigger Provider Next Run \n".
" ------------------------------- ----------------------------------------------------------- --------------------------------- \n".
" every first day of next month Symfony\\\\Component\\\\Scheduler\\\\Trigger\\\\StaticMessageProvider \w{3}, \d{1,2} \w{3} \d{4} \d{2}:\d{2}:\d{2} (\+|-)\d{4} \n".
" ------------------------------- ----------------------------------------------------------- --------------------------------- \n".
"\n/", $tester->getDisplay(true));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Scheduler\Tests\Trigger;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Scheduler\Generator\MessageContext;
use Symfony\Component\Scheduler\Trigger\CallbackMessageProvider;
use Symfony\Component\Scheduler\Trigger\TriggerInterface;

class CallbackMessageProviderTest extends TestCase
{
public function testToString()
{
$context = new MessageContext('test', 'test', $this->createMock(TriggerInterface::class), $this->createMock(\DateTimeImmutable::class));
$messageProvider = new CallbackMessageProvider(fn () => []);
$this->assertEquals([], $messageProvider->getMessages($context));
$this->assertEquals('', $messageProvider->getId());

$messageProvider = new CallbackMessageProvider(fn () => [new \stdClass()], '');
$this->assertEquals([new \stdClass()], $messageProvider->getMessages($context));
$this->assertSame('', $messageProvider->getId());

$messageProvider = new CallbackMessageProvider(fn () => yield new \stdClass(), 'foo');
$this->assertInstanceOf(\Generator::class, $messageProvider->getMessages($context));
$this->assertSame('foo', $messageProvider->getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Scheduler\Trigger;

use Symfony\Component\Scheduler\Generator\MessageContext;

final class CallbackMessageProvider implements MessageProviderInterface
{
private \Closure $callback;

/**
* @param callable(MessageContext): iterable<object> $callback
*/
public function __construct(callable $callback, private string $id = '')
{
$this->callback = $callback(...);
}

public function getMessages(MessageContext $context): iterable
{
return ($this->callback)($context);
}

public function getId(): string
{
return $this->id;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Scheduler\Trigger;

use Symfony\Component\Scheduler\Generator\MessageContext;

interface MessageProviderInterface
{
/**
* @return iterable<object>
*/
public function getMessages(MessageContext $context): iterable;

public function getId(): string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Scheduler\Trigger;

use Symfony\Component\Scheduler\Generator\MessageContext;

final class StaticMessageProvider implements MessageProviderInterface
{
/**
* @param array<object> $messages
*/
public function __construct(
private array $messages,
private string $id = '',
) {
}

public function getMessages(MessageContext $context): iterable
{
return $this->messages;
}

public function getId(): string
{
return $this->id;
}
}