Skip to content

Commit 80495ae

Browse files
committed
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.
1 parent b98385f commit 80495ae

10 files changed

Lines changed: 199 additions & 97 deletions

‎composer.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
],
2121
"require": {
2222
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
23+
"psr/clock": "^1.0",
2324
"psr/log": "^1.1 || ^2.0 || ^3.0",
2425
"symfony/event-dispatcher": "^5.4.26 || ^6.4.0 || ^7.0.0 || ^8.0.0"
2526
},

‎composer.lock‎

Lines changed: 49 additions & 49 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/getting-started.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,24 @@ Iteration details use the `debug` level; stop reasons (limit exceeded, signal re
8585

8686
With the `ConsoleLogger` from the [Symfony command example](integration.md#symfony), run the command with `-v`
8787
to see stop reasons or `-vvv` to see everything.
88+
89+
## Clock
90+
91+
The worker uses a [PSR-20](https://www.php-fig.org/psr/psr-20/) clock to measure the job's run time
92+
and to check the `timeLimit`. By default it uses the system clock. You can pass your own clock to `create`,
93+
e.g. a mock clock to test time-dependent behaviour without actually waiting:
94+
95+
```php
96+
use Patchlevel\Worker\DefaultWorker;
97+
use Symfony\Component\Clock\MockClock;
98+
99+
$worker = DefaultWorker::create(
100+
$job,
101+
['timeLimit' => 3600],
102+
$logger,
103+
clock: new MockClock(),
104+
);
105+
```
106+
:::note
107+
The clock is only used for measuring time. The sleep between iterations still waits for real.
108+
:::

‎src/DefaultWorker.php‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,30 +12,29 @@
1212
use Patchlevel\Worker\Listener\StopWorkerOnMemoryLimitListener;
1313
use Patchlevel\Worker\Listener\StopWorkerOnSignalListener;
1414
use Patchlevel\Worker\Listener\StopWorkerOnTimeLimitListener;
15+
use Psr\Clock\ClockInterface;
1516
use Psr\Log\LoggerInterface;
1617
use Psr\Log\NullLogger;
1718
use Symfony\Component\EventDispatcher\EventDispatcher;
1819
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
1920

2021
use function max;
21-
use function microtime;
22-
use function round;
2322
use function usleep;
2423

2524
final class DefaultWorker implements Worker
2625
{
2726
private bool $shouldStop = false;
2827

29-
/** @var Closure():int */
30-
private Closure $timeMeasure;
28+
private readonly ClockInterface $clock;
3129

3230
/** @param Closure(Closure):void $job */
3331
public function __construct(
3432
private readonly Closure $job,
3533
private readonly EventDispatcherInterface $eventDispatcher,
3634
private readonly LoggerInterface|null $logger = null,
35+
ClockInterface|null $clock = null,
3736
) {
38-
$this->timeMeasure = static fn () => (int)round(microtime(true) * 1000);
37+
$this->clock = $clock ?? new SystemClock();
3938
}
4039

4140
/** @param positive-int|0 $sleepTimer in milliseconds */
@@ -48,11 +47,11 @@ public function run(int $sleepTimer = 1000): void
4847
while (!$this->shouldStop) {
4948
$this->logger?->debug('Worker starting job run');
5049

51-
$startTime = ($this->timeMeasure)();
50+
$startTime = $this->milliseconds();
5251

5352
($this->job)($this->stop(...));
5453

55-
$endTime = ($this->timeMeasure)();
54+
$endTime = $this->milliseconds();
5655
$ranTime = $endTime - $startTime;
5756

5857
$this->logger?->debug('Worker finished job run ({ranTime}ms)', ['ranTime' => $ranTime]);
@@ -97,6 +96,7 @@ public static function create(
9796
array $options = [],
9897
LoggerInterface $logger = new NullLogger(),
9998
EventDispatcherInterface|null $eventDispatcher = null,
99+
ClockInterface|null $clock = null,
100100
): self {
101101
if ($eventDispatcher === null) {
102102
$eventDispatcher = new EventDispatcher();
@@ -118,14 +118,20 @@ public static function create(
118118

119119
if (isset($options['timeLimit'])) {
120120
$eventDispatcher->addSubscriber(
121-
new StopWorkerOnTimeLimitListener($options['timeLimit'], $logger),
121+
new StopWorkerOnTimeLimitListener($options['timeLimit'], $logger, $clock),
122122
);
123123
}
124124

125125
return new self(
126126
$job,
127127
$eventDispatcher,
128128
$logger,
129+
$clock,
129130
);
130131
}
132+
133+
private function milliseconds(): int
134+
{
135+
return (int)$this->clock->now()->format('Uv');
136+
}
131137
}

‎src/Listener/StopWorkerOnTimeLimitListener.php‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,38 @@
44

55
namespace Patchlevel\Worker\Listener;
66

7+
use DateInterval;
8+
use DateTimeImmutable;
79
use Patchlevel\Worker\Event\WorkerRunningEvent;
810
use Patchlevel\Worker\Event\WorkerStartedEvent;
11+
use Patchlevel\Worker\SystemClock;
12+
use Psr\Clock\ClockInterface;
913
use Psr\Log\LoggerInterface;
1014
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
1115

12-
use function time;
13-
1416
final class StopWorkerOnTimeLimitListener implements EventSubscriberInterface
1517
{
16-
private float $endTime = 0;
18+
private DateTimeImmutable|null $endTime = null;
19+
20+
private readonly ClockInterface $clock;
1721

1822
/** @param positive-int $timeLimit in seconds */
1923
public function __construct(
2024
private readonly int $timeLimit,
2125
private readonly LoggerInterface|null $logger = null,
26+
ClockInterface|null $clock = null,
2227
) {
28+
$this->clock = $clock ?? new SystemClock();
2329
}
2430

2531
public function onWorkerStarted(): void
2632
{
27-
$this->endTime = time() + $this->timeLimit;
33+
$this->endTime = $this->clock->now()->add(new DateInterval('PT' . $this->timeLimit . 'S'));
2834
}
2935

3036
public function onWorkerRunning(WorkerRunningEvent $event): void
3137
{
32-
if ($this->endTime >= time()) {
38+
if ($this->endTime !== null && $this->clock->now() < $this->endTime) {
3339
return;
3440
}
3541

‎src/SystemClock.php‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Worker;
6+
7+
use DateTimeImmutable;
8+
use Psr\Clock\ClockInterface;
9+
10+
final class SystemClock implements ClockInterface
11+
{
12+
public function now(): DateTimeImmutable
13+
{
14+
return new DateTimeImmutable();
15+
}
16+
}

‎tests/TestClock.php‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Worker\Tests;
6+
7+
use DateTimeImmutable;
8+
use Psr\Clock\ClockInterface;
9+
10+
use function intdiv;
11+
use function sprintf;
12+
13+
final class TestClock implements ClockInterface
14+
{
15+
private int $milliseconds = 1_767_225_600_000;
16+
17+
/** @param int $tick milliseconds the clock moves forward on every now() call */
18+
public function __construct(
19+
private readonly int $tick = 0,
20+
) {
21+
}
22+
23+
public function now(): DateTimeImmutable
24+
{
25+
$now = new DateTimeImmutable(sprintf('@%d.%03d', intdiv($this->milliseconds, 1000), $this->milliseconds % 1000));
26+
27+
$this->milliseconds += $this->tick;
28+
29+
return $now;
30+
}
31+
32+
public function advance(int $milliseconds): void
33+
{
34+
$this->milliseconds += $milliseconds;
35+
}
36+
}

0 commit comments

Comments
 (0)