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