diff --git a/README.md b/README.md index dcdf16b..76b2717 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ # Worker A small library to build stable, long-running workers that terminate gracefully when limits are exceeded -or a SIGTERM signal is received. Perfect for daemonized console commands running under +or a SIGTERM/SIGINT signal is received. Perfect for daemonized console commands running under Docker, Kubernetes, supervisor or systemd, where the process manager restarts the worker after it exits. ## Features * Configurable run, memory and time [limits](https://patchlevel.dev/docs/worker/latest/getting-started#limits) -* [Graceful shutdown](https://patchlevel.dev/docs/worker/latest/getting-started#graceful-shutdown-on-sigterm) on SIGTERM +* [Graceful shutdown](https://patchlevel.dev/docs/worker/latest/getting-started#graceful-shutdown) on SIGTERM and SIGINT * Extensible via [events and custom listeners](https://patchlevel.dev/docs/worker/latest/events) * [PSR-3 logging](https://patchlevel.dev/docs/worker/latest/getting-started#logging) of the worker lifecycle * Plays well with [Symfony and Laravel console commands](https://patchlevel.dev/docs/worker/latest/integration) diff --git a/docs/events.md b/docs/events.md index eb3997e..f4d3697 100644 --- a/docs/events.md +++ b/docs/events.md @@ -11,7 +11,7 @@ each carrying the worker instance: All [limits](getting-started.md#limits) are implemented as event subscribers (`StopWorkerOnIterationLimitListener`, `StopWorkerOnMemoryLimitListener`, -`StopWorkerOnTimeLimitListener`, `StopWorkerOnSigtermSignalListener`), +`StopWorkerOnTimeLimitListener`, `StopWorkerOnSignalListener`), so you can add your own stop conditions the same way: ```php diff --git a/docs/getting-started.md b/docs/getting-started.md index 5153d0c..97ac7f8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -32,7 +32,7 @@ stopping always happens *between* iterations, so your job is never interrupted h ## Limits All options are optional. Without limits the worker runs until it is stopped -via `$stop()`, `$worker->stop()` or a SIGTERM signal. +via `$stop()`, `$worker->stop()` or a SIGTERM/SIGINT signal. | Option | Type | Description | |---------------|----------|---------------------------------------------------------------------------------------------------------------------------------| @@ -51,11 +51,11 @@ Internally every limit is implemented as an event listener. You can add your own stop conditions the same way, see [events & listeners](events.md). ::: -## Graceful shutdown on SIGTERM +## Graceful shutdown -If the `pcntl` extension is available, the worker automatically registers a SIGTERM handler. -When the process receives SIGTERM (e.g. from `docker stop`, a Kubernetes pod shutdown or supervisor), -the worker finishes the current iteration and then exits cleanly. +If the `pcntl` extension is available, the worker automatically registers a handler for SIGTERM and SIGINT. +When the process receives SIGTERM (e.g. from `docker stop`, a Kubernetes pod shutdown or supervisor) +or SIGINT (e.g. pressing `Ctrl+C`), the worker finishes the current iteration and then exits cleanly. This makes the worker a good fit for process managers that send SIGTERM and restart the process, e.g. to roll out a new version or to keep long-running processes fresh. @@ -64,6 +64,9 @@ e.g. to roll out a new version or to keep long-running processes fresh. Without `ext-pcntl` this feature is not available. ::: +If you need to react to other signals, register the `StopWorkerOnSignalListener` with your own list of signals +on a custom event dispatcher, see [events & listeners](events.md). + ## Sleep `run()` takes a sleep timer in milliseconds (default: `1000`): @@ -78,7 +81,7 @@ the next iteration starts immediately. Pass `0` to disable sleeping entirely. ## Logging The worker logs its lifecycle (start, iteration timings, sleep, stop reason) to the given PSR-3 logger. -Iteration details use the `debug` level; stop reasons (limit exceeded, SIGTERM received) use `info`. +Iteration details use the `debug` level; stop reasons (limit exceeded, signal received) use `info`. With the `ConsoleLogger` from the [Symfony command example](integration.md#symfony), run the command with `-v` to see stop reasons or `-vvv` to see everything. diff --git a/docs/introduction.md b/docs/introduction.md index 9c0b77c..2bc2152 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -1,7 +1,7 @@ # Worker A small library to build stable, long-running workers that terminate gracefully when limits are exceeded -or a SIGTERM signal is received. Perfect for daemonized console commands running under +or a SIGTERM/SIGINT signal is received. Perfect for daemonized console commands running under Docker, Kubernetes, supervisor or systemd, where the process manager restarts the worker after it exits. It was extracted from the [event-sourcing](https://github.com/patchlevel/event-sourcing) library into a separate package. @@ -9,7 +9,7 @@ It was extracted from the [event-sourcing](https://github.com/patchlevel/event-s ## Features * Configurable run, memory and time [limits](getting-started.md#limits) -* [Graceful shutdown](getting-started.md#graceful-shutdown-on-sigterm) on SIGTERM +* [Graceful shutdown](getting-started.md#graceful-shutdown) on SIGTERM and SIGINT * Extensible via [events and custom listeners](events.md) * [PSR-3 logging](getting-started.md#logging) of the worker lifecycle * Plays well with [Symfony and Laravel console commands](integration.md) diff --git a/src/DefaultWorker.php b/src/DefaultWorker.php index aa19e18..02b0b25 100644 --- a/src/DefaultWorker.php +++ b/src/DefaultWorker.php @@ -10,7 +10,7 @@ use Patchlevel\Worker\Event\WorkerStoppedEvent; use Patchlevel\Worker\Listener\StopWorkerOnIterationLimitListener; use Patchlevel\Worker\Listener\StopWorkerOnMemoryLimitListener; -use Patchlevel\Worker\Listener\StopWorkerOnSigtermSignalListener; +use Patchlevel\Worker\Listener\StopWorkerOnSignalListener; use Patchlevel\Worker\Listener\StopWorkerOnTimeLimitListener; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -100,7 +100,7 @@ public static function create( $eventDispatcher = new EventDispatcher(); } - $eventDispatcher->addSubscriber(new StopWorkerOnSigtermSignalListener($logger)); + $eventDispatcher->addSubscriber(new StopWorkerOnSignalListener(logger: $logger)); if (isset($options['runLimit'])) { $eventDispatcher->addSubscriber( diff --git a/src/Listener/StopWorkerOnSignalListener.php b/src/Listener/StopWorkerOnSignalListener.php new file mode 100644 index 0000000..43aa357 --- /dev/null +++ b/src/Listener/StopWorkerOnSignalListener.php @@ -0,0 +1,48 @@ +|null $signals defaults to SIGTERM and SIGINT */ + public function __construct( + private readonly array|null $signals = null, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function onWorkerStarted(WorkerStartedEvent $event): void + { + pcntl_async_signals(true); + + foreach ($this->signals ?? [SIGTERM, SIGINT] as $signal) { + pcntl_signal($signal, function (int $signal) use ($event): void { + $this->logger?->info('Worker received signal {signal}', ['signal' => $signal]); + $event->worker->stop(); + }); + } + } + + /** @return array */ + public static function getSubscribedEvents(): array + { + if (!function_exists('pcntl_signal')) { + return []; + } + + return [WorkerStartedEvent::class => 'onWorkerStarted']; + } +} diff --git a/src/Listener/StopWorkerOnSigtermSignalListener.php b/src/Listener/StopWorkerOnSigtermSignalListener.php index 2ca5147..53a50d8 100644 --- a/src/Listener/StopWorkerOnSigtermSignalListener.php +++ b/src/Listener/StopWorkerOnSigtermSignalListener.php @@ -14,6 +14,7 @@ use const SIGTERM; +/** @deprecated since 1.6, use StopWorkerOnSignalListener instead */ final class StopWorkerOnSigtermSignalListener implements EventSubscriberInterface { public function __construct( diff --git a/tests/Unit/DefaultWorkerTest.php b/tests/Unit/DefaultWorkerTest.php index 1249e1e..6987364 100644 --- a/tests/Unit/DefaultWorkerTest.php +++ b/tests/Unit/DefaultWorkerTest.php @@ -10,7 +10,7 @@ use Patchlevel\Worker\Event\WorkerStartedEvent; use Patchlevel\Worker\Listener\StopWorkerOnIterationLimitListener; use Patchlevel\Worker\Listener\StopWorkerOnMemoryLimitListener; -use Patchlevel\Worker\Listener\StopWorkerOnSigtermSignalListener; +use Patchlevel\Worker\Listener\StopWorkerOnSignalListener; use Patchlevel\Worker\Listener\StopWorkerOnTimeLimitListener; use Patchlevel\Worker\Tests\ReturnCallback; use PHPUnit\Framework\Attributes\CoversClass; @@ -142,7 +142,7 @@ public function testOptions(): void $invokationCount = $this->exactly(4); $invokationParameters = [ - [new StopWorkerOnSigtermSignalListener($logger)], + [new StopWorkerOnSignalListener(logger: $logger)], [new StopWorkerOnIterationLimitListener(10, $logger)], [new StopWorkerOnMemoryLimitListener(Bytes::parseFromString('10KB'), $logger)], [new StopWorkerOnTimeLimitListener(20, $logger)], diff --git a/tests/Unit/Listener/StopWorkerOnSignalListenerTest.php b/tests/Unit/Listener/StopWorkerOnSignalListenerTest.php new file mode 100644 index 0000000..a6d5249 --- /dev/null +++ b/tests/Unit/Listener/StopWorkerOnSignalListenerTest.php @@ -0,0 +1,107 @@ +createMock(LoggerInterface::class); + $logger + ->expects($this->once()) + ->method('info') + ->with('Worker received signal {signal}', ['signal' => SIGTERM]); + + $calls = $this->runWorkerAndSendSignal(new StopWorkerOnSignalListener(logger: $logger), SIGTERM); + + self::assertSame(1, $calls); + } + + public function testStopOnSigintByDefault(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->once()) + ->method('info') + ->with('Worker received signal {signal}', ['signal' => SIGINT]); + + $calls = $this->runWorkerAndSendSignal(new StopWorkerOnSignalListener(logger: $logger), SIGINT); + + self::assertSame(1, $calls); + } + + public function testStopOnCustomSignal(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->once()) + ->method('info') + ->with('Worker received signal {signal}', ['signal' => SIGUSR1]); + + $calls = $this->runWorkerAndSendSignal(new StopWorkerOnSignalListener([SIGUSR1], $logger), SIGUSR1); + + self::assertSame(1, $calls); + } + + /** + * Sends the signal during the first job run. The job stops the worker + * itself after 5 runs, so more than 1 run means the signal was ignored. + */ + private function runWorkerAndSendSignal(StopWorkerOnSignalListener $listener, int $signal): int + { + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addSubscriber($listener); + + $calls = 0; + + $worker = new DefaultWorker( + static function (callable $stop) use (&$calls, $signal): void { + $calls++; + + if ($calls === 1) { + posix_kill((int)getmypid(), $signal); + } + + if ($calls < 5) { + return; + } + + $stop(); + }, + $eventDispatcher, + ); + + $worker->run(0); + + return $calls; + } +}