From 80495ae584c4e6774cd21c9344d8c6f521cef88b Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 2 Oct 2026 18:23:51 +0200 Subject: [PATCH] Use a PSR-20 clock for time measurement DefaultWorker and StopWorkerOnTimeLimitListener accept an optional ClockInterface, falling back to a new SystemClock. This makes time based behaviour testable without reflection or real sleeps. The time limit is now checked with millisecond precision, so the worker stops once the limit is reached instead of up to a second later. --- composer.json | 1 + composer.lock | 98 +++++++++---------- docs/getting-started.md | 21 ++++ src/DefaultWorker.php | 22 +++-- .../StopWorkerOnTimeLimitListener.php | 16 ++- src/SystemClock.php | 16 +++ tests/TestClock.php | 36 +++++++ tests/Unit/DefaultWorkerTest.php | 35 ++----- .../StopWorkerOnTimeLimitListenerTest.php | 27 +++-- tests/Unit/SystemClockTest.php | 24 +++++ 10 files changed, 199 insertions(+), 97 deletions(-) create mode 100644 src/SystemClock.php create mode 100644 tests/TestClock.php create mode 100644 tests/Unit/SystemClockTest.php diff --git a/composer.json b/composer.json index 91fbd05..6b4d77f 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ ], "require": { "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0", "psr/log": "^1.1 || ^2.0 || ^3.0", "symfony/event-dispatcher": "^5.4.26 || ^6.4.0 || ^7.0.0 || ^8.0.0" }, diff --git a/composer.lock b/composer.lock index ca3b884..62caf02 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,56 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1ba9db2e4b9681a4e4fc4e04047375ef", + "content-hash": "b26c13207177c2efab6bb0fa28e7ff7a", "packages": [ + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, { "name": "psr/event-dispatcher", "version": "1.0.0", @@ -3049,54 +3097,6 @@ }, "time": "2021-02-03T23:26:27+00:00" }, - { - "name": "psr/clock", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/clock.git", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", - "shasum": "" - }, - "require": { - "php": "^7.0 || ^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Psr\\Clock\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for reading the clock.", - "homepage": "https://github.com/php-fig/clock", - "keywords": [ - "clock", - "now", - "psr", - "psr-20", - "time" - ], - "support": { - "issues": "https://github.com/php-fig/clock/issues", - "source": "https://github.com/php-fig/clock/tree/1.0.0" - }, - "time": "2022-11-25T14:36:26+00:00" - }, { "name": "psr/container", "version": "2.0.2", diff --git a/docs/getting-started.md b/docs/getting-started.md index 16040fa..d84e8f2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -85,3 +85,24 @@ Iteration details use the `debug` level; stop reasons (limit exceeded, signal re 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. + +## Clock + +The worker uses a [PSR-20](https://www.php-fig.org/psr/psr-20/) clock to measure the job's run time +and to check the `timeLimit`. By default it uses the system clock. You can pass your own clock to `create`, +e.g. a mock clock to test time-dependent behaviour without actually waiting: + +```php +use Patchlevel\Worker\DefaultWorker; +use Symfony\Component\Clock\MockClock; + +$worker = DefaultWorker::create( + $job, + ['timeLimit' => 3600], + $logger, + clock: new MockClock(), +); +``` +:::note +The clock is only used for measuring time. The sleep between iterations still waits for real. +::: diff --git a/src/DefaultWorker.php b/src/DefaultWorker.php index a8458bf..42d5cc3 100644 --- a/src/DefaultWorker.php +++ b/src/DefaultWorker.php @@ -12,30 +12,29 @@ use Patchlevel\Worker\Listener\StopWorkerOnMemoryLimitListener; use Patchlevel\Worker\Listener\StopWorkerOnSignalListener; use Patchlevel\Worker\Listener\StopWorkerOnTimeLimitListener; +use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use function max; -use function microtime; -use function round; use function usleep; final class DefaultWorker implements Worker { private bool $shouldStop = false; - /** @var Closure():int */ - private Closure $timeMeasure; + private readonly ClockInterface $clock; /** @param Closure(Closure):void $job */ public function __construct( private readonly Closure $job, private readonly EventDispatcherInterface $eventDispatcher, private readonly LoggerInterface|null $logger = null, + ClockInterface|null $clock = null, ) { - $this->timeMeasure = static fn () => (int)round(microtime(true) * 1000); + $this->clock = $clock ?? new SystemClock(); } /** @param positive-int|0 $sleepTimer in milliseconds */ @@ -48,11 +47,11 @@ public function run(int $sleepTimer = 1000): void while (!$this->shouldStop) { $this->logger?->debug('Worker starting job run'); - $startTime = ($this->timeMeasure)(); + $startTime = $this->milliseconds(); ($this->job)($this->stop(...)); - $endTime = ($this->timeMeasure)(); + $endTime = $this->milliseconds(); $ranTime = $endTime - $startTime; $this->logger?->debug('Worker finished job run ({ranTime}ms)', ['ranTime' => $ranTime]); @@ -97,6 +96,7 @@ public static function create( array $options = [], LoggerInterface $logger = new NullLogger(), EventDispatcherInterface|null $eventDispatcher = null, + ClockInterface|null $clock = null, ): self { if ($eventDispatcher === null) { $eventDispatcher = new EventDispatcher(); @@ -118,7 +118,7 @@ public static function create( if (isset($options['timeLimit'])) { $eventDispatcher->addSubscriber( - new StopWorkerOnTimeLimitListener($options['timeLimit'], $logger), + new StopWorkerOnTimeLimitListener($options['timeLimit'], $logger, $clock), ); } @@ -126,6 +126,12 @@ public static function create( $job, $eventDispatcher, $logger, + $clock, ); } + + private function milliseconds(): int + { + return (int)$this->clock->now()->format('Uv'); + } } diff --git a/src/Listener/StopWorkerOnTimeLimitListener.php b/src/Listener/StopWorkerOnTimeLimitListener.php index b932ecf..6eb5aa7 100644 --- a/src/Listener/StopWorkerOnTimeLimitListener.php +++ b/src/Listener/StopWorkerOnTimeLimitListener.php @@ -4,32 +4,38 @@ namespace Patchlevel\Worker\Listener; +use DateInterval; +use DateTimeImmutable; use Patchlevel\Worker\Event\WorkerRunningEvent; use Patchlevel\Worker\Event\WorkerStartedEvent; +use Patchlevel\Worker\SystemClock; +use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; -use function time; - final class StopWorkerOnTimeLimitListener implements EventSubscriberInterface { - private float $endTime = 0; + private DateTimeImmutable|null $endTime = null; + + private readonly ClockInterface $clock; /** @param positive-int $timeLimit in seconds */ public function __construct( private readonly int $timeLimit, private readonly LoggerInterface|null $logger = null, + ClockInterface|null $clock = null, ) { + $this->clock = $clock ?? new SystemClock(); } public function onWorkerStarted(): void { - $this->endTime = time() + $this->timeLimit; + $this->endTime = $this->clock->now()->add(new DateInterval('PT' . $this->timeLimit . 'S')); } public function onWorkerRunning(WorkerRunningEvent $event): void { - if ($this->endTime >= time()) { + if ($this->endTime !== null && $this->clock->now() < $this->endTime) { return; } diff --git a/src/SystemClock.php b/src/SystemClock.php new file mode 100644 index 0000000..bbaeae8 --- /dev/null +++ b/src/SystemClock.php @@ -0,0 +1,16 @@ +milliseconds, 1000), $this->milliseconds % 1000)); + + $this->milliseconds += $this->tick; + + return $now; + } + + public function advance(int $milliseconds): void + { + $this->milliseconds += $milliseconds; + } +} diff --git a/tests/Unit/DefaultWorkerTest.php b/tests/Unit/DefaultWorkerTest.php index 5c9660c..2340024 100644 --- a/tests/Unit/DefaultWorkerTest.php +++ b/tests/Unit/DefaultWorkerTest.php @@ -13,10 +13,10 @@ use Patchlevel\Worker\Listener\StopWorkerOnSignalListener; use Patchlevel\Worker\Listener\StopWorkerOnTimeLimitListener; use Patchlevel\Worker\Tests\ReturnCallback; +use Patchlevel\Worker\Tests\TestClock; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -use ReflectionClass; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; @@ -140,12 +140,14 @@ public function testOptions(): void ->expects($this->never()) ->method('debug'); + $clock = new TestClock(); + $invokationCount = $this->exactly(4); $invokationParameters = [ [new StopWorkerOnSignalListener(logger: $logger)], [new StopWorkerOnIterationLimitListener(10, $logger)], [new StopWorkerOnMemoryLimitListener(Bytes::parseFromString('10KB'), $logger)], - [new StopWorkerOnTimeLimitListener(20, $logger)], + [new StopWorkerOnTimeLimitListener(20, $logger, $clock)], ]; $eventDispatcher = $this->createMock(EventDispatcherInterface::class); @@ -167,6 +169,7 @@ static function ($stop): void { ], $logger, $eventDispatcher, + $clock, ); } @@ -193,25 +196,13 @@ public function testRunWorkerSleeping(): void $eventDispatcher = new EventDispatcher(); $eventDispatcher->addSubscriber(new StopWorkerOnIterationLimitListener(2)); - $calls = 0; - $worker = new DefaultWorker( static fn () => null, $eventDispatcher, $logger, + new TestClock(tick: 10), ); - (new ReflectionClass($worker)) - ->getProperty('timeMeasure') - ->setValue( - $worker, - static function () use (&$calls): int { - $calls++; - - return $calls * 10; - }, - ); - $worker->run(200); } @@ -240,25 +231,13 @@ public function testRunWorkerNotSleeping(): void $eventDispatcher = new EventDispatcher(); $eventDispatcher->addSubscriber(new StopWorkerOnIterationLimitListener(2)); - $calls = 0; - $worker = new DefaultWorker( static fn () => null, $eventDispatcher, $logger, + new TestClock(tick: 10), ); - (new ReflectionClass($worker)) - ->getProperty('timeMeasure') - ->setValue( - $worker, - static function () use (&$calls): int { - $calls++; - - return $calls * 10; - }, - ); - $worker->run(5); } diff --git a/tests/Unit/Listener/StopWorkerOnTimeLimitListenerTest.php b/tests/Unit/Listener/StopWorkerOnTimeLimitListenerTest.php index 3b94e98..1467b18 100644 --- a/tests/Unit/Listener/StopWorkerOnTimeLimitListenerTest.php +++ b/tests/Unit/Listener/StopWorkerOnTimeLimitListenerTest.php @@ -6,38 +6,51 @@ use Patchlevel\Worker\Event\WorkerRunningEvent; use Patchlevel\Worker\Listener\StopWorkerOnTimeLimitListener; +use Patchlevel\Worker\Tests\TestClock; use Patchlevel\Worker\Worker; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; - -use function sleep; +use Psr\Log\LoggerInterface; #[CoversClass(StopWorkerOnTimeLimitListener::class)] final class StopWorkerOnTimeLimitListenerTest extends TestCase { - public function testShouldNotStop(): void + public function testShouldNotStopBeforeTimeLimit(): void { $worker = $this->createMock(Worker::class); $worker ->expects($this->never()) ->method('stop'); - $listener = new StopWorkerOnTimeLimitListener(10); + $clock = new TestClock(); + + $listener = new StopWorkerOnTimeLimitListener(10, clock: $clock); $listener->onWorkerStarted(); + + $clock->advance(9_999); + $listener->onWorkerRunning(new WorkerRunningEvent($worker)); } - public function testShouldStop(): void + public function testShouldStopWhenTimeLimitReached(): void { $worker = $this->createMock(Worker::class); $worker ->expects($this->once()) ->method('stop'); - $listener = new StopWorkerOnTimeLimitListener(1); + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->once()) + ->method('info') + ->with('Worker stopped due to time limit of {timeLimit}s exceeded', ['timeLimit' => 10]); + + $clock = new TestClock(); + + $listener = new StopWorkerOnTimeLimitListener(10, $logger, $clock); $listener->onWorkerStarted(); - sleep(2); + $clock->advance(10_000); $listener->onWorkerRunning(new WorkerRunningEvent($worker)); } diff --git a/tests/Unit/SystemClockTest.php b/tests/Unit/SystemClockTest.php new file mode 100644 index 0000000..4fcd16c --- /dev/null +++ b/tests/Unit/SystemClockTest.php @@ -0,0 +1,24 @@ +now(); + $after = new DateTimeImmutable(); + + self::assertGreaterThanOrEqual($before, $now); + self::assertLessThanOrEqual($after, $now); + } +}