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;
}
}