From 7e9a3baafd7e53b46d5dbc99d95fc29e8b8959f7 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Mon, 27 Mar 2023 13:22:28 -0400 Subject: [PATCH] [Scheduler] add `RecurringMessage::getId()` and prevent duplicates --- .../Scheduler/Generator/MessageContext.php | 1 + .../Scheduler/Generator/MessageGenerator.php | 18 ++++++----- .../Scheduler/Generator/TriggerHeap.php | 8 ++--- .../Component/Scheduler/RecurringMessage.php | 25 ++++++++++++++++ src/Symfony/Component/Scheduler/Schedule.php | 14 ++++++--- .../Messenger/SchedulerTransportTest.php | 7 +++-- .../Scheduler/Tests/RecurringMessageTest.php | 9 ++++++ .../Scheduler/Tests/ScheduleTest.php | 30 +++++++++++++++++++ 8 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 src/Symfony/Component/Scheduler/Tests/ScheduleTest.php diff --git a/src/Symfony/Component/Scheduler/Generator/MessageContext.php b/src/Symfony/Component/Scheduler/Generator/MessageContext.php index 849074c6162ad..84b088d7db3e2 100644 --- a/src/Symfony/Component/Scheduler/Generator/MessageContext.php +++ b/src/Symfony/Component/Scheduler/Generator/MessageContext.php @@ -22,6 +22,7 @@ final class MessageContext { public function __construct( public readonly string $name, + public readonly string $id, public readonly TriggerInterface $trigger, public readonly \DateTimeImmutable $triggeredAt, public readonly ?\DateTimeImmutable $nextTriggerAt = null, diff --git a/src/Symfony/Component/Scheduler/Generator/MessageGenerator.php b/src/Symfony/Component/Scheduler/Generator/MessageGenerator.php index 7b4e6b33684b3..d7a30a2347002 100644 --- a/src/Symfony/Component/Scheduler/Generator/MessageGenerator.php +++ b/src/Symfony/Component/Scheduler/Generator/MessageGenerator.php @@ -13,8 +13,8 @@ use Psr\Clock\ClockInterface; use Symfony\Component\Clock\Clock; +use Symfony\Component\Scheduler\RecurringMessage; use Symfony\Component\Scheduler\Schedule; -use Symfony\Component\Scheduler\Trigger\TriggerInterface; /** * @experimental @@ -49,11 +49,13 @@ public function getMessages(): \Generator $heap = $this->heap($lastTime); while (!$heap->isEmpty() && $heap->top()[0] <= $now) { - /** @var TriggerInterface $trigger */ - /** @var int $index */ /** @var \DateTimeImmutable $time */ - /** @var object $message */ - [$time, $index, $trigger, $message] = $heap->extract(); + /** @var int $index */ + /** @var RecurringMessage $recurringMessage */ + [$time, $index, $recurringMessage] = $heap->extract(); + $id = $recurringMessage->getId(); + $message = $recurringMessage->getMessage(); + $trigger = $recurringMessage->getTrigger(); $yield = true; if ($time < $lastTime) { @@ -64,11 +66,11 @@ public function getMessages(): \Generator } if ($nextTime = $trigger->getNextRunDate($time)) { - $heap->insert([$nextTime, $index, $trigger, $message]); + $heap->insert([$nextTime, $index, $recurringMessage]); } if ($yield) { - yield (new MessageContext($this->name, $trigger, $time, $nextTime)) => $message; + yield (new MessageContext($this->name, $id, $trigger, $time, $nextTime)) => $message; $this->checkpoint->save($time, $index); } } @@ -91,7 +93,7 @@ private function heap(\DateTimeImmutable $time): TriggerHeap continue; } - $heap->insert([$nextTime, $index, $recurringMessage->getTrigger(), $recurringMessage->getMessage()]); + $heap->insert([$nextTime, $index, $recurringMessage]); } return $this->triggerHeap = $heap; diff --git a/src/Symfony/Component/Scheduler/Generator/TriggerHeap.php b/src/Symfony/Component/Scheduler/Generator/TriggerHeap.php index abd68bdd43146..4f05a76f068cc 100644 --- a/src/Symfony/Component/Scheduler/Generator/TriggerHeap.php +++ b/src/Symfony/Component/Scheduler/Generator/TriggerHeap.php @@ -11,12 +11,12 @@ namespace Symfony\Component\Scheduler\Generator; -use Symfony\Component\Scheduler\Trigger\TriggerInterface; +use Symfony\Component\Scheduler\RecurringMessage; /** * @internal * - * @extends \SplHeap + * @extends \SplHeap * * @experimental */ @@ -28,8 +28,8 @@ public function __construct( } /** - * @param array{\DateTimeImmutable, int, TriggerInterface, object} $value1 - * @param array{\DateTimeImmutable, int, TriggerInterface, object} $value2 + * @param array{\DateTimeImmutable, int, RecurringMessage} $value1 + * @param array{\DateTimeImmutable, int, RecurringMessage} $value2 */ protected function compare(mixed $value1, mixed $value2): int { diff --git a/src/Symfony/Component/Scheduler/RecurringMessage.php b/src/Symfony/Component/Scheduler/RecurringMessage.php index 3553e96119bf8..e802970e8de52 100644 --- a/src/Symfony/Component/Scheduler/RecurringMessage.php +++ b/src/Symfony/Component/Scheduler/RecurringMessage.php @@ -22,6 +22,8 @@ */ final class RecurringMessage { + private string $id; + private function __construct( private readonly TriggerInterface $trigger, private readonly object $message, @@ -65,6 +67,29 @@ public function withJitter(int $maxSeconds = 60): self return new self(new JitterTrigger($this->trigger, $maxSeconds), $this->message); } + /** + * Unique identifier for this message's context. + */ + public function getId(): string + { + if (isset($this->id)) { + 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->trigger::class, + (string) $this->trigger, + ])); + } + public function getMessage(): object { return $this->message; diff --git a/src/Symfony/Component/Scheduler/Schedule.php b/src/Symfony/Component/Scheduler/Schedule.php index 49b2abdb995a4..cac22ba47e683 100644 --- a/src/Symfony/Component/Scheduler/Schedule.php +++ b/src/Symfony/Component/Scheduler/Schedule.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Scheduler; use Symfony\Component\Lock\LockInterface; +use Symfony\Component\Scheduler\Exception\LogicException; use Symfony\Contracts\Cache\CacheInterface; /** @@ -19,7 +20,7 @@ */ final class Schedule implements ScheduleProviderInterface { - /** @var array */ + /** @var array */ private array $messages = []; private ?LockInterface $lock = null; private ?CacheInterface $state = null; @@ -29,8 +30,13 @@ final class Schedule implements ScheduleProviderInterface */ public function add(RecurringMessage $message, RecurringMessage ...$messages): static { - $this->messages[] = $message; - $this->messages = array_merge($this->messages, $messages); + foreach ([$message, ...$messages] as $m) { + if (isset($this->messages[$m->getId()])) { + throw new LogicException('Duplicated schedule message.'); + } + + $this->messages[$m->getId()] = $m; + } return $this; } @@ -70,7 +76,7 @@ public function getState(): ?CacheInterface */ public function getRecurringMessages(): array { - return $this->messages; + return array_values($this->messages); } /** diff --git a/src/Symfony/Component/Scheduler/Tests/Messenger/SchedulerTransportTest.php b/src/Symfony/Component/Scheduler/Tests/Messenger/SchedulerTransportTest.php index 3c106626d1336..d4294ec470dad 100644 --- a/src/Symfony/Component/Scheduler/Tests/Messenger/SchedulerTransportTest.php +++ b/src/Symfony/Component/Scheduler/Tests/Messenger/SchedulerTransportTest.php @@ -34,16 +34,17 @@ public function testGetFromIterator() $generator->method('getMessages')->willReturnCallback(function () use ($messages): \Generator { $trigger = $this->createMock(TriggerInterface::class); $triggerAt = new \DateTimeImmutable('2020-02-20T02:00:00', new \DateTimeZone('UTC')); - yield (new MessageContext('default', $trigger, $triggerAt)) => $messages[0]; - yield (new MessageContext('default', $trigger, $triggerAt)) => $messages[1]; + yield (new MessageContext('default', 'id1', $trigger, $triggerAt)) => $messages[0]; + yield (new MessageContext('default', 'id2', $trigger, $triggerAt)) => $messages[1]; }); $transport = new SchedulerTransport($generator); - foreach ($transport->get() as $envelope) { + foreach ($transport->get() as $i => $envelope) { $this->assertInstanceOf(Envelope::class, $envelope); $this->assertNotNull($stamp = $envelope->last(ScheduledStamp::class)); $this->assertSame(array_shift($messages), $envelope->getMessage()); $this->assertSame('default', $stamp->messageContext->name); + $this->assertSame('id'.$i + 1, $stamp->messageContext->id); } $this->assertEmpty($messages); diff --git a/src/Symfony/Component/Scheduler/Tests/RecurringMessageTest.php b/src/Symfony/Component/Scheduler/Tests/RecurringMessageTest.php index 390fd007d6bac..7057cb07da7b5 100644 --- a/src/Symfony/Component/Scheduler/Tests/RecurringMessageTest.php +++ b/src/Symfony/Component/Scheduler/Tests/RecurringMessageTest.php @@ -42,4 +42,13 @@ public function testHashedCronContextIsRequiredIfMessageIsNotStringable() RecurringMessage::cron('#midnight', new \stdClass()); } + + public function testUniqueId() + { + $message1 = RecurringMessage::cron('* * * * *', new \stdClass()); + $message2 = RecurringMessage::cron('* 5 * * *', new \stdClass()); + + $this->assertSame($message1->getId(), (clone $message1)->getId()); + $this->assertNotSame($message1->getId(), $message2->getId()); + } } diff --git a/src/Symfony/Component/Scheduler/Tests/ScheduleTest.php b/src/Symfony/Component/Scheduler/Tests/ScheduleTest.php new file mode 100644 index 0000000000000..0dd62695dd952 --- /dev/null +++ b/src/Symfony/Component/Scheduler/Tests/ScheduleTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Scheduler\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Scheduler\Exception\LogicException; +use Symfony\Component\Scheduler\RecurringMessage; +use Symfony\Component\Scheduler\Schedule; + +class ScheduleTest extends TestCase +{ + public function testCannotAddDuplicateMessage() + { + $schedule = new Schedule(); + $schedule->add(RecurringMessage::cron('* * * * *', new \stdClass())); + + $this->expectException(LogicException::class); + + $schedule->add(RecurringMessage::cron('* * * * *', new \stdClass())); + } +}