From c20196220c06b026240d88c62d8a9663510afd74 Mon Sep 17 00:00:00 2001 From: dwalck Date: Thu, 22 May 2025 20:38:22 +0200 Subject: [PATCH 1/8] Messenger: add duration option to messenger:stop-workers command --- .../Messenger/Command/StopWorkersCommand.php | 22 ++++- .../StopWorkerOnRestartSignalListener.php | 15 +++- .../Tests/Command/StopWorkersCommandTest.php | 19 ++++- .../StopWorkerOnRestartSignalListenerTest.php | 81 ++++++++++++++----- 4 files changed, 114 insertions(+), 23 deletions(-) diff --git a/src/Symfony/Component/Messenger/Command/StopWorkersCommand.php b/src/Symfony/Component/Messenger/Command/StopWorkersCommand.php index 85baba37a9a51..74fde3ec2848f 100644 --- a/src/Symfony/Component/Messenger/Command/StopWorkersCommand.php +++ b/src/Symfony/Component/Messenger/Command/StopWorkersCommand.php @@ -14,7 +14,9 @@ use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidOptionException; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -35,7 +37,9 @@ public function __construct( protected function configure(): void { $this - ->setDefinition([]) + ->setDefinition([ + new InputOption('duration', 'd', InputOption::VALUE_REQUIRED, 'Duration in seconds to keep the workers stopped'), + ]) ->setHelp(<<<'EOF' The %command.name% command sends a signal to stop any messenger:consume processes that are running. @@ -44,6 +48,11 @@ protected function configure(): void Each worker command will finish the message they are currently processing and then exit. Worker commands are *not* automatically restarted: that should be handled by a process control system. + +Use the --duration option to keep the workers in a paused state (not processing messages) for the given duration (in seconds). +During this time, no messages will be handled, and the workers will not resume until the pause period has passed: + + php %command.full_name% --duration=60 EOF ) ; @@ -53,11 +62,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + if (null !== $duration = $input->getOption('duration')) { + if (!is_numeric($duration) || 0 >= $duration) { + throw new InvalidOptionException(\sprintf('Option "duration" must be a positive integer, "%s" passed.', $duration)); + } + } + $cacheItem = $this->restartSignalCachePool->getItem(StopWorkerOnRestartSignalListener::RESTART_REQUESTED_TIMESTAMP_KEY); - $cacheItem->set(microtime(true)); + $cacheItem->set(microtime(true) + $duration); $this->restartSignalCachePool->save($cacheItem); $io->success('Signal successfully sent to stop any running workers.'); + if ($duration > 0) { + $io->info(sprintf('Workers will be stopped for next %s seconds.', $duration)); + } return 0; } diff --git a/src/Symfony/Component/Messenger/EventListener/StopWorkerOnRestartSignalListener.php b/src/Symfony/Component/Messenger/EventListener/StopWorkerOnRestartSignalListener.php index 6c00f1025d723..9f385811c544a 100644 --- a/src/Symfony/Component/Messenger/EventListener/StopWorkerOnRestartSignalListener.php +++ b/src/Symfony/Component/Messenger/EventListener/StopWorkerOnRestartSignalListener.php @@ -32,9 +32,15 @@ public function __construct( ) { } - public function onWorkerStarted(): void + public function onWorkerStarted(WorkerStartedEvent $event): void { $this->workerStartedAt = microtime(true); + + if ($this->shouldRestart()) { + $event->getWorker()->stop(); + $remainingStopSeconds = ceil($this->getEndOfStopTime()) - time(); + $this->logger?->info(sprintf('Worker is stopped and message processing is paused for the next %d seconds.', $remainingStopSeconds)); + } } public function onWorkerRunning(WorkerRunningEvent $event): void @@ -54,6 +60,11 @@ public static function getSubscribedEvents(): array } private function shouldRestart(): bool + { + return $this->workerStartedAt < $this->getEndOfStopTime(); + } + + private function getEndOfStopTime(): float { $cacheItem = $this->cachePool->getItem(self::RESTART_REQUESTED_TIMESTAMP_KEY); @@ -62,6 +73,6 @@ private function shouldRestart(): bool return false; } - return $this->workerStartedAt < $cacheItem->get(); + return (float) $cacheItem->get(); } } diff --git a/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php b/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php index fd5ddae244b70..8553ee0fc169a 100644 --- a/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php +++ b/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php @@ -23,7 +23,7 @@ public function testItSetsCacheItem() { $cachePool = $this->createMock(CacheItemPoolInterface::class); $cacheItem = $this->createMock(CacheItemInterface::class); - $cacheItem->expects($this->once())->method('set'); + $cacheItem->expects($this->once())->method('set')->with(); $cachePool->expects($this->once())->method('getItem')->willReturn($cacheItem); $cachePool->expects($this->once())->method('save')->with($cacheItem); @@ -32,4 +32,21 @@ public function testItSetsCacheItem() $tester = new CommandTester($command); $tester->execute([]); } + + /** + * @group time-sensitive + */ + public function testItSetsCacheItemWithDurationAdded() + { + $cachePool = $this->createMock(CacheItemPoolInterface::class); + $cacheItem = $this->createMock(CacheItemInterface::class); + $cacheItem->expects($this->once())->method('set')->with($this->equalToWithDelta(microtime(true), 62)); // "extra" 2 seconds + $cachePool->expects($this->once())->method('getItem')->willReturn($cacheItem); + $cachePool->expects($this->once())->method('save')->with($cacheItem); + + $command = new StopWorkersCommand($cachePool); + + $tester = new CommandTester($command); + $tester->execute(['--duration' => 60]); + } } diff --git a/src/Symfony/Component/Messenger/Tests/EventListener/StopWorkerOnRestartSignalListenerTest.php b/src/Symfony/Component/Messenger/Tests/EventListener/StopWorkerOnRestartSignalListenerTest.php index 3b83f04268ce5..feabc789136d2 100644 --- a/src/Symfony/Component/Messenger/Tests/EventListener/StopWorkerOnRestartSignalListenerTest.php +++ b/src/Symfony/Component/Messenger/Tests/EventListener/StopWorkerOnRestartSignalListenerTest.php @@ -14,7 +14,9 @@ use PHPUnit\Framework\TestCase; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\DependencyInjection\CachePoolPass; use Symfony\Component\Messenger\Event\WorkerRunningEvent; +use Symfony\Component\Messenger\Event\WorkerStartedEvent; use Symfony\Component\Messenger\EventListener\StopWorkerOnRestartSignalListener; use Symfony\Component\Messenger\Worker; @@ -26,44 +28,87 @@ class StopWorkerOnRestartSignalListenerTest extends TestCase /** * @dataProvider restartTimeProvider */ - public function testWorkerStopsWhenMemoryLimitExceeded(?int $lastRestartTimeOffset, bool $shouldStop) + public function testWorkerStopsOnStartIfRestartInCache(?int $lastRestartTimeOffset, bool $shouldStop) { - $cachePool = $this->createMock(CacheItemPoolInterface::class); - $cacheItem = $this->createMock(CacheItemInterface::class); - $cacheItem->expects($this->once())->method('isHit')->willReturn(true); - $cacheItem->expects($this->once())->method('get')->willReturn(null === $lastRestartTimeOffset ? null : time() + $lastRestartTimeOffset); - $cachePool->expects($this->once())->method('getItem')->willReturn($cacheItem); + $cachePool = $this->createRestartInCachePool($lastRestartTimeOffset); $worker = $this->createMock(Worker::class); $worker->expects($shouldStop ? $this->once() : $this->never())->method('stop'); - $event = new WorkerRunningEvent($worker, false); + $workerStartedEvent = new WorkerStartedEvent($worker); $stopOnSignalListener = new StopWorkerOnRestartSignalListener($cachePool); - $stopOnSignalListener->onWorkerStarted(); - $stopOnSignalListener->onWorkerRunning($event); + $stopOnSignalListener->onWorkerStarted($workerStartedEvent); } - public static function restartTimeProvider() + /** + * @dataProvider restartTimeProvider + */ + public function testWorkerStopsIfRestartInCache(?int $lastRestartTimeOffset, bool $shouldStop) + { + $cachePool = $this->createRestartInCachePool($lastRestartTimeOffset); + + $worker = $this->createMock(Worker::class); + $worker->expects($shouldStop ? $this->atLeast(1) : $this->never())->method('stop'); + $workerStartedEvent = new WorkerStartedEvent($worker); + $workerRunningEvent = new WorkerRunningEvent($worker, false); + + $stopOnSignalListener = new StopWorkerOnRestartSignalListener($cachePool); + $stopOnSignalListener->onWorkerStarted($workerStartedEvent); + $stopOnSignalListener->onWorkerRunning($workerRunningEvent); + } + + public static function restartTimeProvider(): iterable { yield [null, false]; // no cached restart time, do not restart yield [+10, true]; // 10 seconds after starting, a restart was requested yield [-10, false]; // a restart was requested, but 10 seconds before we started } + public function testWorkerDoesNotStopOnStartIfRestartNotInCache() + { + $cachePool = $this->createRestartNotInCachePool(); + + $worker = $this->createMock(Worker::class); + $worker->expects($this->never())->method('stop'); + $workerStartedEvent = new WorkerStartedEvent($worker); + + $stopOnSignalListener = new StopWorkerOnRestartSignalListener($cachePool); + $stopOnSignalListener->onWorkerStarted($workerStartedEvent); + } + public function testWorkerDoesNotStopIfRestartNotInCache() { - $cachePool = $this->createMock(CacheItemPoolInterface::class); - $cacheItem = $this->createMock(CacheItemInterface::class); - $cacheItem->expects($this->once())->method('isHit')->willReturn(false); - $cacheItem->expects($this->never())->method('get'); - $cachePool->expects($this->once())->method('getItem')->willReturn($cacheItem); + $cachePool = $this->createRestartNotInCachePool(); $worker = $this->createMock(Worker::class); $worker->expects($this->never())->method('stop'); - $event = new WorkerRunningEvent($worker, false); + $workerStartedEvent = new WorkerStartedEvent($worker); + $workerRunningEvent = new WorkerRunningEvent($worker, false); $stopOnSignalListener = new StopWorkerOnRestartSignalListener($cachePool); - $stopOnSignalListener->onWorkerStarted(); - $stopOnSignalListener->onWorkerRunning($event); + $stopOnSignalListener->onWorkerStarted($workerStartedEvent); + $stopOnSignalListener->onWorkerRunning($workerRunningEvent); + } + + private function createRestartInCachePool(?int $value): CacheItemPoolInterface + { + $cachePool = $this->createMock(CacheItemPoolInterface::class); + $cacheItem = $this->createMock(CacheItemInterface::class); + $cacheItem->method('isHit')->willReturn(true); + $cacheItem->method('get')->willReturn(null === $value ? null : time() + $value); + $cachePool->method('getItem')->willReturn($cacheItem); + + return $cachePool; + } + + private function createRestartNotInCachePool(): CacheItemPoolInterface + { + $cachePool = $this->createMock(CacheItemPoolInterface::class); + $cacheItem = $this->createMock(CacheItemInterface::class); + $cacheItem->method('isHit')->willReturn(false); + $cacheItem->method('get'); + $cachePool->method('getItem')->willReturn($cacheItem); + + return $cachePool; } } From f07dc0d1c8b75dda9c984c19ef36be8edee01669 Mon Sep 17 00:00:00 2001 From: dwalck Date: Thu, 22 May 2025 21:03:30 +0200 Subject: [PATCH 2/8] Fix psalm --- .../EventListener/StopWorkerOnRestartSignalListener.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/Messenger/EventListener/StopWorkerOnRestartSignalListener.php b/src/Symfony/Component/Messenger/EventListener/StopWorkerOnRestartSignalListener.php index 9f385811c544a..c22d5a6a7425a 100644 --- a/src/Symfony/Component/Messenger/EventListener/StopWorkerOnRestartSignalListener.php +++ b/src/Symfony/Component/Messenger/EventListener/StopWorkerOnRestartSignalListener.php @@ -64,13 +64,13 @@ private function shouldRestart(): bool return $this->workerStartedAt < $this->getEndOfStopTime(); } - private function getEndOfStopTime(): float + private function getEndOfStopTime(): ?float { $cacheItem = $this->cachePool->getItem(self::RESTART_REQUESTED_TIMESTAMP_KEY); if (!$cacheItem->isHit()) { // no restart has ever been scheduled - return false; + return null; } return (float) $cacheItem->get(); From 68ef9d609cb682d4072ad4fe5b3983b645a016a9 Mon Sep 17 00:00:00 2001 From: dwalck Date: Fri, 23 May 2025 08:08:50 +0200 Subject: [PATCH 3/8] undo already existing test change --- .../Messenger/Tests/Command/StopWorkersCommandTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php b/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php index 8553ee0fc169a..54e81338b6ffc 100644 --- a/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php +++ b/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php @@ -23,7 +23,7 @@ public function testItSetsCacheItem() { $cachePool = $this->createMock(CacheItemPoolInterface::class); $cacheItem = $this->createMock(CacheItemInterface::class); - $cacheItem->expects($this->once())->method('set')->with(); + $cacheItem->expects($this->once())->method('set'); $cachePool->expects($this->once())->method('getItem')->willReturn($cacheItem); $cachePool->expects($this->once())->method('save')->with($cacheItem); From 6edae20f8b59b6b3aade67f3c590fc1b6d31eaf7 Mon Sep 17 00:00:00 2001 From: dwalck Date: Thu, 22 May 2025 20:38:22 +0200 Subject: [PATCH 4/8] Messenger: add duration option to messenger:stop-workers command --- .../Messenger/Tests/Command/StopWorkersCommandTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php b/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php index 54e81338b6ffc..8553ee0fc169a 100644 --- a/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php +++ b/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php @@ -23,7 +23,7 @@ public function testItSetsCacheItem() { $cachePool = $this->createMock(CacheItemPoolInterface::class); $cacheItem = $this->createMock(CacheItemInterface::class); - $cacheItem->expects($this->once())->method('set'); + $cacheItem->expects($this->once())->method('set')->with(); $cachePool->expects($this->once())->method('getItem')->willReturn($cacheItem); $cachePool->expects($this->once())->method('save')->with($cacheItem); From 730266b6bee0b1d62c0c1e6ebd04d66dfb1482ab Mon Sep 17 00:00:00 2001 From: dwalck Date: Fri, 23 May 2025 08:22:35 +0200 Subject: [PATCH 5/8] Add changelog --- src/Symfony/Component/Messenger/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Symfony/Component/Messenger/CHANGELOG.md b/src/Symfony/Component/Messenger/CHANGELOG.md index c4eae318d3518..e61a207543b50 100644 --- a/src/Symfony/Component/Messenger/CHANGELOG.md +++ b/src/Symfony/Component/Messenger/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.4 +--- + +* Add `--duration` option to `messenger:stop-workers` command to keep workers in paused state. + 7.3 --- From c6d67aa825c0da396511e2e4472121bc88409b0b Mon Sep 17 00:00:00 2001 From: dwalck Date: Sun, 25 May 2025 18:37:32 +0200 Subject: [PATCH 6/8] Remove with() from modified test --- .../Messenger/Tests/Command/StopWorkersCommandTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php b/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php index 8553ee0fc169a..54e81338b6ffc 100644 --- a/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php +++ b/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php @@ -23,7 +23,7 @@ public function testItSetsCacheItem() { $cachePool = $this->createMock(CacheItemPoolInterface::class); $cacheItem = $this->createMock(CacheItemInterface::class); - $cacheItem->expects($this->once())->method('set')->with(); + $cacheItem->expects($this->once())->method('set'); $cachePool->expects($this->once())->method('getItem')->willReturn($cacheItem); $cachePool->expects($this->once())->method('save')->with($cacheItem); From 57d590f113007c30954384b84a05e359aa01552f Mon Sep 17 00:00:00 2001 From: dwalck Date: Sun, 25 May 2025 18:37:46 +0200 Subject: [PATCH 7/8] Update changelog after review --- src/Symfony/Component/Messenger/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Messenger/CHANGELOG.md b/src/Symfony/Component/Messenger/CHANGELOG.md index e61a207543b50..5993e9dbf4d37 100644 --- a/src/Symfony/Component/Messenger/CHANGELOG.md +++ b/src/Symfony/Component/Messenger/CHANGELOG.md @@ -4,7 +4,7 @@ CHANGELOG 7.4 --- -* Add `--duration` option to `messenger:stop-workers` command to keep workers in paused state. + * Add `--duration` option to `messenger:stop-workers` command to keep workers in paused state 7.3 --- From a75bc99eb8a535068ec4fd41646003835b95cdea Mon Sep 17 00:00:00 2001 From: dwalck Date: Sun, 1 Jun 2025 13:16:53 +0200 Subject: [PATCH 8/8] Changes after fabpot review --- .../Component/Messenger/Command/StopWorkersCommand.php | 6 +++--- .../EventListener/StopWorkerOnRestartSignalListener.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Symfony/Component/Messenger/Command/StopWorkersCommand.php b/src/Symfony/Component/Messenger/Command/StopWorkersCommand.php index 74fde3ec2848f..9f26371b6efd5 100644 --- a/src/Symfony/Component/Messenger/Command/StopWorkersCommand.php +++ b/src/Symfony/Component/Messenger/Command/StopWorkersCommand.php @@ -38,7 +38,7 @@ protected function configure(): void { $this ->setDefinition([ - new InputOption('duration', 'd', InputOption::VALUE_REQUIRED, 'Duration in seconds to keep the workers stopped'), + new InputOption('duration', 'd', InputOption::VALUE_REQUIRED, 'Duration in seconds during which workers are paused (not processing messages)'), ]) ->setHelp(<<<'EOF' The %command.name% command sends a signal to stop any messenger:consume processes that are running. @@ -49,7 +49,7 @@ protected function configure(): void and then exit. Worker commands are *not* automatically restarted: that should be handled by a process control system. -Use the --duration option to keep the workers in a paused state (not processing messages) for the given duration (in seconds). +Use the --duration option to keep the workers in a paused state (not processing messages) for the given duration (in seconds). During this time, no messages will be handled, and the workers will not resume until the pause period has passed: php %command.full_name% --duration=60 @@ -74,7 +74,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->success('Signal successfully sent to stop any running workers.'); if ($duration > 0) { - $io->info(sprintf('Workers will be stopped for next %s seconds.', $duration)); + $io->info(sprintf('Workers will be paused for %s seconds.', $duration)); } return 0; diff --git a/src/Symfony/Component/Messenger/EventListener/StopWorkerOnRestartSignalListener.php b/src/Symfony/Component/Messenger/EventListener/StopWorkerOnRestartSignalListener.php index c22d5a6a7425a..0218b821e63e7 100644 --- a/src/Symfony/Component/Messenger/EventListener/StopWorkerOnRestartSignalListener.php +++ b/src/Symfony/Component/Messenger/EventListener/StopWorkerOnRestartSignalListener.php @@ -39,7 +39,7 @@ public function onWorkerStarted(WorkerStartedEvent $event): void if ($this->shouldRestart()) { $event->getWorker()->stop(); $remainingStopSeconds = ceil($this->getEndOfStopTime()) - time(); - $this->logger?->info(sprintf('Worker is stopped and message processing is paused for the next %d seconds.', $remainingStopSeconds)); + $this->logger?->info(sprintf('The worker is paused and message processing will resume in %d seconds.', $remainingStopSeconds)); } }