From 7afcfe76e2f1bf56793e33fc3af18f4bb778d034 Mon Sep 17 00:00:00 2001 From: "D. Bellettini" <325358+dbellettini@users.noreply.github.com> Date: Sun, 17 Aug 2025 23:44:18 +0200 Subject: [PATCH 1/4] Increase PHPStan level to 6 --- phpstan.neon | 5 +- src/Recruiter/Cleaner.php | 6 +- src/Recruiter/Factory.php | 3 + .../Command/Bko/AnalyticsCommand.php | 11 +- .../Command/Bko/RemoveSchedulerCommand.php | 22 +- .../Command/RecruiterCommand.php | 11 +- .../Filesystem/BootstrapFile.php | 4 +- src/Recruiter/Job.php | 190 ++++++++++++++++-- src/Recruiter/Job/Event.php | 81 ++++++++ src/Recruiter/Job/EventListener.php | 2 +- src/Recruiter/Job/Repository.php | 134 ++++++++++-- src/Recruiter/JobAfterFailure.php | 9 +- src/Recruiter/JobExecution.php | 28 ++- src/Recruiter/JobToSchedule.php | 31 ++- src/Recruiter/ProcessTable.php | 2 +- src/Recruiter/Recruiter.php | 49 ++++- src/Recruiter/RepeatableBehaviour.php | 2 +- src/Recruiter/RepeatableInJob.php | 14 +- src/Recruiter/RetryPolicy.php | 4 +- .../RetryPolicy/ExponentialBackoff.php | 10 +- .../RetryPolicy/RetriableExceptionFilter.php | 54 +++-- src/Recruiter/RetryPolicy/RetryManyTimes.php | 2 +- src/Recruiter/RetryPolicy/TimeTable.php | 9 +- src/Recruiter/RetryPolicyBehaviour.php | 23 ++- src/Recruiter/RetryPolicyInJob.php | 14 +- src/Recruiter/SchedulePolicy.php | 4 +- src/Recruiter/SchedulePolicyInJob.php | 19 +- src/Recruiter/Scheduler.php | 79 ++++++-- src/Recruiter/Scheduler/Repository.php | 24 ++- src/Recruiter/SynchronousExecutionReport.php | 11 +- src/Recruiter/WaitStrategy.php | 37 +++- src/Recruiter/Workable.php | 4 +- src/Recruiter/Workable/AlwaysSucceed.php | 2 +- .../Workable/ConsumingMemoryCommand.php | 2 +- .../Workable/FactoryMethodCommand.php | 46 +++-- src/Recruiter/Workable/FailsInConstructor.php | 5 +- src/Recruiter/Workable/LazyBones.php | 14 +- .../RecoverRepeatableFromException.php | 8 +- .../Workable/RecoverWorkableFromException.php | 13 +- .../Workable/SampleRepeatableCommand.php | 2 +- src/Recruiter/Workable/ShellCommand.php | 4 +- src/Recruiter/WorkableBehaviour.php | 8 + src/Recruiter/WorkableInJob.php | 40 +++- src/Recruiter/Worker.php | 97 +++++++-- src/Recruiter/Worker/Repository.php | 7 +- src/Recruiter/functions.php | 15 +- src/Timeless/Interval.php | 42 ++-- src/Timeless/Moment.php | 5 + src/Timeless/MongoDate.php | 2 +- .../Acceptance/BaseAcceptanceTestCase.php | 48 ++++- tests/Recruiter/Acceptance/EnduranceTest.php | 27 ++- tests/Recruiter/Acceptance/HooksTest.php | 3 + .../RepeatableJobsAreScheduledTest.php | 30 ++- .../Acceptance/SyncronousExecutionTest.php | 11 +- ...rGuaranteedToExitWhenAMemoryLeakOccurs.php | 7 +- ...rkableImplementsFinalizerInterfaceTest.php | 5 +- tests/Recruiter/Job/EventTest.php | 19 ++ tests/Recruiter/Job/RepositoryTest.php | 14 +- .../JobCallCustomMethodOnWorkableTest.php | 2 +- .../Recruiter/JobSendEventsToWorkableTest.php | 7 +- .../JobToBePassedRetryStatisticsTest.php | 20 +- tests/Recruiter/JobToScheduleTest.php | 16 +- tests/Recruiter/PickAvailableWorkersTest.php | 49 ++++- tests/Recruiter/RetryPolicy/TimeTableTest.php | 9 +- tests/Recruiter/SchedulePolicy/CronTest.php | 4 +- tests/Recruiter/TaggableWorkableTest.php | 16 +- tests/Recruiter/WaitStrategyTest.php | 2 +- .../Workable/FactoryMethodCommandTest.php | 11 +- tests/Timeless/IntervalParseTest.php | 2 +- tests/Timeless/MongoDateTest.php | 2 +- 70 files changed, 1228 insertions(+), 286 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index a9463112..784e4fa8 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 5 + level: 6 paths: - src - tests @@ -9,6 +9,7 @@ parameters: - '#is_callable.*recruiter_#' - '#recruiter_became_master#' - '#recruiter_stept_back#' - - '#afterSuccess.*0 required#' - '#Unsafe usage of new static\(\).#' + - '#Call to an undefined method.*FactoryMethodCommand::#' inferPrivatePropertyTypeFromConstructor: true + treatPhpDocTypesAsCertain: false diff --git a/src/Recruiter/Cleaner.php b/src/Recruiter/Cleaner.php index 9fd2796c..e02e3a5d 100644 --- a/src/Recruiter/Cleaner.php +++ b/src/Recruiter/Cleaner.php @@ -14,14 +14,14 @@ public function __construct(private readonly Repository $repository) { } - public function cleanArchived(Interval $gracePeriod) + public function cleanArchived(Interval $gracePeriod): int { $upperLimit = T\now()->before($gracePeriod); return $this->repository->cleanArchived($upperLimit); } - public function cleanScheduled(?Interval $gracePeriod = null) + public function cleanScheduled(?Interval $gracePeriod = null): int { $upperLimit = T\now(); if (!is_null($gracePeriod)) { @@ -31,7 +31,7 @@ public function cleanScheduled(?Interval $gracePeriod = null) return $this->repository->cleanScheduled($upperLimit); } - public function bye() + public function bye(): void { } } diff --git a/src/Recruiter/Factory.php b/src/Recruiter/Factory.php index 34900cd2..c6b7258f 100644 --- a/src/Recruiter/Factory.php +++ b/src/Recruiter/Factory.php @@ -11,6 +11,9 @@ class Factory { + /** + * @param array $options + */ public function getMongoDb(URI $uri, array $options = []): Database { try { diff --git a/src/Recruiter/Infrastructure/Command/Bko/AnalyticsCommand.php b/src/Recruiter/Infrastructure/Command/Bko/AnalyticsCommand.php index c5e70e92..ebb78108 100644 --- a/src/Recruiter/Infrastructure/Command/Bko/AnalyticsCommand.php +++ b/src/Recruiter/Infrastructure/Command/Bko/AnalyticsCommand.php @@ -45,11 +45,12 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - /** @var string */ + /** @var string $target */ $target = $input->getOption('target'); $db = $this->factory->getMongoDb(MongoURI::from($target)); $recruiter = new Recruiter($db); + /** @var ?string $group */ $group = $input->getOption('group'); $analytics = $recruiter->analytics($group); @@ -77,6 +78,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::SUCCESS; } + /** + * @param array{ + * jobs: array{queued: int, postponed: int, zombies: int}, + * throughput: array{value: float, value_per_second: float}, + * latency: array{average: float}, + * execution_time: array{average: float} + * } $analytics + */ private function calculateColumnsWidth(array $analytics): int { $maxColumns = 1; diff --git a/src/Recruiter/Infrastructure/Command/Bko/RemoveSchedulerCommand.php b/src/Recruiter/Infrastructure/Command/Bko/RemoveSchedulerCommand.php index df3aa187..87df03c9 100644 --- a/src/Recruiter/Infrastructure/Command/Bko/RemoveSchedulerCommand.php +++ b/src/Recruiter/Infrastructure/Command/Bko/RemoveSchedulerCommand.php @@ -20,17 +20,14 @@ class RemoveSchedulerCommand extends Command { - /** - * @var SchedulerRepository - */ - private $schedulerRepository; + private SchedulerRepository $schedulerRepository; public function __construct(private readonly Factory $factory, private readonly LoggerInterface $logger) { parent::__construct(); } - protected function configure() + protected function configure(): void { $this ->setName('scheduler:remove') @@ -45,9 +42,9 @@ protected function configure() ; } - protected function initialize(InputInterface $input, OutputInterface $output) + protected function initialize(InputInterface $input, OutputInterface $output): void { - /** @var string */ + /** @var string $target */ $target = $input->getOption('target'); $db = $this->factory->getMongoDb(MongoURI::from($target)); $this->schedulerRepository = new SchedulerRepository($db); @@ -75,7 +72,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::SUCCESS; } - private function selectUrnToDelete(array $urns, InputInterface $input, OutputInterface $output) + /** + * @param string[] $urns + */ + private function selectUrnToDelete(array $urns, InputInterface $input, OutputInterface $output): string|false { /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); @@ -96,6 +96,9 @@ private function selectUrnToDelete(array $urns, InputInterface $input, OutputInt return $selectedUrn; } + /** + * @param array> $data + */ private function printTable(array $data, OutputInterface $output): void { $rows = []; @@ -114,6 +117,9 @@ private function printTable(array $data, OutputInterface $output): void echo PHP_EOL; } + /** + * @return ?array> + */ protected function buildOutputData(): ?array { $outputData = []; diff --git a/src/Recruiter/Infrastructure/Command/RecruiterCommand.php b/src/Recruiter/Infrastructure/Command/RecruiterCommand.php index 287ab84c..3b6630e4 100644 --- a/src/Recruiter/Infrastructure/Command/RecruiterCommand.php +++ b/src/Recruiter/Infrastructure/Command/RecruiterCommand.php @@ -18,6 +18,7 @@ use Recruiter\Geezer\Timing\WaitStrategy; use Recruiter\Infrastructure\Filesystem\BootstrapFile; use Recruiter\Infrastructure\Memory\MemoryLimit; +use Recruiter\Infrastructure\Memory\MemoryLimitExceededException; use Recruiter\Infrastructure\Persistence\Mongodb\URI as MongoURI; use Recruiter\Recruiter; use Symfony\Component\Console\Input\InputDefinition; @@ -42,6 +43,9 @@ public static function toRobustCommand(Factory $factory, LoggerInterface $logger return new RobustCommandRunner(new static($factory, $logger), $logger); } + /** + * @throws MemoryLimitExceededException + */ public function execute(): bool { $this->rollbackLockedJobs(); @@ -62,6 +66,11 @@ private function rollbackLockedJobs(): void $this->log(sprintf('rolled back %d jobs in %fms', $rolledBack, ($rollbackEndAt - $rollbackStartAt) * 1000), $logLevel); } + /** + * @return array + * + * @throws MemoryLimitExceededException + */ private function assignJobsToWorkers(): array { $pickStartAt = microtime(true); @@ -182,7 +191,7 @@ public function init(InputInterface $input): void $this->recruiter->createCollectionsAndIndexes(); if ($input->getOption('bootstrap')) { - /** @var string */ + /** @var string $bootstrap */ $bootstrap = $input->getOption('bootstrap'); BootstrapFile::fromFilePath($bootstrap)->load($this->recruiter); } diff --git a/src/Recruiter/Infrastructure/Filesystem/BootstrapFile.php b/src/Recruiter/Infrastructure/Filesystem/BootstrapFile.php index 860d6fb1..84603982 100644 --- a/src/Recruiter/Infrastructure/Filesystem/BootstrapFile.php +++ b/src/Recruiter/Infrastructure/Filesystem/BootstrapFile.php @@ -21,9 +21,9 @@ public static function fromFilePath(string $filePath): self return new static($filePath); } - public function load(Recruiter $recruiter) + public function load(Recruiter $recruiter): void { - return require $this->filePath; + require $this->filePath; } private function validate(string $filePath): void diff --git a/src/Recruiter/Job.php b/src/Recruiter/Job.php index 67acfa1f..3d64c740 100644 --- a/src/Recruiter/Job.php +++ b/src/Recruiter/Job.php @@ -5,6 +5,7 @@ namespace Recruiter; use MongoDB\BSON\ObjectId; +use MongoDB\BSON\UTCDateTime; use MongoDB\Collection as MongoCollection; use MongoDB\Driver\Exception\BulkWriteException; use Recruiter\Exception\ImportException; @@ -30,9 +31,42 @@ public static function around(Workable $workable, Repository $repository): self } /** + * @param array{ + * _id: ObjectId, + * done: bool, + * created_at: UTCDateTime, + * scheduled_at?: UTCDateTime, + * locked: bool, + * attempts: int, + * group: string, + * tags?: string[], + * workable: array{ + * method: string, + * class?: class-string, + * parameters?: array, + * }, + * last_execution?: array{ + * started_at: UTCDateTime, + * ended_at: UTCDateTime, + * crashed: bool, + * duration: int, + * result: mixed, + * class?: class-string, + * message?: string, + * trace?: string, + * }, + * scheduled?: array{ + * by: array{ + * namespace: string, + * urn: string, + * }, + * executions: int, + * }, + * } $document + * * @throws ImportException */ - public static function import($document, Repository $repository): self + public static function import(array $document, Repository $repository): self { return new self( $document, @@ -43,6 +77,33 @@ public static function import($document, Repository $repository): self ); } + /** + * @param array{ + * _id: ObjectId, + * done: bool, + * created_at: UTCDateTime, + * scheduled_at?: UTCDateTime, + * locked: bool, + * attempts: int, + * group: string, + * tags?: string[], + * workable: array{ + * method: string, + * class?: class-string, + * parameters?: array, + * }, + * last_execution?: array{ + * started_at: UTCDateTime, + * ended_at: UTCDateTime, + * crashed: bool, + * duration: int, + * result: mixed, + * class?: class-string, + * message?: string, + * trace?: string, + * }, + * } $status + */ public function __construct( private array $status, private readonly Workable $workable, @@ -52,7 +113,7 @@ public function __construct( ) { } - public function id() + public function id(): ObjectId { return $this->status['_id']; } @@ -67,7 +128,10 @@ public function numberOfAttempts(): int return $this->status['attempts']; } - public function retryWithPolicy(RetryPolicy $retryPolicy) + /** + * @return $this + */ + public function retryWithPolicy(RetryPolicy $retryPolicy): static { $this->retryPolicy = $retryPolicy; @@ -75,6 +139,8 @@ public function retryWithPolicy(RetryPolicy $retryPolicy) } /** + * @param string[] $tags + * * @return $this */ public function taggedAs(array $tags): static @@ -86,6 +152,11 @@ public function taggedAs(array $tags): static return $this; } + /** + * @param string[]|string $group + * + * @return $this + */ public function inGroup(array|string $group): static { if (is_array($group)) { @@ -137,7 +208,10 @@ public function scheduledBy(string $namespace, string $id, int $executions): sta return $this; } - public function methodToCallOnWorkable($method): void + /** + * @throws \Exception + */ + public function methodToCallOnWorkable(string $method): void { if (!method_exists($this->workable, $method)) { throw new \Exception("Unknown method '$method' on workable instance"); @@ -161,6 +235,23 @@ public function execute(EventDispatcherInterface $eventDispatcher): JobExecution return $this->lastJobExecution; } + /** + * @return array{ + * job_id: string, + * retry_number: int, + * is_last_retry: bool, + * last_execution: ?array{ + * started_at: UTCDateTime, + * ended_at: UTCDateTime, + * crashed: bool, + * duration: int, + * result: mixed, + * class?: class-string, + * message?: string, + * trace?: string, + * }, + * } + */ public function retryStatistics(): array { return [ @@ -178,7 +269,7 @@ public function save(): void $this->repository->save($this); } - public function archive($why): void + public function archive(string $why): void { $this->status['why'] = $why; $this->status['locked'] = false; @@ -186,6 +277,42 @@ public function archive($why): void $this->repository->archive($this); } + /** + * @return array{ + * _id: ObjectId, + * done: bool, + * created_at: UTCDateTime, + * scheduled_at?: UTCDateTime, + * locked: bool, + * attempts: int, + * group: string, + * tags?: string[], + * workable: array{ + * method: string, + * class?: class-string, + * parameters?: array, + * }, + * last_execution?: array{ + * started_at: UTCDateTime, + * ended_at: UTCDateTime, + * crashed: bool, + * duration: int, + * result: mixed, + * class?: class-string, + * message?: string, + * trace?: string, + * }, + * scheduled?: array{ + * by: array{ + * namespace: string, + * urn: string, + * }, + * executions: int, + * }, + * retry_policy?: array, + * why?: string + * } + */ public function export(): array { return array_merge( @@ -216,7 +343,7 @@ public function beforeExecution(EventDispatcherInterface $eventDispatcher): stat /** * @return $this */ - public function afterExecution($result, EventDispatcherInterface $eventDispatcher): static + public function afterExecution(mixed $result, EventDispatcherInterface $eventDispatcher): static { $this->status['done'] = true; $this->lastJobExecution->completedWith($result); @@ -229,12 +356,12 @@ public function afterExecution($result, EventDispatcherInterface $eventDispatche return $this; } - public function done() + public function done(): bool { return $this->status['done']; } - private function recoverFromCrash(EventDispatcherInterface $eventDispatcher) + private function recoverFromCrash(EventDispatcherInterface $eventDispatcher): bool { if ($this->lastJobExecution->isCrashed()) { return !$archived = $this->afterFailure(new WorkerDiedInTheLineOfDutyException(), $eventDispatcher); @@ -260,7 +387,7 @@ private function afterFailure(\Throwable $exception, EventDispatcherInterface $e return $archived; } - private function emit($eventType, EventDispatcherInterface $eventDispatcher): void + private function emit(string $eventType, EventDispatcherInterface $eventDispatcher): void { $event = new Event($this->export()); $eventDispatcher->dispatch($event, $eventType); @@ -275,7 +402,12 @@ private function emit($eventType, EventDispatcherInterface $eventDispatcher): vo private function triggerOnWorkable(string $method, ?\Throwable $e = null): void { if ($this->workable instanceof Finalizable) { - $this->workable->$method($e); + if (in_array($method, ['afterFailure', 'afterLastFailure'])) { + assert(null !== $e, new \InvalidArgumentException("\$e cannot be null in $method")); + $this->workable->$method($e); + } else { + $this->workable->$method(); + } if (in_array($method, ['afterSuccess', 'afterLastFailure'])) { $this->workable->finalize($e); @@ -297,6 +429,9 @@ private function scheduledAt(): ?Moment return null; } + /** + * @return array{tags?: string[]} + */ private function tagsToUseFor(Workable $workable): array { $tagsToUse = []; @@ -313,7 +448,21 @@ private function tagsToUseFor(Workable $workable): array return []; } - private static function initialize() + /** + * @return array{ + * _id: ObjectId, + * done: bool, + * created_at: UTCDateTime, + * locked: bool, + * attempts: int, + * group: string, + * tags?: string[], + * workable: array{ + * method: string, + * } + * } + */ + private static function initialize(): array { return array_merge( [ @@ -329,7 +478,12 @@ private static function initialize() ); } - public static function pickReadyJobsForWorkers(MongoCollection $collection, $worksOn, $workers) + /** + * @param ObjectId[] $workers + * + * @return ?array{string, ObjectId[], ObjectId[]} + */ + public static function pickReadyJobsForWorkers(MongoCollection $collection, string $worksOn, array $workers): ?array { $jobs = array_column( iterator_to_array( @@ -356,9 +510,14 @@ public static function pickReadyJobsForWorkers(MongoCollection $collection, $wor if (count($jobs) > 0) { return [$worksOn, $workers, $jobs]; } + + return null; } - public static function rollbackLockedNotIn(MongoCollection $collection, array $excluded) + /** + * @param ObjectId[] $excluded + */ + public static function rollbackLockedNotIn(MongoCollection $collection, array $excluded): int { try { $result = $collection->updateMany( @@ -380,7 +539,10 @@ public static function rollbackLockedNotIn(MongoCollection $collection, array $e } } - public static function lockAll(MongoCollection $collection, $jobs) + /** + * @param ObjectId[] $jobs + */ + public static function lockAll(MongoCollection $collection, array $jobs): void { $collection->updateMany( ['_id' => ['$in' => array_values($jobs)]], diff --git a/src/Recruiter/Job/Event.php b/src/Recruiter/Job/Event.php index 9d506bd8..0bcc29c5 100644 --- a/src/Recruiter/Job/Event.php +++ b/src/Recruiter/Job/Event.php @@ -4,14 +4,95 @@ namespace Recruiter\Job; +use MongoDB\BSON\ObjectId; +use MongoDB\BSON\UTCDateTime; use Symfony\Contracts\EventDispatcher; class Event extends EventDispatcher\Event { + /** + * @param array{ + * _id: ObjectId, + * done: bool, + * created_at: UTCDateTime, + * scheduled_at?: UTCDateTime, + * locked: bool, + * attempts: int, + * group: string, + * tags?: string[], + * workable: array{ + * method: string, + * class?: class-string, + * parameters?: array, + * }, + * last_execution?: array{ + * started_at: UTCDateTime, + * ended_at: UTCDateTime, + * crashed: bool, + * duration: int, + * result: mixed, + * class?: class-string, + * message?: string, + * trace?: string, + * }, + * scheduled?: array{ + * by: array{ + * namespace: string, + * urn: string, + * }, + * executions: int, + * }, + * scheduled?: array{ + * by: array{ + * namespace: string, + * urn: string, + * }, + * executions: int, + * }, + * retry_policy?: array, + * why?: string + * } $jobExport + */ public function __construct(private readonly array $jobExport) { } + /** + * @return array{ + * _id: ObjectId, + * done: bool, + * created_at: UTCDateTime, + * scheduled_at?: UTCDateTime, + * locked: bool, + * attempts: int, + * group: string, + * tags?: string[], + * workable: array{ + * method: string, + * class?: class-string, + * parameters?: array, + * }, + * last_execution?: array{ + * started_at: UTCDateTime, + * ended_at: UTCDateTime, + * crashed: bool, + * duration: int, + * result: mixed, + * class?: class-string, + * message?: string, + * trace?: string, + * }, + * scheduled?: array{ + * by: array{ + * namespace: string, + * urn: string, + * }, + * executions: int, + * }, + * retry_policy?: array, + * why?: string + * } + */ public function export(): array { return $this->jobExport; diff --git a/src/Recruiter/Job/EventListener.php b/src/Recruiter/Job/EventListener.php index 043f5e20..d0523e49 100644 --- a/src/Recruiter/Job/EventListener.php +++ b/src/Recruiter/Job/EventListener.php @@ -6,5 +6,5 @@ interface EventListener { - public function onEvent($channel, Event $ev): void; + public function onEvent(string $channel, Event $ev): void; } diff --git a/src/Recruiter/Job/Repository.php b/src/Recruiter/Job/Repository.php index c5e3e22f..b4b541ad 100644 --- a/src/Recruiter/Job/Repository.php +++ b/src/Recruiter/Job/Repository.php @@ -6,6 +6,7 @@ use MongoDB; use MongoDB\BSON\ObjectId; +use MongoDB\BSON\UTCDateTime; use MongoDB\Collection; use MongoDB\Driver\CursorInterface; use Recruiter\Job; @@ -22,6 +23,9 @@ public function __construct(MongoDB\Database $db) $this->archived = $db->selectCollection('archived'); } + /** + * @return Job[] + */ public function all(): array { return $this->map( @@ -85,7 +89,10 @@ public function archive(Job $job): void $this->archived->replaceOne(['_id' => $document['_id']], $document, ['upsert' => true]); } - public function releaseAll($jobIds): int + /** + * @param ObjectId[] $jobIds + */ + public function releaseAll(array $jobIds): int { $result = $this->scheduled->updateMany( ['_id' => ['$in' => $jobIds]], @@ -131,8 +138,11 @@ public function cleanScheduled(T\Moment $upperLimit): int return $result->getDeletedCount(); } + /** + * @param array $query + */ public function queued( - $group = null, + ?string $group = null, ?T\Moment $at = null, ?T\Moment $from = null, array $query = [], @@ -141,6 +151,10 @@ public function queued( $at = T\now(); } + // Make PHPStan happy + $query['scheduled_at'] ??= []; + assert(is_array($query['scheduled_at'])); + $query['scheduled_at']['$lte'] = T\MongoDate::from($at); if (null !== $from) { @@ -151,15 +165,22 @@ public function queued( $query['group'] = $group; } - return $this->scheduled->count($query); + return $this->scheduled->countDocuments($query); } - public function postponed($group = null, ?T\Moment $at = null, array $query = []): int + /** + * @param array $query + */ + public function postponed(?string $group = null, ?T\Moment $at = null, array $query = []): int { if (null === $at) { $at = T\now(); } + // Make PHPStan happy + $query['scheduled_at'] ??= []; + assert(is_array($query['scheduled_at'])); + $query['scheduled_at']['$gt'] = T\MongoDate::from($at); if (null !== $group) { @@ -169,7 +190,10 @@ public function postponed($group = null, ?T\Moment $at = null, array $query = [] return $this->scheduled->countDocuments($query); } - public function scheduledCount($group = null, array $query = []): int + /** + * @param array $query + */ + public function scheduledCount(?string $group = null, array $query = []): int { if (null !== $group) { $query['group'] = $group; @@ -178,8 +202,17 @@ public function scheduledCount($group = null, array $query = []): int return $this->scheduled->countDocuments($query); } - public function queuedGroupedBy($field, array $query = [], $group = null): array + /** + * @param array $query + * + * @return array + */ + public function queuedGroupedBy(string $field, array $query = [], ?string $group = null): array { + // Make PHPStan happy + $query['scheduled_at'] ??= []; + assert(is_array($query['scheduled_at'])); + $query['scheduled_at']['$lte'] = T\MongoDate::from(T\now()); if (null !== $group) { $query['group'] = $group; @@ -194,6 +227,7 @@ public function queuedGroupedBy($field, array $query = [], $group = null): array ]); $distinctAndCount = []; + /** @var array{_id: string, count: int} $r */ foreach ($cursor as $r) { $distinctAndCount[$r['_id']] = $r['count']; } @@ -201,7 +235,23 @@ public function queuedGroupedBy($field, array $query = [], $group = null): array return $distinctAndCount; } - public function recentHistory($group = null, ?T\Moment $at = null, array $query = []): array + /** + * @param array $query + * + * @return array{ + * throughput: array{ + * value: float, + * value_per_second: float, + * }, + * latency: array{ + * average: float, + * }, + * execution_time: array{ + * average: float, + * }, + * } + */ + public function recentHistory(?string $group = null, ?T\Moment $at = null, array $query = []): array { if (null === $at) { $at = T\now(); @@ -238,6 +288,7 @@ public function recentHistory($group = null, ?T\Moment $at = null, array $query ]], ]); + /** @var array{_id: 1, throughput: int, latency: numeric, execution_time: numeric}[] $documents */ $documents = $cursor->toArray(); if (0 === count($documents)) { $throughputPerMinute = 0.0; @@ -268,7 +319,7 @@ public function recentHistory($group = null, ?T\Moment $at = null, array $query public function countSlowRecentJobs( T\Moment $lowerLimit, T\Moment $upperLimit, - $secondsToConsiderJobAsSlow = 5, + int $secondsToConsiderJobAsSlow = 5, ): int { return count( $this->slowArchivedRecentJobs( @@ -292,11 +343,11 @@ public function countRecentJobsWithManyAttempts( return $this->countRecentArchivedOrScheduledJobsWithManyAttempts( $lowerLimit, $upperLimit, - 'archived', + $this->archived, ) + $this->countRecentArchivedOrScheduledJobsWithManyAttempts( $lowerLimit, $upperLimit, - 'scheduled', + $this->scheduled, ); } @@ -309,6 +360,9 @@ public function countDelayedScheduledJobs(T\Moment $lowerLimit): int ]); } + /** + * @return Job[] + */ public function delayedScheduledJobs(T\Moment $lowerLimit): array { return $this->map( @@ -320,6 +374,9 @@ public function delayedScheduledJobs(T\Moment $lowerLimit): array ); } + /** + * @return Job[] + */ public function recentJobsWithManyAttempts( T\Moment $lowerLimit, T\Moment $upperLimit, @@ -328,24 +385,27 @@ public function recentJobsWithManyAttempts( $this->recentArchivedOrScheduledJobsWithManyAttempts( $lowerLimit, $upperLimit, - 'archived', + $this->archived, ), ); $scheduled = $this->map( $this->recentArchivedOrScheduledJobsWithManyAttempts( $lowerLimit, $upperLimit, - 'scheduled', + $this->scheduled, ), ); return array_merge($archived, $scheduled); } + /** + * @return Job[] + */ public function slowRecentJobs( T\Moment $lowerLimit, T\Moment $upperLimit, - $secondsToConsiderJobAsSlow = 5, + int $secondsToConsiderJobAsSlow = 5, ): array { $archived = []; $archivedArray = $this->slowArchivedRecentJobs( @@ -369,10 +429,13 @@ public function slowRecentJobs( return array_merge($archived, $scheduled); } + /** + * @return array + */ private function slowArchivedRecentJobs( T\Moment $lowerLimit, T\Moment $upperLimit, - $secondsToConsiderJobAsSlow, + int $secondsToConsiderJobAsSlow, ): array { return $this->archived->aggregate([ [ @@ -413,10 +476,39 @@ private function slowArchivedRecentJobs( ])->toArray(); } + /** + * @return array{ + * _id: ObjectId, + * execution_time: int, + * done: bool, + * created_at: UTCDateTime, + * locked: bool, + * attempts: int, + * group: string, + * workable: array{ + * method: string, + * class?: class-string, + * parameters?: array, + * }, + * tags?: string[], + * scheduled_at?: UTCDateTime, + * last_execution?: array{ + * started_at: UTCDateTime, + * ended_at: UTCDateTime, + * crashed: bool, + * duration: int, + * result: mixed, + * class?: class-string, + * message?: string, + * trace?: string, + * }, + * retry_policy?: array, + * }[] + */ private function slowScheduledRecentJobs( T\Moment $lowerLimit, T\Moment $upperLimit, - $secondsToConsiderJobAsSlow, + int $secondsToConsiderJobAsSlow, ): array { return $this->scheduled->aggregate([ [ @@ -467,21 +559,21 @@ private function slowScheduledRecentJobs( private function countRecentArchivedOrScheduledJobsWithManyAttempts( T\Moment $lowerLimit, T\Moment $upperLimit, - $collectionName, + Collection $collection, ): int { return count($this->recentArchivedOrScheduledJobsWithManyAttempts( $lowerLimit, $upperLimit, - $collectionName, + $collection, )->toArray()); } private function recentArchivedOrScheduledJobsWithManyAttempts( T\Moment $lowerLimit, T\Moment $upperLimit, - $collectionName, - ) { - return $this->{$collectionName}->find([ + Collection $collection, + ): CursorInterface { + return $collection->find([ 'last_execution.ended_at' => [ '$gte' => T\MongoDate::from($lowerLimit), '$lte' => T\MongoDate::from($upperLimit), @@ -494,6 +586,8 @@ private function recentArchivedOrScheduledJobsWithManyAttempts( /** * @return array + * + * @throws \Exception */ private function map(CursorInterface $cursor): array { diff --git a/src/Recruiter/JobAfterFailure.php b/src/Recruiter/JobAfterFailure.php index ab1cd5a1..8b031e62 100644 --- a/src/Recruiter/JobAfterFailure.php +++ b/src/Recruiter/JobAfterFailure.php @@ -24,7 +24,10 @@ public function createdAt(): Moment return $this->job->createdAt(); } - public function inGroup($group): void + /** + * @param string|string[] $group + */ + public function inGroup(array|string $group): void { $this->job->inGroup($group); $this->job->save(); @@ -42,7 +45,7 @@ public function scheduleAt(Moment $at): void $this->job->save(); } - public function archive($why): void + public function archive(string $why): void { $this->hasBeenArchived = true; $this->job->archive($why); @@ -58,7 +61,7 @@ public function lastExecutionDuration(): Interval return $this->lastJobExecution->duration(); } - public function numberOfAttempts() + public function numberOfAttempts(): int { return $this->job->numberOfAttempts(); } diff --git a/src/Recruiter/JobExecution.php b/src/Recruiter/JobExecution.php index 7857da34..9c3d9cc4 100644 --- a/src/Recruiter/JobExecution.php +++ b/src/Recruiter/JobExecution.php @@ -4,6 +4,7 @@ namespace Recruiter; +use MongoDB\BSON\UTCDateTime; use Timeless as T; class JobExecution @@ -32,13 +33,13 @@ public function failedWith(\Throwable $exception): void $this->failedWith = $exception; } - public function completedWith($result): void + public function completedWith(mixed $result): void { $this->endedAt = T\now(); $this->completedWith = $result; } - public function result() + public function result(): mixed { return $this->completedWith; } @@ -65,6 +66,16 @@ public function duration(): T\Interval return T\seconds(0); } + /** + * @param array{ + * last_execution?: array{ + * crashed?: bool, + * scheduled_at?: UTCDateTime, + * started_at?: UTCDateTime, + * ended_at?: UTCDateTime, + * } + * } $document + */ public static function import(array $document): self { $lastExecution = new self(); @@ -84,6 +95,18 @@ public static function import(array $document): self return $lastExecution; } + /** + * @return array{ + * last_execution?: array{ + * scheduled_at?: UTCDateTime, + * started_at?: UTCDateTime, + * ended_at?: UTCDateTime, + * class?: string, + * message?: string, + * trace?: string, + * } + * } + */ public function export(): array { $exported = []; @@ -117,6 +140,7 @@ private function traceOf(mixed $result): string if ($result instanceof \Throwable) { $trace = $result->getTraceAsString(); } elseif (is_object($result) && method_exists($result, 'trace')) { + /** @var scalar $trace */ $trace = $result->trace(); } elseif (is_object($result)) { $trace = $result::class; diff --git a/src/Recruiter/JobToSchedule.php b/src/Recruiter/JobToSchedule.php index fdfef72e..5d6172b8 100644 --- a/src/Recruiter/JobToSchedule.php +++ b/src/Recruiter/JobToSchedule.php @@ -4,6 +4,7 @@ namespace Recruiter; +use Recruiter\Job\Repository; use Symfony\Component\EventDispatcher\EventDispatcher; use Timeless as T; use Timeless\Interval; @@ -27,9 +28,11 @@ public function doNotRetry(): static } /** + * @param class-string|class-string[] $retriableExceptionTypes + * * @return $this */ - public function retryManyTimes($howManyTimes, Interval $timeToWaitBeforeRetry, $retriableExceptionTypes = []): static + public function retryManyTimes(int $howManyTimes, Interval $timeToWaitBeforeRetry, string|array $retriableExceptionTypes = []): static { $this->job->retryWithPolicy( $this->filterForRetriableExceptions( @@ -42,9 +45,11 @@ public function retryManyTimes($howManyTimes, Interval $timeToWaitBeforeRetry, $ } /** + * @param class-string|class-string[] $retriableExceptionTypes + * * @return $this */ - public function retryWithPolicy(RetryPolicy $retryPolicy, $retriableExceptionTypes = []): static + public function retryWithPolicy(RetryPolicy $retryPolicy, string|array $retriableExceptionTypes = []): static { $this->job->retryWithPolicy( $this->filterForRetriableExceptions( @@ -84,6 +89,8 @@ public function scheduleAt(Moment $momentInTime): static } /** + * @param array|string|null $group + * * @return $this */ public function inGroup(array|string|null $group): static @@ -95,6 +102,9 @@ public function inGroup(array|string|null $group): static return $this; } + /** + * @param array|string $tags + */ public function taggedAs(array|string $tags): static { if (!empty($tags)) { @@ -135,26 +145,37 @@ private function emptyEventDispatcher(): EventDispatcher } /** + * @param array $arguments + * * @throws \Exception */ - public function __call(string $name, array $arguments) + public function __call(string $name, array $arguments): mixed { $this->job->methodToCallOnWorkable($name); return $this->execute(); } + /** + * @return array + */ public function export(): array { return $this->job->export(); } - public static function import($document, $repository): self + /** + * @param array $document + */ + public static function import(array $document, Repository $repository): self { return new self(Job::import($document, $repository)); } - private function filterForRetriableExceptions($retryPolicy, $retriableExceptionTypes) + /** + * @param class-string|class-string[] $retriableExceptionTypes + */ + private function filterForRetriableExceptions(RetryPolicy $retryPolicy, string|array $retriableExceptionTypes): RetryPolicy { if (!is_array($retriableExceptionTypes)) { $retriableExceptionTypes = [$retriableExceptionTypes]; diff --git a/src/Recruiter/ProcessTable.php b/src/Recruiter/ProcessTable.php index ec4d285c..ff571508 100644 --- a/src/Recruiter/ProcessTable.php +++ b/src/Recruiter/ProcessTable.php @@ -6,7 +6,7 @@ class ProcessTable { - public function isAlive($pid) + public function isAlive(int $pid): bool { return posix_kill($pid, 0); } diff --git a/src/Recruiter/Recruiter.php b/src/Recruiter/Recruiter.php index 13c12635..83102446 100644 --- a/src/Recruiter/Recruiter.php +++ b/src/Recruiter/Recruiter.php @@ -5,6 +5,7 @@ namespace Recruiter; use MongoDB; +use MongoDB\BSON\ObjectId; use Recruiter\Infrastructure\Memory\MemoryLimit; use Symfony\Component\EventDispatcher\EventDispatcher; use Timeless as T; @@ -53,15 +54,38 @@ public function scheduled(): int return $this->jobs->scheduledCount(); } - public function queuedGroupedBy($field, array $query = [], $group = null): array + /** + * @param array $query + * + * @return array + */ + public function queuedGroupedBy(string $field, array $query = [], ?string $group = null): array { return $this->jobs->queuedGroupedBy($field, $query, $group); } /** - * @return array + * @param array $query + * + * @return array{ + * jobs: array{ + * queued: int, + * postponed: int, + * zombies: int, + * }, + * throughput: array{ + * value: float, + * value_per_second: float, + * }, + * latency: array{ + * average: float, + * }, + * execution_time: array{ + * average: float, + * }, + * } */ - public function analytics($group = null, ?Moment $at = null, array $query = []): array + public function analytics(?string $group = null, ?Moment $at = null, array $query = []): array { $totalsScheduledJobs = $this->jobs->scheduledCount($group, $query); $queued = $this->jobs->queued($group, $at, $at?->before(T\hour(24)), $query); @@ -103,6 +127,9 @@ public function bye(): void { } + /** + * @return array{array, int} + */ public function assignJobsToWorkers(): array { return $this->assignLockedJobsToWorkers($this->bookJobsForWorkers()); @@ -118,6 +145,8 @@ public function scheduleRepeatableJobs(): void /** * @step + * + * @return array */ public function bookJobsForWorkers(): array { @@ -144,6 +173,10 @@ public function bookJobsForWorkers(): array /** * @step + * + * @param array $bookedJobs + * + * @return array{array, int} */ public function assignLockedJobsToWorkers(array $bookedJobs): array { @@ -169,7 +202,7 @@ public function assignLockedJobsToWorkers(array $bookedJobs): array ]; } - public function scheduledJob($id) + public function scheduledJob(string|ObjectId $id): Job { return $this->jobs->scheduled($id); } @@ -261,7 +294,13 @@ public function createCollectionsAndIndexes(): void ); } - private function combineJobsWithWorkers($jobs, $workers): array + /** + * @param ObjectId[] $jobs + * @param ObjectId[] $workers + * + * @return array{int, ObjectId[], ObjectId[]} + */ + private function combineJobsWithWorkers(array $jobs, array $workers): array { $assignments = min(count($workers), count($jobs)); $workers = array_slice($workers, 0, $assignments); diff --git a/src/Recruiter/RepeatableBehaviour.php b/src/Recruiter/RepeatableBehaviour.php index fbe35f59..a199eadf 100644 --- a/src/Recruiter/RepeatableBehaviour.php +++ b/src/Recruiter/RepeatableBehaviour.php @@ -6,7 +6,7 @@ trait RepeatableBehaviour { - public function asRepeatableJobOf(Recruiter $recruiter) + public function asRepeatableJobOf(Recruiter $recruiter): Scheduler { return $recruiter ->repeatableJobOf($this) diff --git a/src/Recruiter/RepeatableInJob.php b/src/Recruiter/RepeatableInJob.php index 3d4fc70e..e46380f8 100644 --- a/src/Recruiter/RepeatableInJob.php +++ b/src/Recruiter/RepeatableInJob.php @@ -11,9 +11,11 @@ class RepeatableInJob { // TODO: resolve duplication with WorkableInJob /** + * @param array $document + * * @throws ImportException */ - public static function import($document): Repeatable + public static function import(array $document): Repeatable { $dataAboutWorkableObject = [ 'parameters' => null, @@ -44,7 +46,10 @@ public static function import($document): Repeatable } } - public static function export($workable, $methodToCall): array + /** + * @return array + */ + public static function export(Workable $workable, string $methodToCall): array { return [ 'workable' => [ @@ -55,12 +60,15 @@ public static function export($workable, $methodToCall): array ]; } + /** + * @return array{workable: array{method: string}} + */ public static function initialize(): array { return ['workable' => ['method' => 'execute']]; } - private static function classNameOf($repeatable): string + private static function classNameOf(object $repeatable): string { $repeatableClassName = $repeatable::class; if (method_exists($repeatable, 'getClass')) { diff --git a/src/Recruiter/RetryPolicy.php b/src/Recruiter/RetryPolicy.php index 1ffe4842..7210cc72 100644 --- a/src/Recruiter/RetryPolicy.php +++ b/src/Recruiter/RetryPolicy.php @@ -20,13 +20,15 @@ public function schedule(JobAfterFailure $job): void; /** * Export retry policy parameters. + * + * @return array */ public function export(): array; /** * Import retry policy parameters. * - * @param array $parameters Previously exported parameters + * @param array $parameters Previously exported parameters */ public static function import(array $parameters): RetryPolicy; diff --git a/src/Recruiter/RetryPolicy/ExponentialBackoff.php b/src/Recruiter/RetryPolicy/ExponentialBackoff.php index a8364b5b..2a725d3b 100644 --- a/src/Recruiter/RetryPolicy/ExponentialBackoff.php +++ b/src/Recruiter/RetryPolicy/ExponentialBackoff.php @@ -17,12 +17,12 @@ class ExponentialBackoff implements RetryPolicy private Interval $timeToInitiallyWaitBeforeRetry; - public static function forTimes($retryHowManyTimes, $timeToInitiallyWaitBeforeRetry = 60): static + public static function forTimes(int $retryHowManyTimes, int|Interval $timeToInitiallyWaitBeforeRetry = 60): static { return new static($retryHowManyTimes, $timeToInitiallyWaitBeforeRetry); } - public function atFirstWaiting($timeToInitiallyWaitBeforeRetry): static + public function atFirstWaiting(int|Interval $timeToInitiallyWaitBeforeRetry): static { return new static($this->retryHowManyTimes, $timeToInitiallyWaitBeforeRetry); } @@ -31,12 +31,12 @@ public function atFirstWaiting($timeToInitiallyWaitBeforeRetry): static * @params integer $interval in seconds * @params integer $timeToWaitBeforeRetry in seconds */ - public static function forAnInterval($interval, $timeToInitiallyWaitBeforeRetry): static + public static function forAnInterval(int $interval, int|Interval $timeToInitiallyWaitBeforeRetry): static { if (!($timeToInitiallyWaitBeforeRetry instanceof Interval)) { $timeToInitiallyWaitBeforeRetry = T\seconds($timeToInitiallyWaitBeforeRetry); } - $numberOfRetries = round( + $numberOfRetries = (int) round( log($interval / $timeToInitiallyWaitBeforeRetry->seconds()) / log(2), ); @@ -44,7 +44,7 @@ public static function forAnInterval($interval, $timeToInitiallyWaitBeforeRetry) return new static($numberOfRetries, $timeToInitiallyWaitBeforeRetry); } - public function __construct(private $retryHowManyTimes, int|Interval $timeToInitiallyWaitBeforeRetry) + public function __construct(private int $retryHowManyTimes, int|Interval $timeToInitiallyWaitBeforeRetry) { if (!($timeToInitiallyWaitBeforeRetry instanceof Interval)) { $timeToInitiallyWaitBeforeRetry = T\seconds($timeToInitiallyWaitBeforeRetry); diff --git a/src/Recruiter/RetryPolicy/RetriableExceptionFilter.php b/src/Recruiter/RetryPolicy/RetriableExceptionFilter.php index cab8beeb..d6aa658c 100644 --- a/src/Recruiter/RetryPolicy/RetriableExceptionFilter.php +++ b/src/Recruiter/RetryPolicy/RetriableExceptionFilter.php @@ -8,23 +8,24 @@ use Recruiter\JobAfterFailure; use Recruiter\RetryPolicy; -class RetriableExceptionFilter implements RetryPolicy +readonly class RetriableExceptionFilter implements RetryPolicy { - private $retriableExceptions; - /** - * @param string $exceptionClass fully qualified class or interface name + * @param class-string $exceptionClass fully qualified class or interface name * * @return self */ - public static function onlyFor($exceptionClass, RetryPolicy $retryPolicy) + public static function onlyFor(string $exceptionClass, RetryPolicy $retryPolicy) { return new self($retryPolicy, [$exceptionClass]); } - public function __construct(private readonly RetryPolicy $filteredRetryPolicy, array $retriableExceptions = ['Exception']) + /** + * @param class-string[] $retriableExceptions + */ + public function __construct(private RetryPolicy $filteredRetryPolicy, private array $retriableExceptions = ['Exception']) { - $this->retriableExceptions = $this->ensureAreAllExceptions($retriableExceptions); + $this->ensureAreAllExceptions($retriableExceptions); } public function schedule(JobAfterFailure $job): void @@ -36,6 +37,15 @@ public function schedule(JobAfterFailure $job): void } } + /** + * @return array{ + * retriable_exceptions: class-string[], + * filtered_retry_policy: array{ + * class: class-string, + * parameters: array, + * }, + * } + */ public function export(): array { return [ @@ -47,6 +57,15 @@ public function export(): array ]; } + /** + * @param array{ + * retriable_exceptions: class-string[], + * filtered_retry_policy: array{ + * class: class-string, + * parameters: array, + * }, + * } $parameters + */ public static function import(array $parameters): RetryPolicy { $filteredRetryPolicy = $parameters['filtered_retry_policy']; @@ -63,26 +82,27 @@ public function isLastRetry(Job $job): bool return $this->filteredRetryPolicy->isLastRetry($job); } - private function ensureAreAllExceptions($exceptions) + /** + * @param class-string[] $exceptions + */ + private function ensureAreAllExceptions(array $exceptions): void { foreach ($exceptions as $exception) { if (!is_a($exception, 'Throwable', true)) { throw new \InvalidArgumentException("Only subclasses of Exception can be retriable exceptions, '{$exception}' is not"); } } - - return $exceptions; } - private function isExceptionRetriable($exception) + private function isExceptionRetriable(?\Throwable $exception): bool { - if (is_object($exception)) { - return array_any( - $this->retriableExceptions, - fn ($retriableExceptionType) => $exception instanceof $retriableExceptionType, - ); + if (null == $exception) { + return false; } - return false; + return array_any( + $this->retriableExceptions, + fn ($retriableExceptionType) => $exception instanceof $retriableExceptionType, + ); } } diff --git a/src/Recruiter/RetryPolicy/RetryManyTimes.php b/src/Recruiter/RetryPolicy/RetryManyTimes.php index c3935348..a0e630ce 100644 --- a/src/Recruiter/RetryPolicy/RetryManyTimes.php +++ b/src/Recruiter/RetryPolicy/RetryManyTimes.php @@ -25,7 +25,7 @@ public function __construct(private readonly int $retryHowManyTimes, int|Interva $this->timeToWaitBeforeRetry = $timeToWaitBeforeRetry; } - public static function forTimes($retryHowManyTimes, int|Interval $timeToWaitBeforeRetry = 60): static + public static function forTimes(int $retryHowManyTimes, int|Interval $timeToWaitBeforeRetry = 60): static { return new static($retryHowManyTimes, $timeToWaitBeforeRetry); } diff --git a/src/Recruiter/RetryPolicy/TimeTable.php b/src/Recruiter/RetryPolicy/TimeTable.php index 64123201..b78eb76b 100644 --- a/src/Recruiter/RetryPolicy/TimeTable.php +++ b/src/Recruiter/RetryPolicy/TimeTable.php @@ -17,6 +17,8 @@ class TimeTable implements RetryPolicy public readonly int $howManyRetries; /** + * @param array|null $timeTable + * * @throws \Exception */ public function __construct(private ?array $timeTable) @@ -60,20 +62,23 @@ public static function import(array $parameters): RetryPolicy return new self($parameters['time_table']); } - private function hasBeenCreatedLessThan($job, $relativeTime) + private function hasBeenCreatedLessThan(Job|JobAfterFailure $job, string $relativeTime): bool { return $job->createdAt()->isAfter( T\Moment::fromTimestamp(strtotime((string) $relativeTime, T\now()->seconds())), ); } - private function rescheduleIn($job, $relativeTime): void + private function rescheduleIn(JobAfterFailure $job, string $relativeTime): void { $job->scheduleAt( T\Moment::fromTimestamp(strtotime((string) $relativeTime, T\now()->seconds())), ); } + /** + * @param array $timeTable + */ private static function estimateHowManyRetriesIn(array $timeTable): int { $now = T\now()->seconds(); diff --git a/src/Recruiter/RetryPolicyBehaviour.php b/src/Recruiter/RetryPolicyBehaviour.php index 5d1e4194..fc0807ef 100644 --- a/src/Recruiter/RetryPolicyBehaviour.php +++ b/src/Recruiter/RetryPolicyBehaviour.php @@ -8,19 +8,25 @@ trait RetryPolicyBehaviour { - private $parameters; - - public function __construct($parameters = []) + /** + * @param array $parameters + */ + public function __construct(private readonly array $parameters = []) { - $this->parameters = $parameters; } - public function retryOnlyWhenExceptionIs($retriableExceptionType) + /** + * @param class-string $retriableExceptionType + */ + public function retryOnlyWhenExceptionIs(string $retriableExceptionType): RetryPolicy { return new RetriableExceptionFilter($this, [$retriableExceptionType]); } - public function retryOnlyWhenExceptionsAre($retriableExceptionTypes) + /** + * @param class-string[] $retriableExceptionTypes + */ + public function retryOnlyWhenExceptionsAre(array $retriableExceptionTypes): RetryPolicy { return new RetriableExceptionFilter($this, $retriableExceptionTypes); } @@ -35,7 +41,10 @@ public function export(): array return $this->parameters; } - public static function import(array $parameters): RetryPolicy + /** + * @param array $parameters + */ + public static function import(array $parameters): static { return new static($parameters); } diff --git a/src/Recruiter/RetryPolicyInJob.php b/src/Recruiter/RetryPolicyInJob.php index 8b12a633..84ecce23 100644 --- a/src/Recruiter/RetryPolicyInJob.php +++ b/src/Recruiter/RetryPolicyInJob.php @@ -9,9 +9,11 @@ class RetryPolicyInJob { /** + * @param array $document + * * @throws ImportException */ - public static function import($document) + public static function import(array $document): RetryPolicy { if (!array_key_exists('retry_policy', $document)) { throw new ImportException('Unable to import Job without data about RetryPolicy object'); @@ -30,7 +32,10 @@ public static function import($document) return $dataAboutRetryPolicyObject['class']::import($dataAboutRetryPolicyObject['parameters']); } - public static function export($retryPolicy) + /** + * @return array + */ + public static function export(RetryPolicy $retryPolicy): array { return [ 'retry_policy' => [ @@ -40,7 +45,10 @@ public static function export($retryPolicy) ]; } - public static function initialize() + /** + * @return array + */ + public static function initialize(): array { return []; } diff --git a/src/Recruiter/SchedulePolicy.php b/src/Recruiter/SchedulePolicy.php index bd4885d8..db454fb4 100644 --- a/src/Recruiter/SchedulePolicy.php +++ b/src/Recruiter/SchedulePolicy.php @@ -15,13 +15,15 @@ public function next(): Moment; /** * Export schedule policy parameters. + * + * @return array */ public function export(): array; /** * Import schedule policy parameters. * - * @param array $parameters Previously exported parameters + * @param array $parameters Previously exported parameters */ public static function import(array $parameters): SchedulePolicy; } diff --git a/src/Recruiter/SchedulePolicyInJob.php b/src/Recruiter/SchedulePolicyInJob.php index 3999b017..2eb58317 100644 --- a/src/Recruiter/SchedulePolicyInJob.php +++ b/src/Recruiter/SchedulePolicyInJob.php @@ -9,9 +9,11 @@ class SchedulePolicyInJob { /** + * @param array $document + * * @throws ImportException */ - public static function import($document): SchedulePolicy + public static function import(array $document): SchedulePolicy { if (!array_key_exists('schedule_policy', $document)) { throw new ImportException('Unable to import Job without data about SchedulePolicy object'); @@ -30,7 +32,15 @@ public static function import($document): SchedulePolicy return $dataAboutSchedulePolicyObject['class']::import($dataAboutSchedulePolicyObject['parameters']); } - public static function export($schedulePolicy) + /** + * @return array{ + * schedule_policy: array{ + * class: class-string, + * parameters: array, + * }, + * } + */ + public static function export(SchedulePolicy $schedulePolicy): array { return [ 'schedule_policy' => [ @@ -40,7 +50,10 @@ public static function export($schedulePolicy) ]; } - public static function initialize() + /** + * @return array + */ + public static function initialize(): array { return []; } diff --git a/src/Recruiter/Scheduler.php b/src/Recruiter/Scheduler.php index ba872a79..ce582025 100644 --- a/src/Recruiter/Scheduler.php +++ b/src/Recruiter/Scheduler.php @@ -4,9 +4,11 @@ namespace Recruiter; +use MongoDB\BSON\UTCDateTime; use Recruiter\Job\Repository as JobsRepository; use Recruiter\Scheduler\Repository; use Timeless as T; +use Timeless\Moment; class Scheduler { @@ -25,6 +27,9 @@ public static function around(Repeatable $repeatable, Repository $repository): s ); } + /** + * @param array $document + */ public static function import(array $document, Repository $repository): self { return new self( @@ -36,25 +41,50 @@ public static function import(array $document, Repository $repository): self ); } - public function __construct(private array $status, private readonly Repeatable $repeatable, private ?SchedulePolicy $schedulePolicy, private ?RetryPolicy $retryPolicy, private readonly Repository $schedulers) - { + /** + * @param array $status + */ + public function __construct( + private array $status, + private readonly Repeatable $repeatable, + private ?SchedulePolicy $schedulePolicy, + private ?RetryPolicy $retryPolicy, + private readonly Repository $schedulers, + ) { } - public function create() + /** + * @return $this + */ + public function create(): static { $this->schedulers->create($this); return $this; } - public function repeatWithPolicy(SchedulePolicy $schedulePolicy) + /** + * @return $this + */ + public function repeatWithPolicy(SchedulePolicy $schedulePolicy): static { $this->schedulePolicy = $schedulePolicy; return $this; } - private static function initialize() + /** + * @return array{ + * urn: null, + * created_at: UTCDateTime, + * last_scheduling: array{ + * scheduled_at: null, + * job_id: null, + * }, + * attempts: 0, + * } + */ + private static function initialize(): array { return [ 'urn' => null, @@ -67,7 +97,10 @@ private static function initialize() ]; } - public function export() + /** + * @return array + */ + public function export(): array { return array_merge( $this->status, @@ -81,7 +114,7 @@ public function export() ); } - private function wasAlreadyScheduled($nextScheduling) + private function wasAlreadyScheduled(Moment $nextScheduling): bool { if (!$this->status['last_scheduling']['scheduled_at']) { return false; @@ -89,10 +122,10 @@ private function wasAlreadyScheduled($nextScheduling) $lastScheduling = T\MongoDate::toMoment($this->status['last_scheduling']['scheduled_at']); - return $lastScheduling == $nextScheduling; + return $lastScheduling->equals($nextScheduling); } - private function aJobIsStillRunning(JobsRepository $jobs) + private function aJobIsStillRunning(JobsRepository $jobs): bool { if (!$this->status['last_scheduling']['job_id']) { return false; @@ -107,7 +140,7 @@ private function aJobIsStillRunning(JobsRepository $jobs) } } - public function schedule(JobsRepository $jobs) + public function schedule(JobsRepository $jobs): void { if (!$this->schedulePolicy) { throw new \RuntimeException('You need to assign a `SchedulePolicy` (use `repeatWithPolicy` to inject it) in order to schedule a job'); @@ -138,7 +171,12 @@ public function schedule(JobsRepository $jobs) $this->schedulers->save($this); } - public function retryWithPolicy(RetryPolicy $retryPolicy, $retriableExceptionTypes = []) + /** + * @param class-string|class-string[] $retriableExceptionTypes + * + * @return $this + */ + public function retryWithPolicy(RetryPolicy $retryPolicy, array|string $retriableExceptionTypes = []): static { $this->retryPolicy = $this->filterForRetriableExceptions( $retryPolicy, @@ -148,31 +186,40 @@ public function retryWithPolicy(RetryPolicy $retryPolicy, $retriableExceptionTyp return $this; } - public function withUrn(string $urn) + /** + * @return $this + */ + public function withUrn(string $urn): static { $this->status['urn'] = $urn; return $this; } - public function unique(bool $unique) + /** + * @return $this + */ + public function unique(bool $unique): static { $this->status['unique'] = $unique; return $this; } - public function urn() + public function urn(): string { return $this->status['urn']; } - public function schedulePolicy() + public function schedulePolicy(): ?SchedulePolicy { return $this->schedulePolicy; } - private function filterForRetriableExceptions(RetryPolicy $retryPolicy, $retriableExceptionTypes = []) + /** + * @param class-string|class-string[] $retriableExceptionTypes + */ + private function filterForRetriableExceptions(RetryPolicy $retryPolicy, array|string $retriableExceptionTypes = []): RetryPolicy { if (!is_array($retriableExceptionTypes)) { $retriableExceptionTypes = [$retriableExceptionTypes]; diff --git a/src/Recruiter/Scheduler/Repository.php b/src/Recruiter/Scheduler/Repository.php index 87021e9c..1fa8e32d 100644 --- a/src/Recruiter/Scheduler/Repository.php +++ b/src/Recruiter/Scheduler/Repository.php @@ -4,19 +4,24 @@ namespace Recruiter\Scheduler; -use MongoDB; +use MongoDB\Collection; +use MongoDB\Database; +use MongoDB\Driver\CursorInterface; use Recruiter\Scheduler; class Repository { - private $schedulers; + private Collection $schedulers; - public function __construct(MongoDB\Database $db) + public function __construct(Database $db) { $this->schedulers = $db->selectCollection('schedulers'); } - public function all() + /** + * @return Scheduler[] + */ + public function all(): array { return $this->map( $this->schedulers->find([], [ @@ -25,7 +30,7 @@ public function all() ); } - public function save(Scheduler $scheduler) + public function save(Scheduler $scheduler): void { $document = $scheduler->export(); $this->schedulers->replaceOne( @@ -35,7 +40,7 @@ public function save(Scheduler $scheduler) ); } - public function create(Scheduler $scheduler) + public function create(Scheduler $scheduler): void { $document = $scheduler->export(); @@ -55,12 +60,15 @@ public function create(Scheduler $scheduler) } } - public function deleteByUrn(string $urn) + public function deleteByUrn(string $urn): void { $this->schedulers->deleteOne(['urn' => $urn]); } - private function map($cursor) + /** + * @return Scheduler[] + */ + private function map(CursorInterface $cursor): array { $schedulers = []; foreach ($cursor as $document) { diff --git a/src/Recruiter/SynchronousExecutionReport.php b/src/Recruiter/SynchronousExecutionReport.php index f2f80cda..78a2c192 100644 --- a/src/Recruiter/SynchronousExecutionReport.php +++ b/src/Recruiter/SynchronousExecutionReport.php @@ -10,14 +10,14 @@ class SynchronousExecutionReport { /** - * @param array $data = [] + * @param array $data */ - public function __construct(private readonly array $data = []) + private function __construct(private readonly array $data) { } /** - *. @params array $data : key value array where key are the id of the job and value is the JobExecution. + * @param array $data the key is the Job ID */ public static function fromArray(array $data): SynchronousExecutionReport { @@ -29,7 +29,10 @@ public function isThereAFailure(): bool return array_any($this->data, fn ($jobExecution, $jobId) => $jobExecution->isFailed()); } - public function toArray() + /** + * @return array the key is the Job ID + */ + public function toArray(): array { return $this->data; } diff --git a/src/Recruiter/WaitStrategy.php b/src/Recruiter/WaitStrategy.php index b97c5585..e762b2a9 100644 --- a/src/Recruiter/WaitStrategy.php +++ b/src/Recruiter/WaitStrategy.php @@ -8,25 +8,36 @@ class WaitStrategy { - private $timeToWaitAtLeast; - private $timeToWaitAtMost; - private $timeToWait; + private int $timeToWaitAtLeast; + private int $timeToWaitAtMost; + private int $timeToWait; + private \Closure $howToWait; - public function __construct(Interval $timeToWaitAtLeast, Interval $timeToWaitAtMost, private $howToWait = 'usleep') + /** + * @param callable|callable-string $howToWait + */ + public function __construct(Interval $timeToWaitAtLeast, Interval $timeToWaitAtMost, callable|string $howToWait = 'usleep') { $this->timeToWaitAtLeast = $timeToWaitAtLeast->milliseconds(); $this->timeToWaitAtMost = $timeToWaitAtMost->milliseconds(); $this->timeToWait = $timeToWaitAtLeast->milliseconds(); + $this->howToWait = $howToWait(...); } - public function reset() + /** + * @return $this + */ + public function reset(): static { $this->timeToWait = $this->timeToWaitAtLeast; return $this; } - public function goForward() + /** + * @return $this + */ + public function goForward(): static { $this->timeToWait = max( $this->timeToWait / 2, @@ -36,7 +47,10 @@ public function goForward() return $this; } - public function backOff() + /** + * @return $this + */ + public function backOff(): static { $this->timeToWait = min( $this->timeToWait * 2, @@ -46,19 +60,22 @@ public function backOff() return $this; } - public function wait() + /** + * @return $this + */ + public function wait(): static { call_user_func($this->howToWait, $this->timeToWait * 1000); return $this; } - public function timeToWait() + public function timeToWait(): Interval { return new Interval($this->timeToWait); } - public function timeToWaitAtMost() + public function timeToWaitAtMost(): Interval { return new Interval($this->timeToWaitAtMost); } diff --git a/src/Recruiter/Workable.php b/src/Recruiter/Workable.php index 696f0cb9..352345cc 100644 --- a/src/Recruiter/Workable.php +++ b/src/Recruiter/Workable.php @@ -15,13 +15,15 @@ public function asJobOf(Recruiter $recruiter): JobToSchedule; /** * Export parameters that need to be persisted. + * + * @return array */ public function export(): array; /** * Import an array of parameters as a Workable instance. * - * @param array $parameters Previously exported parameters + * @param array $parameters Previously exported parameters * * @throws ImportException */ diff --git a/src/Recruiter/Workable/AlwaysSucceed.php b/src/Recruiter/Workable/AlwaysSucceed.php index 75145475..5407381f 100644 --- a/src/Recruiter/Workable/AlwaysSucceed.php +++ b/src/Recruiter/Workable/AlwaysSucceed.php @@ -11,7 +11,7 @@ class AlwaysSucceed implements Workable { use WorkableBehaviour; - public function execute() + public function execute(): void { // It's easy to do nothing right :-) } diff --git a/src/Recruiter/Workable/ConsumingMemoryCommand.php b/src/Recruiter/Workable/ConsumingMemoryCommand.php index 7631d9a0..bcb1018a 100644 --- a/src/Recruiter/Workable/ConsumingMemoryCommand.php +++ b/src/Recruiter/Workable/ConsumingMemoryCommand.php @@ -11,7 +11,7 @@ class ConsumingMemoryCommand implements Workable { use WorkableBehaviour; - public function execute() + public function execute(): void { if ($this->parameters['withMemoryLeak']) { global $occupied; diff --git a/src/Recruiter/Workable/FactoryMethodCommand.php b/src/Recruiter/Workable/FactoryMethodCommand.php index 82a5aff0..45096372 100644 --- a/src/Recruiter/Workable/FactoryMethodCommand.php +++ b/src/Recruiter/Workable/FactoryMethodCommand.php @@ -10,16 +10,21 @@ class FactoryMethodCommand implements Workable { - public static function from(/* $callable[, $argument, $argument...] */) + /** + * @param callable-string $callable + */ + public static function from(string $callable, mixed ...$arguments): self { - $arguments = func_get_args(); - $callable = array_shift($arguments); - [$class, $method] = explode('::', (string) $callable); + /** @var class-string $class */ + [$class, $method] = explode('::', $callable); return self::singleStep(self::stepFor($class, $method, $arguments)); } - private static function singleStep($step): self + /** + * @param array{class?: class-string, method: string, arguments?: array} $step + */ + private static function singleStep(array $step): self { return new self([ $step, @@ -30,7 +35,7 @@ private static function singleStep($step): self * @param class-string $class * @param array $arguments * - * @return array + * @return array{class: class-string, method: string, arguments?: array} */ private static function stepFor(string $class, string $method, array $arguments): array { @@ -45,6 +50,9 @@ private static function stepFor(string $class, string $method, array $arguments) return $step; } + /** + * @param array}> $steps + */ private function __construct(private array $steps = []) { } @@ -54,7 +62,7 @@ public function asJobOf(Recruiter $recruiter): JobToSchedule return $recruiter->jobOf($this); } - public function execute($retryOptions = null) + public function execute(mixed $retryOptions = null): mixed { $result = null; $lastStepIndex = count($this->steps) - 1; @@ -86,14 +94,22 @@ public function execute($retryOptions = null) return $result; } - private function arguments($step) + /** + * @param array{arguments?: array} $step + * + * @return array + */ + private function arguments(array $step): array { - $arguments = $step['arguments'] ?? []; - - return $arguments; + return $step['arguments'] ?? []; } - public function __call($method, $arguments) + /** + * @param array $arguments + * + * @return $this + */ + public function __call(string $method, array $arguments): self { $step = [ 'method' => $method, @@ -106,6 +122,9 @@ public function __call($method, $arguments) return $this; } + /** + * @return array{steps: array>} + */ public function export(): array { return [ @@ -113,6 +132,9 @@ public function export(): array ]; } + /** + * @param array{steps: array>} $parameters + */ public static function import(array $parameters): static { return new static($parameters['steps']); diff --git a/src/Recruiter/Workable/FailsInConstructor.php b/src/Recruiter/Workable/FailsInConstructor.php index 910eb1ec..278cd420 100644 --- a/src/Recruiter/Workable/FailsInConstructor.php +++ b/src/Recruiter/Workable/FailsInConstructor.php @@ -11,7 +11,10 @@ class FailsInConstructor implements Workable { use WorkableBehaviour; - public function __construct(protected array $parameters = [], $fromRecruiter = true) + /** + * @param array $parameters + */ + public function __construct(protected array $parameters = [], bool $fromRecruiter = true) { if ($fromRecruiter) { throw new \Exception('I am supposed to fail in constructor code for testing purpose'); diff --git a/src/Recruiter/Workable/LazyBones.php b/src/Recruiter/Workable/LazyBones.php index 25abb8f4..3bea5cce 100644 --- a/src/Recruiter/Workable/LazyBones.php +++ b/src/Recruiter/Workable/LazyBones.php @@ -4,6 +4,7 @@ namespace Recruiter\Workable; +use Random\RandomException; use Recruiter\Workable; use Recruiter\WorkableBehaviour; @@ -16,7 +17,7 @@ public static function waitFor(int $timeInSeconds, int $deltaInSeconds = 0): sel return new self($timeInSeconds * 1000000, $deltaInSeconds * 1000000); } - public static function waitForMs($timeInMs, $deltaInMs = 0): self + public static function waitForMs(int $timeInMs, int $deltaInMs = 0): self { return new self($timeInMs * 1000, $deltaInMs * 1000); } @@ -25,11 +26,17 @@ public function __construct(private readonly int $usToSleep = 1, private readonl { } + /** + * @throws RandomException + */ public function execute(): void { - usleep($this->usToSleep + random_int(intval(-$this->usOfDelta), $this->usOfDelta)); + usleep($this->usToSleep + random_int(-$this->usOfDelta, $this->usOfDelta)); } + /** + * @return array{us_to_sleep: int, us_of_delta: int} + */ public function export(): array { return [ @@ -38,6 +45,9 @@ public function export(): array ]; } + /** + * @param array{us_to_sleep: int, us_of_delta: int} $parameters + */ public static function import(array $parameters): static { return new static( diff --git a/src/Recruiter/Workable/RecoverRepeatableFromException.php b/src/Recruiter/Workable/RecoverRepeatableFromException.php index 693dfce3..f3046945 100644 --- a/src/Recruiter/Workable/RecoverRepeatableFromException.php +++ b/src/Recruiter/Workable/RecoverRepeatableFromException.php @@ -11,7 +11,11 @@ class RecoverRepeatableFromException implements Repeatable { use WorkableBehaviour; - public function __construct($parameters, protected $recoverForClass, protected $recoverForException) + /** + * @param array $parameters + * @param class-string $recoverForClass + */ + public function __construct(array $parameters, protected string $recoverForClass, protected \Throwable $recoverForException) { $this->parameters = $parameters; } @@ -21,7 +25,7 @@ public function execute(): never throw new \Exception('This job failed while instantiating a workable of class: ' . $this->recoverForClass . PHP_EOL . 'Original exception: ' . $this->recoverForException::class . PHP_EOL . $this->recoverForException->getMessage() . PHP_EOL . $this->recoverForException->getTraceAsString() . PHP_EOL); } - public function getClass() + public function getClass(): string { return $this->recoverForClass; } diff --git a/src/Recruiter/Workable/RecoverWorkableFromException.php b/src/Recruiter/Workable/RecoverWorkableFromException.php index 15c6dc8f..08f931f6 100644 --- a/src/Recruiter/Workable/RecoverWorkableFromException.php +++ b/src/Recruiter/Workable/RecoverWorkableFromException.php @@ -11,9 +11,13 @@ class RecoverWorkableFromException implements Workable { use WorkableBehaviour; - public function __construct($parameters, protected $recoverForClass, protected $recoverForException) + /** + * @param ?array $parameters + * @param ?class-string $recoverForClass + */ + public function __construct(?array $parameters, protected ?string $recoverForClass, protected \Throwable $recoverForException) { - $this->parameters = $parameters; + $this->parameters = (array) $parameters; } public function execute(): never @@ -21,7 +25,10 @@ public function execute(): never throw new \Exception('This job failed while instantiating a workable of class: ' . $this->recoverForClass . PHP_EOL . 'Original exception: ' . $this->recoverForException::class . PHP_EOL . $this->recoverForException->getMessage() . PHP_EOL . $this->recoverForException->getTraceAsString() . PHP_EOL); } - public function getClass() + /** + * @return ?class-string + */ + public function getClass(): ?string { return $this->recoverForClass; } diff --git a/src/Recruiter/Workable/SampleRepeatableCommand.php b/src/Recruiter/Workable/SampleRepeatableCommand.php index 1061fc55..587b57a0 100644 --- a/src/Recruiter/Workable/SampleRepeatableCommand.php +++ b/src/Recruiter/Workable/SampleRepeatableCommand.php @@ -14,7 +14,7 @@ class SampleRepeatableCommand implements Workable, Repeatable use WorkableBehaviour; use RepeatableBehaviour; - public function execute() + public function execute(): void { var_export(new \DateTime()->format('c')); } diff --git a/src/Recruiter/Workable/ShellCommand.php b/src/Recruiter/Workable/ShellCommand.php index b963f46f..85e018ac 100644 --- a/src/Recruiter/Workable/ShellCommand.php +++ b/src/Recruiter/Workable/ShellCommand.php @@ -11,12 +11,12 @@ class ShellCommand implements Workable { use WorkableBehaviour; - public static function fromCommandLine($commandLine): self + public static function fromCommandLine(string $commandLine): self { return new self($commandLine); } - private function __construct(private $commandLine) + private function __construct(private string $commandLine) { } diff --git a/src/Recruiter/WorkableBehaviour.php b/src/Recruiter/WorkableBehaviour.php index dd4abd36..43ac2974 100644 --- a/src/Recruiter/WorkableBehaviour.php +++ b/src/Recruiter/WorkableBehaviour.php @@ -8,6 +8,9 @@ trait WorkableBehaviour { + /** + * @param array $parameters + */ final public function __construct(protected array $parameters = []) { } @@ -22,12 +25,17 @@ public function execute(): never throw new \Exception('Workable::execute() need to be implemented'); } + /** + * @return array + */ public function export(): array { return $this->parameters; } /** + * @param array $parameters + * * @throws ImportException */ public static function import(array $parameters): static diff --git a/src/Recruiter/WorkableInJob.php b/src/Recruiter/WorkableInJob.php index 36e2d8e0..1f6cbb59 100644 --- a/src/Recruiter/WorkableInJob.php +++ b/src/Recruiter/WorkableInJob.php @@ -11,15 +11,16 @@ class WorkableInJob { // TODO: resolve the duplication with RepeatableInJob /** + * @param array{workable?: array{ + * method?: string, + * class?: class-string, + * parameters?: array, + * }} $document + * * @throws ImportException */ - public static function import($document): Workable + public static function import(array $document): Workable { - $dataAboutWorkableObject = [ - 'parameters' => null, - 'class' => null, - ]; - try { if (!array_key_exists('workable', $document)) { throw new ImportException('Unable to import Job without data about Workable object'); @@ -39,11 +40,20 @@ public static function import($document): Workable return $workable; } catch (\Throwable $e) { - return new RecoverWorkableFromException($dataAboutWorkableObject['parameters'], $dataAboutWorkableObject['class'], $e); + return new RecoverWorkableFromException($dataAboutWorkableObject['parameters'] ?? null, $dataAboutWorkableObject['class'] ?? null, $e); } } - public static function export($workable, $methodToCall) + /** + * @return array{ + * workable: array{ + * class: class-string, + * parameters: array, + * method: string, + * } + * } + */ + public static function export(Workable $workable, string $methodToCall): array { return [ 'workable' => [ @@ -54,12 +64,22 @@ public static function export($workable, $methodToCall) ]; } - public static function initialize() + /** + * @return array{ + * workable: array{ + * method: string, + * } + * } + */ + public static function initialize(): array { return ['workable' => ['method' => 'execute']]; } - private static function classNameOf($workable) + /** + * @return class-string + */ + private static function classNameOf(Workable $workable): string { $workableClassName = $workable::class; if (method_exists($workable, 'getClass')) { diff --git a/src/Recruiter/Worker.php b/src/Recruiter/Worker.php index bab0b56e..24afbd22 100644 --- a/src/Recruiter/Worker.php +++ b/src/Recruiter/Worker.php @@ -5,6 +5,7 @@ namespace Recruiter; use MongoDB\BSON\ObjectId; +use MongoDB\BSON\UTCDateTime; use MongoDB\Collection as MongoCollection; use Recruiter\Infrastructure\Memory\MemoryLimit; use Recruiter\Infrastructure\Memory\MemoryLimitExceededException; @@ -25,6 +26,22 @@ public static function workFor( return $worker; } + /** + * @param array{ + * _id: ObjectId, + * work_on: string, + * available: bool, + * available_since: UTCDateTime, + * last_seen_at: UTCDateTime, + * created_at: UTCDateTime, + * working: bool, + * pid: int, + * working_on?: ObjectId, + * working_since?: UTCDateTime, + * assigned_to?: array, + * assigned_since?: UTCDateTime, + * } $status + */ public function __construct(private array $status, private readonly Recruiter $recruiter, private readonly Repository $repository, private readonly MemoryLimit $memoryLimit) { } @@ -57,14 +74,46 @@ public function work(): string|false } } + /** + * @return array{ + * _id: ObjectId, + * work_on: string, + * available: bool, + * available_since: UTCDateTime, + * last_seen_at: UTCDateTime, + * created_at: UTCDateTime, + * working: bool, + * pid: int, + * working_on?: ObjectId, + * working_since?: UTCDateTime, + * assigned_to?: array, + * assigned_since?: UTCDateTime, + * } + */ public function export(): array { return $this->status; } + /** + * @param array{ + * _id: ObjectId, + * work_on: string, + * available: bool, + * available_since: UTCDateTime, + * last_seen_at: UTCDateTime, + * created_at: UTCDateTime, + * working: bool, + * pid: int, + * working_on?: ObjectId, + * working_since?: UTCDateTime, + * assigned_to?: array, + * assigned_since?: UTCDateTime, + * } $document + */ public function updateWith(array $document): void { - $this->status = self::fromMongoDocumentToInternalStatus($document); + $this->status = $document; } public function workOnJobsGroupedAs(string $group): void @@ -93,14 +142,14 @@ private function stillHere(): void $this->repository->atomicUpdate($this, ['last_seen_at' => $lastSeenAt]); } - private function workOn($job): void + private function workOn(Job $job): void { $this->beforeExecutionOf($job); $job->execute($this->recruiter->getEventDispatcher()); $this->afterExecutionOf($job); } - private function beforeExecutionOf($job): void + private function beforeExecutionOf(Job $job): void { $this->status['working'] = true; $this->status['working_on'] = $job->id(); @@ -109,7 +158,7 @@ private function beforeExecutionOf($job): void $this->save(); } - private function afterExecutionOf($job): void + private function afterExecutionOf(Job $job): void { try { $this->memoryLimit->ensure(memory_get_usage()); @@ -138,7 +187,7 @@ private function afterExecutionOf($job): void $this->save(); } - private function retireAfterMemoryLimitIsExceeded() + private function retireAfterMemoryLimitIsExceeded(): void { $this->repository->retireWorkerWithId($this->id()); } @@ -158,11 +207,18 @@ private function save(): void $this->repository->save($this); } - private static function fromMongoDocumentToInternalStatus($document) - { - return $document; - } - + /** + * @return array{ + * _id: ObjectId, + * work_on: string, + * available: bool, + * available_since: UTCDateTime, + * last_seen_at: UTCDateTime, + * created_at: UTCDateTime, + * working: bool, + * pid: int, + * } + */ private static function initialize(): array { return [ @@ -182,14 +238,18 @@ public static function canWorkOnAnyJobs(string $worksOn): bool return '*' === $worksOn; } - public static function pickAvailableWorkers(MongoCollection $collection, $workersPerUnit): array + /** + * @return array + */ + public static function pickAvailableWorkers(MongoCollection $collection, int $workersPerUnit): array { $result = []; + /** @var array $workers */ $workers = iterator_to_array($collection->find(['available' => true], ['projection' => ['_id' => 1, 'work_on' => 1]])); if (count($workers) > 0) { $unitsOfWorkers = array_group_by( $workers, - fn ($worker) => $worker['work_on'], + fn (array $worker) => $worker['work_on'], ); foreach ($unitsOfWorkers as $workOn => $workersInUnit) { $workersInUnit = array_column($workersInUnit, '_id'); @@ -201,7 +261,13 @@ public static function pickAvailableWorkers(MongoCollection $collection, $worker return $result; } - public static function tryToAssignJobsToWorkers(MongoCollection $collection, $jobs, $workers): array + /** + * @param ObjectId[] $jobs + * @param ObjectId[] $workers + * + * @return array{array, int} + */ + public static function tryToAssignJobsToWorkers(MongoCollection $collection, array $jobs, array $workers): array { $assignment = array_combine( array_map(fn ($id) => (string) $id, $workers), @@ -236,7 +302,10 @@ public static function assignedJobs(MongoCollection $collection): array return array_values(array_unique($jobs)); } - public static function retireDeadWorkers(Repository $roster, \DateTimeImmutable $now, Interval $consideredDeadAfter) + /** + * @return ObjectId[] + */ + public static function retireDeadWorkers(Repository $roster, \DateTimeImmutable $now, Interval $consideredDeadAfter): array { $consideredDeadAt = $now->sub($consideredDeadAfter->toDateInterval()); $deadWorkers = $roster->deadWorkers($consideredDeadAt); diff --git a/src/Recruiter/Worker/Repository.php b/src/Recruiter/Worker/Repository.php index 81739774..6fc2bb64 100644 --- a/src/Recruiter/Worker/Repository.php +++ b/src/Recruiter/Worker/Repository.php @@ -28,7 +28,10 @@ public function save(Worker $worker): void ); } - public function atomicUpdate($worker, array $changeSet) + /** + * @param array $changeSet + */ + public function atomicUpdate(Worker $worker, array $changeSet): void { $this->roster->updateOne( ['_id' => $worker->id()], @@ -43,7 +46,7 @@ public function refresh(Worker $worker): void $worker->updateWith($updated); } - public function deadWorkers($consideredDeadAt) + public function deadWorkers(\DateTimeImmutable $consideredDeadAt): MongoDB\Driver\CursorInterface { return $this->roster->find( ['last_seen_at' => [ diff --git a/src/Recruiter/functions.php b/src/Recruiter/functions.php index 6deffecf..e01e28eb 100644 --- a/src/Recruiter/functions.php +++ b/src/Recruiter/functions.php @@ -4,15 +4,24 @@ namespace Recruiter; -function array_group_by($array, ?callable $f = null): array +/** + * @template T + * + * @param array $array + * @param ?callable(T): array-key $f + * + * @return array + */ +function array_group_by(array $array, ?callable $f = null): array { $f = $f ?: (fn ($value) => $value); return array_reduce( $array, - function ($buckets, $x) use ($f) { + function (array $buckets, mixed $x) use ($f) { + /** @var array-key $key */ $key = call_user_func($f, $x); - if (!array_key_exists($key, $buckets)) { + if (!is_array($buckets[$key] ?? null)) { $buckets[$key] = []; } $buckets[$key][] = $x; diff --git a/src/Timeless/Interval.php b/src/Timeless/Interval.php index 078fbe47..a7374af4 100644 --- a/src/Timeless/Interval.php +++ b/src/Timeless/Interval.php @@ -147,28 +147,26 @@ public function toDateInterval(): \DateInterval return new \DateInterval("PT{$this->seconds()}S"); } - public static function parse($string) - { - if (is_string($string)) { - $tokenToFunction = [ - 'milliseconds' => 'milliseconds', 'millisecond' => 'milliseconds', 'ms' => 'milliseconds', - 'seconds' => 'seconds', 'second' => 'seconds', 's' => 'seconds', - 'minutes' => 'minutes', 'minute' => 'minutes', 'm' => 'minutes', - 'hours' => 'hours', 'hour' => 'hours', 'h' => 'hours', - 'days' => 'days', 'day' => 'days', 'd' => 'days', - 'weeks' => 'weeks', 'week' => 'weeks', 'w' => 'weeks', - 'months' => 'months', 'month' => 'months', 'mo' => 'months', - 'years' => 'years', 'year' => 'years', 'y' => 'years', - ]; - $units = implode('|', array_keys($tokenToFunction)); - if (preg_match("/^[^\\d]*(?P\\d+)\\s*(?P{$units})(?:\\W.*|$)/", $string, $matches)) { - $callable = 'Timeless\\' . $tokenToFunction[$matches['unit']]; - - return call_user_func($callable, (int) $matches['quantity']); - } - if (!preg_match('/^\d+$/', $string)) { - throw new InvalidIntervalFormat("'{$string}' is not a valid Interval format"); - } + public static function parse(string $string): self + { + $tokenToFunction = [ + 'milliseconds' => 'milliseconds', 'millisecond' => 'milliseconds', 'ms' => 'milliseconds', + 'seconds' => 'seconds', 'second' => 'seconds', 's' => 'seconds', + 'minutes' => 'minutes', 'minute' => 'minutes', 'm' => 'minutes', + 'hours' => 'hours', 'hour' => 'hours', 'h' => 'hours', + 'days' => 'days', 'day' => 'days', 'd' => 'days', + 'weeks' => 'weeks', 'week' => 'weeks', 'w' => 'weeks', + 'months' => 'months', 'month' => 'months', 'mo' => 'months', + 'years' => 'years', 'year' => 'years', 'y' => 'years', + ]; + $units = implode('|', array_keys($tokenToFunction)); + if (preg_match("/^[^\\d]*(?P\\d+)\\s*(?P{$units})(?:\\W.*|$)/", $string, $matches)) { + $callable = 'Timeless\\' . $tokenToFunction[$matches['unit']]; + + return call_user_func($callable, (int) $matches['quantity']); + } + if (!preg_match('/^\d+$/', $string)) { + throw new InvalidIntervalFormat("'{$string}' is not a valid Interval format"); } if (is_numeric($string)) { $duration = floor(floatval($string)); diff --git a/src/Timeless/Moment.php b/src/Timeless/Moment.php index 83e16dd2..a17cb605 100644 --- a/src/Timeless/Moment.php +++ b/src/Timeless/Moment.php @@ -60,6 +60,11 @@ public function isBefore(Moment $m): bool return $this->ms <= $m->ms(); } + public function equals(Moment $m): bool + { + return $this->ms === $m->ms; + } + public function toSecondPrecision(): Moment { return new self($this->s() * 1000); diff --git a/src/Timeless/MongoDate.php b/src/Timeless/MongoDate.php index c21ee93f..8988f191 100644 --- a/src/Timeless/MongoDate.php +++ b/src/Timeless/MongoDate.php @@ -24,7 +24,7 @@ public static function toMoment(MongoUTCDateTime $mongoDate): Moment return new Moment(intval($mongoDate->__toString())); } - public static function now() + public static function now(): MongoUTCDateTime { return self::from(now()); } diff --git a/tests/Recruiter/Acceptance/BaseAcceptanceTestCase.php b/tests/Recruiter/Acceptance/BaseAcceptanceTestCase.php index 2d0bf0e7..2387b14a 100644 --- a/tests/Recruiter/Acceptance/BaseAcceptanceTestCase.php +++ b/tests/Recruiter/Acceptance/BaseAcceptanceTestCase.php @@ -23,11 +23,26 @@ abstract class BaseAcceptanceTestCase extends TestCase protected Collection $archived; protected Collection $roster; protected Collection $schedulers; + /** + * @var string[] + */ protected array $files; protected int $jobs; + /** + * @var ?array{resource, resource[], string} + */ private ?array $processRecruiter; + /** + * @var ?array{resource, resource[], string} + */ private ?array $processCleaner; + /** + * @var array{resource, resource[], string}[] + */ private array $processWorkers; + /** + * @var array + */ private array $lastStatus; protected function setUp(): void @@ -80,7 +95,7 @@ protected function numberOfWorkers(): int return $this->roster->countDocuments(); } - protected function waitForNumberOfWorkersToBe($expectedNumber, $howManySeconds = 1): void + protected function waitForNumberOfWorkersToBe(int $expectedNumber, int $howManySeconds = 1): void { Timeout::inSeconds($howManySeconds, "workers to be $expectedNumber") ->until(function () use ($expectedNumber) { @@ -91,6 +106,9 @@ protected function waitForNumberOfWorkersToBe($expectedNumber, $howManySeconds = ; } + /** + * @return array{resource, resource[], 'recruiter'} + */ protected function startRecruiter(): array { $descriptors = [ @@ -116,6 +134,9 @@ protected function startRecruiter(): array return $this->processRecruiter; } + /** + * @return array{resource, resource[], 'cleaner'} + */ protected function startCleaner(): array { $descriptors = [ @@ -125,6 +146,7 @@ protected function startCleaner(): array ]; $cwd = __DIR__ . '/../../../'; $process = proc_open('exec php bin/recruiter start:cleaner --wait-at-least=5s --wait-at-most=1m --lease-time 20s >> /tmp/cleaner.log 2>&1', $descriptors, $pipes, $cwd); + $this->assertIsResource($process); Timeout::inSeconds(1, 'cleaner to be up') ->until(function () use ($process) { $status = proc_get_status($process); @@ -132,12 +154,18 @@ protected function startCleaner(): array return $status['running']; }) ; + /** @var resource[] $pipes */ $this->processCleaner = [$process, $pipes, 'cleaner']; return $this->processCleaner; } - protected function startWorker(array $additionalOptions = []) + /** + * @param array $additionalOptions + * + * @return array{resource, resource[], 'worker'} + */ + protected function startWorker(array $additionalOptions = []): array { $descriptors = [ 0 => ['pipe', 'r'], @@ -153,6 +181,7 @@ protected function startWorker(array $additionalOptions = []) $cwd = __DIR__ . '/../../../'; $process = proc_open("exec php bin/recruiter start:worker $options >> /tmp/worker.log 2>&1", $descriptors, $pipes, $cwd); + $this->assertIsResource($process); Timeout::inSeconds(1, 'worker to be up') ->until(function () use ($process) { @@ -163,11 +192,15 @@ protected function startWorker(array $additionalOptions = []) ; // proc_get_status($process); + /** @var resource[] $pipes */ $this->processWorkers[] = [$process, $pipes, 'worker']; return end($this->processWorkers); } + /** + * @param array{resource, resource[], string} $processAndPipes + */ protected function stopProcessWithSignal(array $processAndPipes, int $signal): void { [$process, $pipes, $name] = $processAndPipes; @@ -185,7 +218,7 @@ protected function stopProcessWithSignal(array $processAndPipes, int $signal): v /** * @param int $duration milliseconds */ - protected function enqueueJob(int $duration = 10, $tag = 'generic'): void + protected function enqueueJob(int $duration = 10, string $tag = 'generic'): void { $workable = ShellCommand::fromCommandLine('sleep ' . ($duration / 1000)); $workable @@ -234,13 +267,13 @@ private function terminateProcesses(int $signal): void $this->processWorkers = []; } - protected function restartWorkerGracefully($workerIndex): void + protected function restartWorkerGracefully(int $workerIndex): void { $this->stopProcessWithSignal($this->processWorkers[$workerIndex], SIGTERM); $this->processWorkers[$workerIndex] = $this->startWorker(); } - protected function restartWorkerByKilling($workerIndex): void + protected function restartWorkerByKilling(int $workerIndex): void { $this->stopProcessWithSignal($this->processWorkers[$workerIndex], SIGKILL); $this->processWorkers[$workerIndex] = $this->startWorker(); @@ -248,12 +281,14 @@ protected function restartWorkerByKilling($workerIndex): void protected function restartRecruiterGracefully(): void { + $this->assertNotNull($this->processRecruiter); $this->stopProcessWithSignal($this->processRecruiter, SIGTERM); $this->startRecruiter(); } protected function restartRecruiterByKilling(): void { + $this->assertNotNull($this->processRecruiter); $this->stopProcessWithSignal($this->processRecruiter, SIGKILL); $this->startRecruiter(); } @@ -273,6 +308,9 @@ protected function files(): string return $logs; } + /** + * @param array $options + */ private function optionsToString(array $options = []): string { $optionsString = ''; diff --git a/tests/Recruiter/Acceptance/EnduranceTest.php b/tests/Recruiter/Acceptance/EnduranceTest.php index 83eabc87..ce831625 100644 --- a/tests/Recruiter/Acceptance/EnduranceTest.php +++ b/tests/Recruiter/Acceptance/EnduranceTest.php @@ -72,7 +72,7 @@ function ($durationAndTag) { ->hook(Listener\log('/tmp/recruiter-test-iterations.log')) ->hook(Listener\collectFrequencies()) ->disableShrinking() - ->then(function ($tuple): void { + ->then(function (array $tuple): void { [$workers, $actions] = $tuple; $this->clean(); $this->start($workers); @@ -115,7 +115,7 @@ function ($durationAndTag) { ; } - private function logAction($action) + private function logAction(mixed $action): void { file_put_contents( $this->actionLog, @@ -128,12 +128,31 @@ private function logAction($action) ); } - protected function sleep($milliseconds) + protected function sleep(int $milliseconds): void { usleep($milliseconds * 1000); } - protected function assertInvariantsOnStatistics($statistics) + /** + * @param array{ + * jobs: array{ + * queued: int, + * postponed: int, + * zombies: int, + * }, + * throughput: array{ + * value: float, + * value_per_second: float, + * }, + * latency: array{ + * average: float, + * }, + * execution_time: array{ + * average: float, + * }, + * } $statistics + */ + protected function assertInvariantsOnStatistics(array $statistics): void { $this->assertEquals(0, $statistics['jobs']['queued']); $this->assertEquals(0, $statistics['jobs']['zombies']); diff --git a/tests/Recruiter/Acceptance/HooksTest.php b/tests/Recruiter/Acceptance/HooksTest.php index 687c9d97..de746a9c 100644 --- a/tests/Recruiter/Acceptance/HooksTest.php +++ b/tests/Recruiter/Acceptance/HooksTest.php @@ -13,6 +13,9 @@ class HooksTest extends BaseAcceptanceTestCase { private MemoryLimit $memoryLimit; + /** + * @var Event[] + */ private array $events; #[\Override] diff --git a/tests/Recruiter/Acceptance/RepeatableJobsAreScheduledTest.php b/tests/Recruiter/Acceptance/RepeatableJobsAreScheduledTest.php index 27ab09a8..1d4da4a5 100644 --- a/tests/Recruiter/Acceptance/RepeatableJobsAreScheduledTest.php +++ b/tests/Recruiter/Acceptance/RepeatableJobsAreScheduledTest.php @@ -5,9 +5,11 @@ namespace Recruiter\Acceptance; use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; +use Recruiter\Job; use Recruiter\Job\Repository as JobsRepository; use Recruiter\RetryPolicy\ExponentialBackoff; use Recruiter\SchedulePolicy; +use Recruiter\Scheduler; use Recruiter\Scheduler\Repository as SchedulersRepository; use Recruiter\Workable\SampleRepeatableCommand; use Timeless as T; @@ -27,7 +29,7 @@ public function testARepeatableJobIsScheduledAtExpectedScheduledTime(): void $jobs = $this->fetchScheduledJobs(); - $this->assertEquals(1, count($jobs)); + $this->assertCount(1, $jobs); $jobData = $jobs[0]->export(); self::assertArraySubset([ @@ -90,7 +92,7 @@ public function testAJobIsScheduledForEverySchedulingTime(): void $this->recruiterScheduleJobsNTimes(2); $jobs = $this->fetchScheduledJobs(); - $this->assertEquals(2, count($jobs)); + $this->assertCount(2, $jobs); $this->assertEquals(2, $jobs[0]->export()['scheduled']['executions']); $this->assertEquals(1, $jobs[1]->export()['scheduled']['executions']); } @@ -107,7 +109,7 @@ public function testANewJobIsNotScheduledIfItShouldBeUniqueAndTheOldOneIsStillRu $this->recruiterScheduleJobsNTimes(2); $jobs = $this->fetchScheduledJobs(); - $this->assertEquals(1, count($jobs)); + $this->assertCount(1, $jobs); } public function testSchedulersAreUniqueOnUrn(): void @@ -132,7 +134,7 @@ public function testSchedulersAreUniqueOnUrn(): void $this->assertEquals($aSchedulerAlreadyHaveSomeAttempts, $schedulers[0]->export()['attempts']); } - private function IHaveAScheduleWithALongStory(string $urn, $attempts) + private function IHaveAScheduleWithALongStory(string $urn, int $attempts): void { $scheduleTimes = []; for ($i = 1; $i <= $attempts; ++$i) { @@ -145,7 +147,7 @@ private function IHaveAScheduleWithALongStory(string $urn, $attempts) $this->recruiterScheduleJobsNTimes($attempts); } - private function scheduleAJob(string $urn, ?SchedulePolicy $schedulePolicy = null, bool $unique = false) + private function scheduleAJob(string $urn, ?SchedulePolicy $schedulePolicy = null, bool $unique = false): Scheduler { if (is_null($schedulePolicy)) { $schedulePolicy = new FixedSchedulePolicy(strtotime('2023-02-18T17:00:00')); @@ -169,14 +171,20 @@ private function recruiterScheduleJobsNTimes(int $nth = 1): void } } - private function fetchScheduledJobs() + /** + * @return Job[] + */ + private function fetchScheduledJobs(): array { $jobsRepository = new JobsRepository($this->recruiterDb); return $jobsRepository->all(); } - private function fetchSchedulers() + /** + * @return Scheduler[] + */ + private function fetchSchedulers(): array { $schedulersRepository = new SchedulersRepository($this->recruiterDb); @@ -186,9 +194,15 @@ private function fetchSchedulers() class FixedSchedulePolicy implements SchedulePolicy { + /** + * @var int[] + */ private array $timestamps; - public function __construct($timestamps, private int $index = 0) + /** + * @param array|int $timestamps + */ + public function __construct(array|int $timestamps, private int $index = 0) { if (!is_array($timestamps)) { $timestamps = [$timestamps]; diff --git a/tests/Recruiter/Acceptance/SyncronousExecutionTest.php b/tests/Recruiter/Acceptance/SyncronousExecutionTest.php index 623f7d99..1d64399f 100644 --- a/tests/Recruiter/Acceptance/SyncronousExecutionTest.php +++ b/tests/Recruiter/Acceptance/SyncronousExecutionTest.php @@ -37,7 +37,7 @@ public function testAReportIsReturnedInOrderToSortOutIfAnErrorOccured(): void $this->assertTrue($report->isThereAFailure()); } - private function enqueueAnAnswerJob($answer, $scheduledAt) + private function enqueueAnAnswerJob(mixed $answer, T\Moment $scheduledAt): void { FactoryMethodCommand::from('Recruiter\Acceptance\SyncronousExecutionTestDummyObject::create') ->answer($answer) @@ -51,17 +51,20 @@ private function enqueueAnAnswerJob($answer, $scheduledAt) class SyncronousExecutionTestDummyObject { - public static function create() + public static function create(): self { return new self(); } - public function answer($value) + public function answer(mixed $value): mixed { return $value; } - public function myNeedyMethod(array $retryStatistics) + /** + * @param array{retry_number: int} $retryStatistics + */ + public function myNeedyMethod(array $retryStatistics): int { return $retryStatistics['retry_number']; } diff --git a/tests/Recruiter/Acceptance/WorkerGuaranteedToExitWhenAMemoryLeakOccurs.php b/tests/Recruiter/Acceptance/WorkerGuaranteedToExitWhenAMemoryLeakOccurs.php index 53724557..6e2b4e3e 100644 --- a/tests/Recruiter/Acceptance/WorkerGuaranteedToExitWhenAMemoryLeakOccurs.php +++ b/tests/Recruiter/Acceptance/WorkerGuaranteedToExitWhenAMemoryLeakOccurs.php @@ -15,7 +15,7 @@ class WorkerGuaranteedToExitWhenAMemoryLeakOccurs extends BaseAcceptanceTestCase * * @dataProvider provideMemoryConsumptions */ - public function testWorkerKillItselfAfterAMemoryLeakButNotAfterABigMemoryConsumptionWithoutLeak($withMemoryLeak, $howManyItems, $memoryLimit, $expectedWorkerAlive): void + public function testWorkerKillItselfAfterAMemoryLeakButNotAfterABigMemoryConsumptionWithoutLeak(bool $withMemoryLeak, int $howManyItems, string $memoryLimit, bool $expectedWorkerAlive): void { new ConsumingMemoryCommand([ 'withMemoryLeak' => $withMemoryLeak, @@ -59,7 +59,10 @@ public function testWorkerKillItselfAfterAMemoryLeakButNotAfterABigMemoryConsump ); } - public static function provideMemoryConsumptions() + /** + * @return array{bool, int, string, bool}[] + */ + public static function provideMemoryConsumptions(): array { return [ // legend: [$withMemoryLeak, $howManyItems, $memoryLimit, $expectedWorkerAlive], diff --git a/tests/Recruiter/FinalizerMethodsAreCalledWhenWorkableImplementsFinalizerInterfaceTest.php b/tests/Recruiter/FinalizerMethodsAreCalledWhenWorkableImplementsFinalizerInterfaceTest.php index d3ef24a8..0f5bfa9e 100644 --- a/tests/Recruiter/FinalizerMethodsAreCalledWhenWorkableImplementsFinalizerInterfaceTest.php +++ b/tests/Recruiter/FinalizerMethodsAreCalledWhenWorkableImplementsFinalizerInterfaceTest.php @@ -70,6 +70,9 @@ public function testFinalizableSuccessfullMethodsAreCalledWhenJobIsDone(): void class ListenerSpy { + /** + * @var array + */ public array $calls = []; public function methodWasCalled(string $name, ?\Throwable $exception = null): void @@ -85,7 +88,7 @@ class FinalizableWorkable implements Workable, Finalizable private $whatToDo; - public function __construct(callable $whatToDo, private $listener) + public function __construct(callable $whatToDo, private ListenerSpy $listener) { $this->parameters = []; $this->whatToDo = $whatToDo; diff --git a/tests/Recruiter/Job/EventTest.php b/tests/Recruiter/Job/EventTest.php index 5a0ffbe1..30ecf4d2 100644 --- a/tests/Recruiter/Job/EventTest.php +++ b/tests/Recruiter/Job/EventTest.php @@ -11,10 +11,16 @@ class EventTest extends TestCase public function testHasTagReturnsTrueWhenTheExportedJobContainsTheTag(): void { $event = new Event([ + '_id' => new \MongoDB\BSON\ObjectId(), + 'done' => false, + 'created_at' => new \MongoDB\BSON\UTCDateTime(), + 'locked' => false, + 'attempts' => 0, 'group' => 'generic', 'tags' => [ 1 => 'billing-notification', ], + 'workable' => ['method' => 'execute'], ]); $this->assertTrue($event->hasTag('billing-notification')); @@ -23,10 +29,16 @@ public function testHasTagReturnsTrueWhenTheExportedJobContainsTheTag(): void public function testHasTagReturnsFalseWhenTheExportedJobDoesNotContainTheTag(): void { $event = new Event([ + '_id' => new \MongoDB\BSON\ObjectId(), + 'done' => false, + 'created_at' => new \MongoDB\BSON\UTCDateTime(), + 'locked' => false, + 'attempts' => 0, 'group' => 'generic', 'tags' => [ 1 => 'billing-notification', ], + 'workable' => ['method' => 'execute'], ]); $this->assertFalse($event->hasTag('inexistant-tag')); @@ -35,6 +47,13 @@ public function testHasTagReturnsFalseWhenTheExportedJobDoesNotContainTheTag(): public function testHasTagReturnsFalseWhenTheExportedJobDoesNotContainTags(): void { $event = new Event([ + '_id' => new \MongoDB\BSON\ObjectId(), + 'done' => false, + 'created_at' => new \MongoDB\BSON\UTCDateTime(), + 'locked' => false, + 'attempts' => 0, + 'group' => 'generic', + 'workable' => ['method' => 'execute'], ]); $this->assertFalse($event->hasTag('inexistant-tag')); diff --git a/tests/Recruiter/Job/RepositoryTest.php b/tests/Recruiter/Job/RepositoryTest.php index d7a42e98..e27bc6f6 100644 --- a/tests/Recruiter/Job/RepositoryTest.php +++ b/tests/Recruiter/Job/RepositoryTest.php @@ -421,7 +421,7 @@ public function testCleaningOfOldArchivedCanBeLimitedByTime(): void $this->assertEquals(1, $this->repository->countArchived()); } - private function aJob($workable = null) + private function aJob(?Workable $workable = null): Job { if (is_null($workable)) { $workable = $this->workableMock(); @@ -432,7 +432,7 @@ private function aJob($workable = null) ; } - private function aJobToSchedule($job = null) + private function aJobToSchedule(?Job $job = null): JobToSchedule { if (is_null($job)) { $job = $this->aJob(); @@ -449,6 +449,9 @@ private function workableMock(): MockObject&Workable ; } + /** + * @param array $parameters + */ private function workableMockWithCustomParameters(array $parameters): MockObject&Workable { $workable = $this->workableMock(); @@ -461,9 +464,12 @@ private function workableMockWithCustomParameters(array $parameters): MockObject return $workable; } + /** + * @param array|null $workableParameters + */ private function jobMockWithAttemptsAndCustomParameters( - ?Moment $createdAt = null, - ?Moment $endedAt = null, + Moment $createdAt, + Moment $endedAt, ?array $workableParameters = null, ): Job&MockObject { $parameters = [ diff --git a/tests/Recruiter/JobCallCustomMethodOnWorkableTest.php b/tests/Recruiter/JobCallCustomMethodOnWorkableTest.php index 8b844da0..1c8bdfd7 100644 --- a/tests/Recruiter/JobCallCustomMethodOnWorkableTest.php +++ b/tests/Recruiter/JobCallCustomMethodOnWorkableTest.php @@ -70,7 +70,7 @@ public function testCustomMethodIsConservedAfterImport(): void class DummyWorkableWithSendCustomMethod extends BaseWorkable { - public function send() + public function send(): void { } } diff --git a/tests/Recruiter/JobSendEventsToWorkableTest.php b/tests/Recruiter/JobSendEventsToWorkableTest.php index cb0155d4..a0957ad2 100644 --- a/tests/Recruiter/JobSendEventsToWorkableTest.php +++ b/tests/Recruiter/JobSendEventsToWorkableTest.php @@ -52,7 +52,7 @@ public function __construct(private readonly EventListener $listener) $this->parameters = []; } - public function onEvent($channel, Event $ev): void + public function onEvent(string $channel, Event $ev): void { $this->listener->onEvent($channel, $ev); } @@ -65,9 +65,12 @@ public function execute(): never class EventListenerSpy implements EventListener { + /** + * @var array + */ public array $events = []; - public function onEvent($channel, Event $ev): void + public function onEvent(string $channel, Event $ev): void { $this->events[] = [$channel, $ev]; } diff --git a/tests/Recruiter/JobToBePassedRetryStatisticsTest.php b/tests/Recruiter/JobToBePassedRetryStatisticsTest.php index 92684c46..a8e64f93 100644 --- a/tests/Recruiter/JobToBePassedRetryStatisticsTest.php +++ b/tests/Recruiter/JobToBePassedRetryStatisticsTest.php @@ -4,6 +4,7 @@ namespace Recruiter; +use MongoDB\BSON\UTCDateTime; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -46,7 +47,24 @@ public function retryWithPolicy(): RetryPolicy return new DoNotDoItAgain(); } - public function execute(array $retryStatistics) + /** + * @param array{ + * job_id: string, + * retry_number: int, + * is_last_retry: bool, + * last_execution: ?array{ + * started_at: UTCDateTime, + * ended_at: UTCDateTime, + * crashed: bool, + * duration: int, + * result: mixed, + * class?: class-string, + * message?: string, + * trace?: string, + * }, + * } $retryStatistics + */ + public function execute(array $retryStatistics): void { } } diff --git a/tests/Recruiter/JobToScheduleTest.php b/tests/Recruiter/JobToScheduleTest.php index 88884892..1bed64e1 100644 --- a/tests/Recruiter/JobToScheduleTest.php +++ b/tests/Recruiter/JobToScheduleTest.php @@ -4,6 +4,7 @@ namespace Recruiter; +use MongoDB\BSON\ObjectId; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -21,6 +22,11 @@ protected function setUp(): void { $this->clock = T\clock()->stop(); $this->job = $this->createMock(Job::class); + $this->job + ->expects($this->any()) + ->method('id') + ->willReturnCallback(fn () => new ObjectId()) + ; } protected function tearDown(): void @@ -141,14 +147,8 @@ public function testConfigureMethodToCallOnWorkableInJob(): void public function testReturnsJobId(): void { - $this->job - ->expects($this->any()) - ->method('id') - ->will($this->returnValue('42')) - ; - - $this->assertEquals( - '42', + $this->assertMatchesRegularExpression( + '/^[a-f0-9]{24}$/', new JobToSchedule($this->job)->execute(), ); } diff --git a/tests/Recruiter/PickAvailableWorkersTest.php b/tests/Recruiter/PickAvailableWorkersTest.php index baed5ee4..45c81faf 100644 --- a/tests/Recruiter/PickAvailableWorkersTest.php +++ b/tests/Recruiter/PickAvailableWorkersTest.php @@ -36,6 +36,9 @@ public function testNoWorkersAreFound(): void $this->assertEquals([], $picked); } + /** + * @throws Exception + */ public function testFewWorkersWithNoSpecificSkill(): void { $callbackHasBeenCalled = false; @@ -48,6 +51,9 @@ public function testFewWorkersWithNoSpecificSkill(): void $this->assertCount(3, $workers); } + /** + * @throws Exception + */ public function testFewWorkersWithSameSkill(): void { $callbackHasBeenCalled = false; @@ -57,9 +63,12 @@ public function testFewWorkersWithSameSkill(): void [$worksOn, $workers] = $picked[0]; $this->assertEquals('send-emails', $worksOn); - $this->assertEquals(3, count($workers)); + $this->assertCount(3, $workers); } + /** + * @throws Exception + */ public function testFewWorkersWithSomeDifferentSkills(): void { $this->withAvailableWorkers(['send-emails' => 3, 'count-transactions' => 3]); @@ -76,6 +85,9 @@ public function testFewWorkersWithSomeDifferentSkills(): void $this->assertEquals(6, $totalWorkersGiven); } + /** + * @throws Exception + */ public function testMoreWorkersThanAllowedPerUnit(): void { $this->withAvailableWorkers(['send-emails' => $this->workersPerUnit + 10]); @@ -91,9 +103,11 @@ public function testMoreWorkersThanAllowedPerUnit(): void } /** + * @param array $workers + * * @throws Exception */ - private function withAvailableWorkers($workers): void + private function withAvailableWorkers(array $workers): void { $workersThatShouldBeFound = []; foreach ($workers as $skill => $quantity) { @@ -109,11 +123,11 @@ private function withAvailableWorkers($workers): void $this->repository ->expects($this->any()) ->method('find') - ->willReturn(new FakeCursor($workersThatShouldBeFound)) + ->willReturn(new FakeCursor(array_values($workersThatShouldBeFound))) ; } - private function withNoAvailableWorkers() + private function withNoAvailableWorkers(): void { $this->repository ->expects($this->any()) @@ -122,7 +136,13 @@ private function withNoAvailableWorkers() ; } - private function assertArrayAreEquals($expected, $given) + /** + * @template T + * + * @param array $expected + * @param array $given + */ + private function assertArrayAreEquals(array $expected, array $given): void { sort($expected); sort($given); @@ -130,10 +150,19 @@ private function assertArrayAreEquals($expected, $given) } } +/** + * @implements \Iterator> + */ class FakeCursor implements CursorInterface, \Iterator { + /** + * @var array> + */ private array $data; + /** + * @param array> $data + */ public function __construct(array $data = []) { $this->data = array_values($data); @@ -154,19 +183,25 @@ public function isDead(): bool throw new \LogicException('Not implemented'); } + /** + * @param array $typemap + */ public function setTypeMap(array $typemap): void { throw new \LogicException('Not implemented'); } - public function toArray(): array + public function toArray(): never { throw new \LogicException('Not implemented'); } + /** + * @return object|array|null + */ public function current(): object|array|null { - return current($this->data); + return current($this->data) ?: null; } public function next(): void diff --git a/tests/Recruiter/RetryPolicy/TimeTableTest.php b/tests/Recruiter/RetryPolicy/TimeTableTest.php index 8c13b9a5..1a42991a 100644 --- a/tests/Recruiter/RetryPolicy/TimeTableTest.php +++ b/tests/Recruiter/RetryPolicy/TimeTableTest.php @@ -4,6 +4,7 @@ namespace Recruiter\RetryPolicy; +use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Recruiter\Job; @@ -12,8 +13,11 @@ class TimeTableTest extends TestCase { - private $scheduler; + private TimeTable $scheduler; + /** + * @throws \Exception + */ protected function setUp(): void { $this->scheduler = new TimeTable([ @@ -66,6 +70,9 @@ public function testShouldNotBeRescheduledWhenWasCreatedMoreThan24HoursAgo(): vo $this->scheduler->schedule($job); } + /** + * @throws Exception + */ public function testIsLastRetryReturnTrueIfJobWasCreatedMoreThanLastTimeSpen(): void { $job = $this->createMock(Job::class); diff --git a/tests/Recruiter/SchedulePolicy/CronTest.php b/tests/Recruiter/SchedulePolicy/CronTest.php index 992b8b0d..0a2b2d19 100644 --- a/tests/Recruiter/SchedulePolicy/CronTest.php +++ b/tests/Recruiter/SchedulePolicy/CronTest.php @@ -16,7 +16,9 @@ class CronTest extends TestCase */ public function testCronCanBeExportedAndImportedWithoutDataLoss(string $cronExpression, string $expectedDate): void { - $cron = new Cron($cronExpression, \DateTime::createFromFormat('Y-m-d H:i:s', '2019-01-15 15:00:00')); + $now = \DateTime::createFromFormat('Y-m-d H:i:s', '2019-01-15 15:00:00'); + $this->assertInstanceOf(\DateTime::class, $now); + $cron = new Cron($cronExpression, $now); $cron = Cron::import($cron->export()); $this->assertEquals( diff --git a/tests/Recruiter/TaggableWorkableTest.php b/tests/Recruiter/TaggableWorkableTest.php index d5bc7040..a3e851e5 100644 --- a/tests/Recruiter/TaggableWorkableTest.php +++ b/tests/Recruiter/TaggableWorkableTest.php @@ -98,26 +98,38 @@ class WorkableTaggable implements Workable, Taggable { use WorkableBehaviour; - public function __construct(private array $tags) + /** + * @param string[] $tags + */ + public function __construct(private readonly array $tags) { } + /** + * @return string[] + */ public function taggedAs(): array { return $this->tags; } + /** + * @return array{tags: string[]} + */ public function export(): array { return ['tags' => $this->tags]; } + /** + * @param array{tags: string[]} $parameters + */ public static function import(array $parameters): static { return new static($parameters['tags']); } - public function execute() + public function execute(): void { // nothing is good } diff --git a/tests/Recruiter/WaitStrategyTest.php b/tests/Recruiter/WaitStrategyTest.php index 281ec059..372aaf13 100644 --- a/tests/Recruiter/WaitStrategyTest.php +++ b/tests/Recruiter/WaitStrategyTest.php @@ -17,7 +17,7 @@ class WaitStrategyTest extends TestCase protected function setUp(): void { $this->waited = T\milliseconds(0); - $this->howToWait = function ($microseconds): void { + $this->howToWait = function (int $microseconds): void { $this->waited = T\milliseconds($microseconds / 1000); }; $this->timeToWaitAtLeast = T\milliseconds(250); diff --git a/tests/Recruiter/Workable/FactoryMethodCommandTest.php b/tests/Recruiter/Workable/FactoryMethodCommandTest.php index 2e5cf188..1e36403e 100644 --- a/tests/Recruiter/Workable/FactoryMethodCommandTest.php +++ b/tests/Recruiter/Workable/FactoryMethodCommandTest.php @@ -41,12 +41,12 @@ public function testPassesRetryStatisticsAsAnAdditionalArgumentToTheLastMethodTo class DummyFactory { - public static function create() + public static function create(): self { return new self(); } - public function myObject() + public function myObject(): DummyObject { return new DummyObject(); } @@ -54,12 +54,15 @@ public function myObject() class DummyObject { - public function myMethod($what, $value) + public function myMethod(string $what, int $value): int { return $value; } - public function myNeedyMethod(array $retryStatistics) + /** + * @param array{retry_number: int} $retryStatistics + */ + public function myNeedyMethod(array $retryStatistics): int { return $retryStatistics['retry_number']; } diff --git a/tests/Timeless/IntervalParseTest.php b/tests/Timeless/IntervalParseTest.php index 37d2ca47..907546d4 100644 --- a/tests/Timeless/IntervalParseTest.php +++ b/tests/Timeless/IntervalParseTest.php @@ -98,7 +98,7 @@ public function testNumberAsIntervalFormat(): void { $this->expectException(InvalidIntervalFormat::class); $this->expectExceptionMessage("Maybe you mean '5 seconds' or something like that?"); - Interval::parse(5); + Interval::parse('5'); } public function testBadString(): void diff --git a/tests/Timeless/MongoDateTest.php b/tests/Timeless/MongoDateTest.php index 418df5b0..a3a4c3ac 100644 --- a/tests/Timeless/MongoDateTest.php +++ b/tests/Timeless/MongoDateTest.php @@ -18,7 +18,7 @@ public function testConvertsBackAndForthMongoDatesWithoutLosingMillisecondPrecis ->forAll( Generator\choose(0, 1500 * 1000 * 1000), ) - ->then(function ($milliseconds): void { + ->then(function (int $milliseconds): void { $moment = new Moment($milliseconds); $this->assertEquals( $moment, From 266a008ccebc42e2bab23b7e74e9db4426b08a26 Mon Sep 17 00:00:00 2001 From: "D. Bellettini" <325358+dbellettini@users.noreply.github.com> Date: Mon, 18 Aug 2025 00:16:27 +0200 Subject: [PATCH 2/4] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Recruiter/RetryPolicy/RetriableExceptionFilter.php | 2 +- src/Recruiter/functions.php | 2 +- tests/Recruiter/PickAvailableWorkersTest.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Recruiter/RetryPolicy/RetriableExceptionFilter.php b/src/Recruiter/RetryPolicy/RetriableExceptionFilter.php index d6aa658c..5c83cf18 100644 --- a/src/Recruiter/RetryPolicy/RetriableExceptionFilter.php +++ b/src/Recruiter/RetryPolicy/RetriableExceptionFilter.php @@ -96,7 +96,7 @@ private function ensureAreAllExceptions(array $exceptions): void private function isExceptionRetriable(?\Throwable $exception): bool { - if (null == $exception) { + if (null === $exception) { return false; } diff --git a/src/Recruiter/functions.php b/src/Recruiter/functions.php index e01e28eb..8b0bdea6 100644 --- a/src/Recruiter/functions.php +++ b/src/Recruiter/functions.php @@ -21,7 +21,7 @@ function array_group_by(array $array, ?callable $f = null): array function (array $buckets, mixed $x) use ($f) { /** @var array-key $key */ $key = call_user_func($f, $x); - if (!is_array($buckets[$key] ?? null)) { + if (!isset($buckets[$key])) { $buckets[$key] = []; } $buckets[$key][] = $x; diff --git a/tests/Recruiter/PickAvailableWorkersTest.php b/tests/Recruiter/PickAvailableWorkersTest.php index 45c81faf..f8fb161c 100644 --- a/tests/Recruiter/PickAvailableWorkersTest.php +++ b/tests/Recruiter/PickAvailableWorkersTest.php @@ -201,7 +201,7 @@ public function toArray(): never */ public function current(): object|array|null { - return current($this->data) ?: null; + return current($this->data) !== false ? current($this->data) : null; } public function next(): void From 4dc4e2d100d853fbcc5cc55ddb0ab99befe48d92 Mon Sep 17 00:00:00 2001 From: "D. Bellettini" <325358+dbellettini@users.noreply.github.com> Date: Mon, 18 Aug 2025 01:42:32 +0200 Subject: [PATCH 3/4] Increase PHPStan level to 7 --- phpstan.neon | 2 +- .../Infrastructure/Memory/MemoryLimit.php | 2 +- src/Recruiter/Job.php | 38 +++---------------- src/Recruiter/Job/Repository.php | 2 + src/Recruiter/RetryPolicy/TimeTable.php | 9 ++++- src/Recruiter/SchedulePolicy/EveryMinutes.php | 5 ++- src/Recruiter/Scheduler/Repository.php | 1 + src/Recruiter/WorkableInJob.php | 1 + src/Recruiter/Worker.php | 19 +++------- src/Recruiter/Worker/Repository.php | 2 +- .../Acceptance/BaseAcceptanceTestCase.php | 10 ++++- tests/Recruiter/Acceptance/EnduranceTest.php | 4 +- .../Acceptance/FaultToleranceTest.php | 2 + tests/Recruiter/Acceptance/HooksTest.php | 8 ++-- .../RepeatableJobsAreScheduledTest.php | 10 +++-- .../Acceptance/SyncronousExecutionTest.php | 6 ++- tests/Recruiter/Job/RepositoryTest.php | 12 ++++-- tests/Recruiter/JobTest.php | 2 + tests/Recruiter/PickAvailableWorkersTest.php | 2 +- tests/Recruiter/RetryPolicy/TimeTableTest.php | 4 +- tests/Recruiter/TaggableWorkableTest.php | 10 +++++ 21 files changed, 81 insertions(+), 70 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 784e4fa8..f1c3d66f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 6 + level: 7 paths: - src - tests diff --git a/src/Recruiter/Infrastructure/Memory/MemoryLimit.php b/src/Recruiter/Infrastructure/Memory/MemoryLimit.php index 85f8e4db..46db3033 100644 --- a/src/Recruiter/Infrastructure/Memory/MemoryLimit.php +++ b/src/Recruiter/Infrastructure/Memory/MemoryLimit.php @@ -18,7 +18,7 @@ public function __construct(int|string|System $limit) try { $this->limit = box($limit); } catch (ParseException $e) { - throw new \UnexpectedValueException(sprintf("Memory limit '%s' is an invalid value: %s", $limit, $e->getMessage())); + throw new \UnexpectedValueException(sprintf("Memory limit '%s' is an invalid value: %s", (string) $limit, $e->getMessage())); } } diff --git a/src/Recruiter/Job.php b/src/Recruiter/Job.php index 3d64c740..33e1371b 100644 --- a/src/Recruiter/Job.php +++ b/src/Recruiter/Job.php @@ -31,44 +31,14 @@ public static function around(Workable $workable, Repository $repository): self } /** - * @param array{ - * _id: ObjectId, - * done: bool, - * created_at: UTCDateTime, - * scheduled_at?: UTCDateTime, - * locked: bool, - * attempts: int, - * group: string, - * tags?: string[], - * workable: array{ - * method: string, - * class?: class-string, - * parameters?: array, - * }, - * last_execution?: array{ - * started_at: UTCDateTime, - * ended_at: UTCDateTime, - * crashed: bool, - * duration: int, - * result: mixed, - * class?: class-string, - * message?: string, - * trace?: string, - * }, - * scheduled?: array{ - * by: array{ - * namespace: string, - * urn: string, - * }, - * executions: int, - * }, - * } $document + * @param array $document * * @throws ImportException */ public static function import(array $document, Repository $repository): self { return new self( + // @phpstan-ignore-next-line $document, WorkableInJob::import($document), RetryPolicyInJob::import($document), @@ -315,6 +285,7 @@ public function archive(string $why): void */ public function export(): array { + // @phpstan-ignore-next-line return array_merge( $this->status, $this->lastJobExecution->export(), @@ -423,6 +394,8 @@ private function hasBeenScheduled(): bool private function scheduledAt(): ?Moment { if ($this->hasBeenScheduled()) { + assert(isset($this->status['scheduled_at'])); + return T\MongoDate::toMoment($this->status['scheduled_at']); } @@ -464,6 +437,7 @@ private function tagsToUseFor(Workable $workable): array */ private static function initialize(): array { + // @phpstan-ignore-next-line return array_merge( [ '_id' => new ObjectId(), diff --git a/src/Recruiter/Job/Repository.php b/src/Recruiter/Job/Repository.php index b4b541ad..28d53340 100644 --- a/src/Recruiter/Job/Repository.php +++ b/src/Recruiter/Job/Repository.php @@ -120,6 +120,7 @@ public function cleanArchived(T\Moment $upperLimit): int $deleted = 0; foreach ($documents as $document) { + assert(is_array($document) && isset($document['_id'])); $this->archived->deleteOne(['_id' => $document['_id']]); ++$deleted; } @@ -593,6 +594,7 @@ private function map(CursorInterface $cursor): array { $jobs = []; foreach ($cursor as $document) { + assert(is_array($document)); $jobs[] = Job::import($document, $this); } diff --git a/src/Recruiter/RetryPolicy/TimeTable.php b/src/Recruiter/RetryPolicy/TimeTable.php index b78eb76b..6ac5898e 100644 --- a/src/Recruiter/RetryPolicy/TimeTable.php +++ b/src/Recruiter/RetryPolicy/TimeTable.php @@ -64,15 +64,20 @@ public static function import(array $parameters): RetryPolicy private function hasBeenCreatedLessThan(Job|JobAfterFailure $job, string $relativeTime): bool { + $timestamp = strtotime($relativeTime, T\now()->seconds()); + assert(false !== $timestamp); + return $job->createdAt()->isAfter( - T\Moment::fromTimestamp(strtotime((string) $relativeTime, T\now()->seconds())), + T\Moment::fromTimestamp($timestamp), ); } private function rescheduleIn(JobAfterFailure $job, string $relativeTime): void { + $timestamp = strtotime($relativeTime, T\now()->seconds()); + assert(false !== $timestamp); $job->scheduleAt( - T\Moment::fromTimestamp(strtotime((string) $relativeTime, T\now()->seconds())), + T\Moment::fromTimestamp($timestamp), ); } diff --git a/src/Recruiter/SchedulePolicy/EveryMinutes.php b/src/Recruiter/SchedulePolicy/EveryMinutes.php index fa01fdc2..9db04fc5 100644 --- a/src/Recruiter/SchedulePolicy/EveryMinutes.php +++ b/src/Recruiter/SchedulePolicy/EveryMinutes.php @@ -15,7 +15,10 @@ public function __construct() public function next(): Moment { - return Moment::fromTimestamp(mktime(intval(date('H')), intval(date('i')) + 1, 0)); + $timestamp = mktime(intval(date('H')), intval(date('i')) + 1, 0); + assert(false !== $timestamp); + + return Moment::fromTimestamp($timestamp); } public function export(): array diff --git a/src/Recruiter/Scheduler/Repository.php b/src/Recruiter/Scheduler/Repository.php index 1fa8e32d..4f66e37a 100644 --- a/src/Recruiter/Scheduler/Repository.php +++ b/src/Recruiter/Scheduler/Repository.php @@ -72,6 +72,7 @@ private function map(CursorInterface $cursor): array { $schedulers = []; foreach ($cursor as $document) { + assert(is_array($document)); $schedulers[] = Scheduler::import($document, $this); } diff --git a/src/Recruiter/WorkableInJob.php b/src/Recruiter/WorkableInJob.php index 1f6cbb59..c11024e2 100644 --- a/src/Recruiter/WorkableInJob.php +++ b/src/Recruiter/WorkableInJob.php @@ -35,6 +35,7 @@ public static function import(array $document): Workable if (!method_exists($dataAboutWorkableObject['class'], 'import')) { throw new ImportException('Unable to import Workable without method import'); } + assert(isset($dataAboutWorkableObject['parameters'])); $workable = $dataAboutWorkableObject['class']::import($dataAboutWorkableObject['parameters']); assert($workable instanceof Workable); diff --git a/src/Recruiter/Worker.php b/src/Recruiter/Worker.php index 24afbd22..5073df2e 100644 --- a/src/Recruiter/Worker.php +++ b/src/Recruiter/Worker.php @@ -60,6 +60,7 @@ public function work(): string|false { $this->refresh(); if ($this->hasBeenAssignedToDoSomething()) { + assert(isset($this->status['assigned_to'])); $this->workOn( $job = $this->recruiter->scheduledJob( $this->status['assigned_to'][(string) $this->status['_id']], @@ -96,23 +97,11 @@ public function export(): array } /** - * @param array{ - * _id: ObjectId, - * work_on: string, - * available: bool, - * available_since: UTCDateTime, - * last_seen_at: UTCDateTime, - * created_at: UTCDateTime, - * working: bool, - * pid: int, - * working_on?: ObjectId, - * working_since?: UTCDateTime, - * assigned_to?: array, - * assigned_since?: UTCDateTime, - * } $document + * @param array $document */ public function updateWith(array $document): void { + // @phpstan-ignore-next-line $this->status = $document; } @@ -294,6 +283,7 @@ public static function assignedJobs(MongoCollection $collection): array $cursor = $collection->find([], ['projection' => ['assigned_to' => 1]]); $jobs = []; foreach ($cursor as $document) { + assert(is_array($document)); if (array_key_exists('assigned_to', $document)) { $jobs = array_merge($jobs, array_values($document['assigned_to'])); } @@ -311,6 +301,7 @@ public static function retireDeadWorkers(Repository $roster, \DateTimeImmutable $deadWorkers = $roster->deadWorkers($consideredDeadAt); $jobsToReassign = []; foreach ($deadWorkers as $deadWorker) { + assert(is_array($deadWorker) && isset($deadWorker['_id'])); $roster->retireWorkerWithId($deadWorker['_id']); if (array_key_exists('assigned_to', $deadWorker)) { if (array_key_exists((string) $deadWorker['_id'], $deadWorker['assigned_to'])) { diff --git a/src/Recruiter/Worker/Repository.php b/src/Recruiter/Worker/Repository.php index 6fc2bb64..7b70e131 100644 --- a/src/Recruiter/Worker/Repository.php +++ b/src/Recruiter/Worker/Repository.php @@ -50,7 +50,7 @@ public function deadWorkers(\DateTimeImmutable $consideredDeadAt): MongoDB\Drive { return $this->roster->find( ['last_seen_at' => [ - '$lt' => new MongoUTCDateTime($consideredDeadAt->format('U') * 1000)], + '$lt' => new MongoUTCDateTime(intval($consideredDeadAt->format('U')) * 1000)], ], ['projection' => ['_id' => true, 'assigned_to' => true]], ); diff --git a/tests/Recruiter/Acceptance/BaseAcceptanceTestCase.php b/tests/Recruiter/Acceptance/BaseAcceptanceTestCase.php index 2387b14a..25a92c97 100644 --- a/tests/Recruiter/Acceptance/BaseAcceptanceTestCase.php +++ b/tests/Recruiter/Acceptance/BaseAcceptanceTestCase.php @@ -123,12 +123,14 @@ protected function startRecruiter(): array Timeout::inSeconds(1, 'recruiter to be up') ->until(function () use ($process) { + assert(is_resource($process)); $status = proc_get_status($process); return $status['running']; }) ; + assert(is_resource($process)); $this->processRecruiter = [$process, $pipes, 'recruiter']; return $this->processRecruiter; @@ -149,6 +151,7 @@ protected function startCleaner(): array $this->assertIsResource($process); Timeout::inSeconds(1, 'cleaner to be up') ->until(function () use ($process) { + assert(is_resource($process)); $status = proc_get_status($process); return $status['running']; @@ -185,6 +188,7 @@ protected function startWorker(array $additionalOptions = []): array Timeout::inSeconds(1, 'worker to be up') ->until(function () use ($process) { + assert(is_resource($process)); $status = proc_get_status($process); return $status['running']; @@ -193,9 +197,11 @@ protected function startWorker(array $additionalOptions = []): array // proc_get_status($process); /** @var resource[] $pipes */ - $this->processWorkers[] = [$process, $pipes, 'worker']; + assert(is_resource($process)); + $lastWorker = [$process, $pipes, 'worker']; + $this->processWorkers[] = $lastWorker; - return end($this->processWorkers); + return $lastWorker; } /** diff --git a/tests/Recruiter/Acceptance/EnduranceTest.php b/tests/Recruiter/Acceptance/EnduranceTest.php index ce831625..6cae0bea 100644 --- a/tests/Recruiter/Acceptance/EnduranceTest.php +++ b/tests/Recruiter/Acceptance/EnduranceTest.php @@ -81,8 +81,10 @@ function ($durationAndTag) { if (is_array($action)) { $arguments = $action; $method = array_shift($arguments); + $callable = [$this, $method]; + $this->assertIsCallable($callable); call_user_func_array( - [$this, $method], + $callable, $arguments, ); } else { diff --git a/tests/Recruiter/Acceptance/FaultToleranceTest.php b/tests/Recruiter/Acceptance/FaultToleranceTest.php index 938af5ad..09721427 100644 --- a/tests/Recruiter/Acceptance/FaultToleranceTest.php +++ b/tests/Recruiter/Acceptance/FaultToleranceTest.php @@ -119,7 +119,9 @@ private function assertJobIsMarkedAsCrashed(): void $jobs = iterator_to_array($this->recruiterDb->selectCollection('scheduled')->find()); $this->assertCount(1, $jobs); foreach ($jobs as $job) { + assert(is_array($job)); $this->assertArrayHasKey('last_execution', $job); + assert(is_array($job['last_execution'])); $this->assertArrayHasKey('crashed', $job['last_execution']); $this->assertArrayHasKey('scheduled_at', $job['last_execution']); $this->assertArrayHasKey('started_at', $job['last_execution']); diff --git a/tests/Recruiter/Acceptance/HooksTest.php b/tests/Recruiter/Acceptance/HooksTest.php index de746a9c..1d8284e4 100644 --- a/tests/Recruiter/Acceptance/HooksTest.php +++ b/tests/Recruiter/Acceptance/HooksTest.php @@ -48,9 +48,9 @@ function (Event $event): void { $this->recruiter->assignJobsToWorkers(); $worker->work(); - $this->assertEquals(1, count($this->events)); + $this->assertCount(1, $this->events); $this->assertInstanceOf(Event::class, $this->events[0]); - $this->assertEquals('not-scheduled-by-retry-policy', $this->events[0]->export()['why']); + $this->assertEquals('not-scheduled-by-retry-policy', $this->events[0]->export()['why'] ?? null); } public function testAfterLastFailureEventIsFired(): void @@ -86,9 +86,9 @@ function (Event $event): void { $worker = $this->recruiter->hire($this->memoryLimit); $runAJob(2, $worker); - $this->assertEquals(1, count($this->events)); + $this->assertCount(1, $this->events); $this->assertInstanceOf(Event::class, $this->events[0]); - $this->assertEquals('tried-too-many-times', $this->events[0]->export()['why']); + $this->assertEquals('tried-too-many-times', $this->events[0]->export()['why'] ?? null); } public function testJobStartedIsFired(): void diff --git a/tests/Recruiter/Acceptance/RepeatableJobsAreScheduledTest.php b/tests/Recruiter/Acceptance/RepeatableJobsAreScheduledTest.php index 1d4da4a5..0b20137f 100644 --- a/tests/Recruiter/Acceptance/RepeatableJobsAreScheduledTest.php +++ b/tests/Recruiter/Acceptance/RepeatableJobsAreScheduledTest.php @@ -70,12 +70,13 @@ public function testOnlyASingleJobAreScheduledForTheSameSchedulingTime(): void $this->recruiterScheduleJobsNTimes(10); $jobs = $this->fetchScheduledJobs(); - $this->assertEquals(1, count($jobs)); + $this->assertCount(1, $jobs); $jobData = $jobs[0]->export(); + assert(isset($jobData['scheduled_at'])); $this->assertEquals( T\MongoDate::from(Moment::fromTimestamp($expectedScheduleDate)), - $jobs[0]->export()['scheduled_at'], + $jobData['scheduled_at'], ); } @@ -93,8 +94,8 @@ public function testAJobIsScheduledForEverySchedulingTime(): void $jobs = $this->fetchScheduledJobs(); $this->assertCount(2, $jobs); - $this->assertEquals(2, $jobs[0]->export()['scheduled']['executions']); - $this->assertEquals(1, $jobs[1]->export()['scheduled']['executions']); + $this->assertSame(2, $jobs[0]->export()['scheduled']['executions'] ?? 0); + $this->assertSame(1, $jobs[1]->export()['scheduled']['executions'] ?? 0); } public function testANewJobIsNotScheduledIfItShouldBeUniqueAndTheOldOneIsStillRunning(): void @@ -140,6 +141,7 @@ private function IHaveAScheduleWithALongStory(string $urn, int $attempts): void for ($i = 1; $i <= $attempts; ++$i) { $scheduleTimes[] = strtotime('2018-05-' . $i . 'T15:00:00'); } + $scheduleTimes = array_filter($scheduleTimes); // makes PHPStan happy $schedulePolicy = new FixedSchedulePolicy($scheduleTimes); $this->scheduleAJob($urn, $schedulePolicy); diff --git a/tests/Recruiter/Acceptance/SyncronousExecutionTest.php b/tests/Recruiter/Acceptance/SyncronousExecutionTest.php index 1d64399f..593b46d4 100644 --- a/tests/Recruiter/Acceptance/SyncronousExecutionTest.php +++ b/tests/Recruiter/Acceptance/SyncronousExecutionTest.php @@ -20,8 +20,10 @@ public function testJobsAreExecutedInOrderOfScheduling(): void $this->assertFalse($report->isThereAFailure()); $results = $report->toArray(); - $this->assertEquals(42, current($results)->result()); - $this->assertEquals(43, end($results)->result()); + $this->assertCount(2, $results); + $values = array_values($results); + $this->assertEquals(42, $values[0]->result()); + $this->assertEquals(43, $values[1]->result()); } public function testAReportIsReturnedInOrderToSortOutIfAnErrorOccured(): void diff --git a/tests/Recruiter/Job/RepositoryTest.php b/tests/Recruiter/Job/RepositoryTest.php index e27bc6f6..9ba5e38f 100644 --- a/tests/Recruiter/Job/RepositoryTest.php +++ b/tests/Recruiter/Job/RepositoryTest.php @@ -164,7 +164,9 @@ public function testGetDelayedScheduledJobs(): void $jobs = $this->repository->delayedScheduledJobs($lowerLimit); $jobsFounds = 0; foreach ($jobs as $job) { - $this->assertEquals('delayed_and_unpicked', reset($job->export()['workable']['parameters'])); + $workable = $job->export()['workable']; + assert(isset($workable['parameters'])); + $this->assertEquals('delayed_and_unpicked', reset($workable['parameters'])); ++$jobsFounds; } $this->assertEquals(2, $jobsFounds); @@ -244,9 +246,12 @@ public function testGetRecentJobsWithManyAttempts(): void $jobs = $this->repository->recentJobsWithManyAttempts($lowerLimit, $upperLimit); $jobsFounds = 0; foreach ($jobs as $job) { + $workable = $job->export()['workable']; + // Makes PHPStan happy + assert(isset($workable['parameters'])); $this->assertMatchesRegularExpression( '/many_attempts_and_archived|many_attempts_and_scheduled/', - reset($job->export()['workable']['parameters']), + reset($workable['parameters']), ); ++$jobsFounds; } @@ -390,9 +395,10 @@ public function testGetSlowRecentJobs(): void $jobs = $this->repository->slowRecentJobs($lowerLimit, $upperLimit); $jobsFounds = 0; foreach ($jobs as $job) { + $parameters = $job->export()['workable']['parameters'] ?? []; $this->assertMatchesRegularExpression( '/slow_job_recent_archived|slow_job_recent_scheduled/', - reset($job->export()['workable']['parameters']), + reset($parameters), ); ++$jobsFounds; } diff --git a/tests/Recruiter/JobTest.php b/tests/Recruiter/JobTest.php index e44930ea..681d9bd7 100644 --- a/tests/Recruiter/JobTest.php +++ b/tests/Recruiter/JobTest.php @@ -55,6 +55,8 @@ public function testRetryStatisticsOnSubsequentExecutions(): void $this->assertArrayHasKey('class', $lastExecution); $this->assertArrayHasKey('message', $lastExecution); $this->assertArrayHasKey('trace', $lastExecution); + // Makes PHPStan happy + assert(isset($lastExecution['message'], $lastExecution['trace'])); $this->assertEquals("Sorry, I'm good for nothing", $lastExecution['message']); $this->assertMatchesRegularExpression('/.*AlwaysFail->execute.*/', $lastExecution['trace']); } diff --git a/tests/Recruiter/PickAvailableWorkersTest.php b/tests/Recruiter/PickAvailableWorkersTest.php index f8fb161c..f287ce15 100644 --- a/tests/Recruiter/PickAvailableWorkersTest.php +++ b/tests/Recruiter/PickAvailableWorkersTest.php @@ -201,7 +201,7 @@ public function toArray(): never */ public function current(): object|array|null { - return current($this->data) !== false ? current($this->data) : null; + return false !== current($this->data) ? current($this->data) : null; } public function next(): void diff --git a/tests/Recruiter/RetryPolicy/TimeTableTest.php b/tests/Recruiter/RetryPolicy/TimeTableTest.php index 1a42991a..e30b1d98 100644 --- a/tests/Recruiter/RetryPolicy/TimeTableTest.php +++ b/tests/Recruiter/RetryPolicy/TimeTableTest.php @@ -138,7 +138,9 @@ private function givenJobThat(T\Moment $wasCreatedAt): MockObject&JobAfterFailur private function jobThatWasCreated(string $relativeTime): MockObject&JobAfterFailure { - $wasCreatedAt = T\Moment::fromTimestamp(strtotime($relativeTime)); + $timestamp = strtotime($relativeTime); + assert(false !== $timestamp); + $wasCreatedAt = T\Moment::fromTimestamp($timestamp); $job = $this->getMockBuilder(JobAfterFailure::class) ->disableOriginalConstructor() ->onlyMethods(['createdAt', 'scheduleAt']) diff --git a/tests/Recruiter/TaggableWorkableTest.php b/tests/Recruiter/TaggableWorkableTest.php index a3e851e5..189dfcb5 100644 --- a/tests/Recruiter/TaggableWorkableTest.php +++ b/tests/Recruiter/TaggableWorkableTest.php @@ -28,6 +28,8 @@ public function testWorkableExportsTags(): void $exported = $job->export(); $this->assertArrayHasKey('tags', $exported); + // Makes PHPStan happy + assert(isset($exported['tags'])); $this->assertEquals(['a', 'b'], $exported['tags']); } @@ -39,6 +41,8 @@ public function testCanSetTagsOnJobs(): void $exported = $job->export(); $this->assertArrayHasKey('tags', $exported); + // Makes PHPStan happy + assert(isset($exported['tags'])); $this->assertEquals(['c'], $exported['tags']); } @@ -50,6 +54,8 @@ public function testTagsAreMergedTogether(): void $exported = $job->export(); $this->assertArrayHasKey('tags', $exported); + // Makes PHPStan happy + assert(isset($exported['tags'])); $this->assertEquals(['a', 'b', 'c'], $exported['tags']); } @@ -61,6 +67,8 @@ public function testTagsAreUnique(): void $exported = $job->export(); $this->assertArrayHasKey('tags', $exported); + // Makes PHPStan happy + assert(isset($exported['tags'])); $this->assertEquals(['c'], $exported['tags']); } @@ -90,6 +98,8 @@ public function testTagsAreImported(): void // is always the same because tags are kept unique $exported = $job->export(); $this->assertArrayHasKey('tags', $exported); + // Makes PHPStan happy + assert(isset($exported['tags'])); $this->assertEquals(['a', 'b', 'c'], $exported['tags']); } } From a1c04bc6c0a58199d85a988cc479fe3ec24391ef Mon Sep 17 00:00:00 2001 From: "D. Bellettini" <325358+dbellettini@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:34:28 +0100 Subject: [PATCH 4/4] Going back to PHPStan 6 --- phpstan.neon | 2 +- src/Recruiter/Job.php | 3 --- src/Recruiter/Worker.php | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index f1c3d66f..784e4fa8 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 7 + level: 6 paths: - src - tests diff --git a/src/Recruiter/Job.php b/src/Recruiter/Job.php index 33e1371b..502823aa 100644 --- a/src/Recruiter/Job.php +++ b/src/Recruiter/Job.php @@ -38,7 +38,6 @@ public static function around(Workable $workable, Repository $repository): self public static function import(array $document, Repository $repository): self { return new self( - // @phpstan-ignore-next-line $document, WorkableInJob::import($document), RetryPolicyInJob::import($document), @@ -285,7 +284,6 @@ public function archive(string $why): void */ public function export(): array { - // @phpstan-ignore-next-line return array_merge( $this->status, $this->lastJobExecution->export(), @@ -437,7 +435,6 @@ private function tagsToUseFor(Workable $workable): array */ private static function initialize(): array { - // @phpstan-ignore-next-line return array_merge( [ '_id' => new ObjectId(), diff --git a/src/Recruiter/Worker.php b/src/Recruiter/Worker.php index 5073df2e..702bdd94 100644 --- a/src/Recruiter/Worker.php +++ b/src/Recruiter/Worker.php @@ -101,7 +101,6 @@ public function export(): array */ public function updateWith(array $document): void { - // @phpstan-ignore-next-line $this->status = $document; }