diff --git a/src/Symfony/Component/Messenger/CHANGELOG.md b/src/Symfony/Component/Messenger/CHANGELOG.md index c4eae318d3518..5993e9dbf4d37 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 --- diff --git a/src/Symfony/Component/Messenger/Command/StopWorkersCommand.php b/src/Symfony/Component/Messenger/Command/StopWorkersCommand.php index 85baba37a9a51..9f26371b6efd5 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 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. @@ -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 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 6c00f1025d723..0218b821e63e7 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('The worker is paused and message processing will resume in %d seconds.', $remainingStopSeconds)); + } } public function onWorkerRunning(WorkerRunningEvent $event): void @@ -54,14 +60,19 @@ 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); if (!$cacheItem->isHit()) { // no restart has ever been scheduled - return false; + return null; } - 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..54e81338b6ffc 100644 --- a/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php +++ b/src/Symfony/Component/Messenger/Tests/Command/StopWorkersCommandTest.php @@ -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; } }