diff --git a/src/Bundle/ChillMainBundle/Cron/CronJobInterface.php b/src/Bundle/ChillMainBundle/Cron/CronJobInterface.php new file mode 100644 index 000000000..4e1ca9ff6 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Cron/CronJobInterface.php @@ -0,0 +1,23 @@ + + */ + private iterable $jobs; + + private LoggerInterface $logger; + + /** + * @param CronJobInterface[] $jobs + */ + public function __construct( + CronJobExecutionRepositoryInterface $cronJobExecutionRepository, + EntityManagerInterface $entityManager, + iterable $jobs, + LoggerInterface $logger + ) { + $this->cronJobExecutionRepository = $cronJobExecutionRepository; + $this->entityManager = $entityManager; + $this->jobs = $jobs; + $this->logger = $logger; + } + + public function run(?string $forceJob = null): void + { + if (null !== $forceJob) { + $this->runForce($forceJob); + + return; + } + + [$orderedJobs, $lasts] = $this->getOrderedJobs(); + + foreach ($orderedJobs as $job) { + if ($job->canRun($lasts[$job->getKey()] ?? null)) { + if (array_key_exists($job->getKey(), $lasts)) { + $this->entityManager + ->createQuery(self::UPDATE_BEFORE_EXEC) + ->setParameters([ + 'now' => new DateTimeImmutable('now'), + 'key' => $job->getKey(), + ]); + } else { + $execution = new CronJobExecution($job->getKey()); + $this->entityManager->persist($execution); + $this->entityManager->flush(); + } + $this->entityManager->clear(); + + try { + $this->logger->info(sprintf('%sWill run job', self::LOG_PREFIX), ['job' => $job->getKey()]); + $job->run(); + + $this->entityManager + ->createQuery(self::UPDATE_AFTER_EXEC) + ->setParameters([ + 'now' => new DateTimeImmutable('now'), + 'status' => CronJobExecution::SUCCESS, + 'key' => $job->getKey(), + ]) + ->execute(); + + $this->logger->info(sprintf('%sSuccessfully run job', self::LOG_PREFIX), ['job' => $job->getKey()]); + + return; + } catch (Exception $e) { + $this->logger->error(sprintf('%sRunning job failed', self::LOG_PREFIX), ['job' => $job->getKey()]); + $this->entityManager + ->createQuery(self::UPDATE_AFTER_EXEC) + ->setParameters([ + 'now' => new DateTimeImmutable('now'), + 'status' => CronJobExecution::FAILURE, + 'key' => $job->getKey(), + ]) + ->execute(); + + return; + } + } + } + } + + /** + * @return array<0: CronJobInterface[], 1: array> + */ + private function getOrderedJobs(): array + { + /** @var array $lasts */ + $lasts = []; + + foreach ($this->cronJobExecutionRepository->findAll() as $execution) { + $lasts[$execution->getKey()] = $execution; + } + + // order by last, NULL first + $orderedJobs = iterator_to_array($this->jobs); + usort( + $orderedJobs, + static function (CronJobInterface $a, CronJobInterface $b) use ($lasts): int { + if ( + (!array_key_exists($a->getKey(), $lasts) && !array_key_exists($b->getKey(), $lasts)) + ) { + return 0; + } + + if (!array_key_exists($a->getKey(), $lasts) && array_key_exists($b->getKey(), $lasts)) { + return -1; + } + + if (!array_key_exists($b->getKey(), $lasts) && array_key_exists($a->getKey(), $lasts)) { + return 1; + } + + return $lasts[$a->getKey()]->getLastStart() <=> $lasts[$b->getKey()]->getLastStart(); + } + ); + + return [$orderedJobs, $lasts]; + } + + private function runForce(string $forceJob): void + { + foreach ($this->jobs as $job) { + $job->run(); + } + } +} diff --git a/src/Bundle/ChillMainBundle/Entity/CronJobExecution.php b/src/Bundle/ChillMainBundle/Entity/CronJobExecution.php new file mode 100644 index 000000000..e0899e21f --- /dev/null +++ b/src/Bundle/ChillMainBundle/Entity/CronJobExecution.php @@ -0,0 +1,92 @@ +key = $key; + $this->lastStart = new DateTimeImmutable('now'); + } + + public function getKey(): string + { + return $this->key; + } + + public function getLastEnd(): DateTimeImmutable + { + return $this->lastEnd; + } + + public function getLastStart(): DateTimeImmutable + { + return $this->lastStart; + } + + public function getLastStatus(): ?int + { + return $this->lastStatus; + } + + public function setLastEnd(?DateTimeImmutable $lastEnd): CronJobExecution + { + $this->lastEnd = $lastEnd; + + return $this; + } + + public function setLastStart(DateTimeImmutable $lastStart): CronJobExecution + { + $this->lastStart = $lastStart; + + return $this; + } + + public function setLastStatus(?int $lastStatus): CronJobExecution + { + $this->lastStatus = $lastStatus; + + return $this; + } +} diff --git a/src/Bundle/ChillMainBundle/Repository/CronJobExecutionRepository.php b/src/Bundle/ChillMainBundle/Repository/CronJobExecutionRepository.php new file mode 100644 index 000000000..eb0b63724 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/CronJobExecutionRepository.php @@ -0,0 +1,57 @@ +repository = $entityManager->getRepository($this->getClassName()); + } + + public function find($id): ?CronJobExecution + { + return $this->repository->find($id); + } + + /** + * @return array|CronJobExecution[] + */ + public function findAll(): array + { + return $this->repository->findAll(); + } + + /** + * @return array|CronJobExecution[] + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?CronJobExecution + { + return $this->repository->findOneBy($criteria); + } + + public function getClassName(): string + { + return CronJobExecutionRepository::class; + } +} diff --git a/src/Bundle/ChillMainBundle/Repository/CronJobExecutionRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/CronJobExecutionRepositoryInterface.php new file mode 100644 index 000000000..df894bbfb --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/CronJobExecutionRepositoryInterface.php @@ -0,0 +1,34 @@ +prophesize(CronJobInterface::class); + $jobToExecute->getKey()->willReturn('to-exec'); + $jobToExecute->canRun(Argument::type(CronJobExecution::class))->willReturn(true); + $jobToExecute->run()->shouldBeCalled(); + + $executions = [ + ['key' => $jobOld1->getKey(), 'lastStart' => new DateTimeImmutable('yesterday'), 'lastEnd' => new DateTimeImmutable('1 hours ago'), 'lastStatus' => CronJobExecution::SUCCESS], + ['key' => $jobOld2->getKey(), 'lastStart' => new DateTimeImmutable('3 days ago'), 'lastEnd' => new DateTimeImmutable('36 hours ago'), 'lastStatus' => CronJobExecution::SUCCESS], + // this is the oldest one + ['key' => 'to-exec', 'lastStart' => new DateTimeImmutable('1 month ago'), 'lastEnd' => new DateTimeImmutable('10 days ago'), 'lastStatus' => CronJobExecution::SUCCESS], + ]; + + $cronManager = new CronManager( + $this->buildCronJobExecutionRepository($executions), + $this->buildEntityManager([]), + new ArrayObject([$jobOld1, $jobToExecute->reveal(), $jobOld2]), + new NullLogger() + ); + + $cronManager->run(); + } + + public function testSelectNewJobFirstAndNewJobIsFirstInList(): void + { + $jobAlreadyExecuted = new JobCanRun('k'); + $jobNeverExecuted = $this->prophesize(CronJobInterface::class); + $jobNeverExecuted->getKey()->willReturn('never-executed'); + $jobNeverExecuted->run()->shouldBeCalled(); + $jobNeverExecuted->canRun(null)->willReturn(true); + + $executions = [ + ['key' => $jobAlreadyExecuted->getKey(), 'lastStart' => new DateTimeImmutable('yesterday'), 'lastEnd' => new DateTimeImmutable('1 hours ago'), 'lastStatus' => CronJobExecution::SUCCESS], + ]; + + $cronManager = new CronManager( + $this->buildCronJobExecutionRepository($executions), + $this->buildEntityManager([Argument::type(CronJobExecution::class)]), + new ArrayObject([$jobNeverExecuted->reveal(), $jobAlreadyExecuted]), + new NullLogger() + ); + + $cronManager->run(); + } + + public function testSelectNewJobFirstAndNewJobIsLastInList(): void + { + $jobAlreadyExecuted = new JobCanRun('k'); + $jobNeverExecuted = $this->prophesize(CronJobInterface::class); + $jobNeverExecuted->getKey()->willReturn('never-executed'); + $jobNeverExecuted->run()->shouldBeCalled(); + $jobNeverExecuted->canRun(null)->willReturn(true); + + $executions = [ + ['key' => $jobAlreadyExecuted->getKey(), 'lastStart' => new DateTimeImmutable('yesterday'), 'lastEnd' => new DateTimeImmutable('1 hours ago'), 'lastStatus' => CronJobExecution::SUCCESS], + ]; + + $cronManager = new CronManager( + $this->buildCronJobExecutionRepository($executions), + $this->buildEntityManager([Argument::type(CronJobExecution::class)]), + new ArrayObject([$jobAlreadyExecuted, $jobNeverExecuted->reveal()]), + new NullLogger() + ); + + $cronManager->run(); + } + + /** + * @param array $executions + */ + private function buildCronJobExecutionRepository(array $executions): CronJobExecutionRepositoryInterface + { + $repository = $this->prophesize(CronJobExecutionRepositoryInterface::class); + + $repository->findAll()->willReturn( + array_map( + static function (array $exec): CronJobExecution { + $e = new CronJobExecution($exec['key']); + $e->setLastStart($exec['lastStart']); + + if (array_key_exists('lastEnd', $exec)) { + $e->setLastEnd($exec['lastEnd']); + } + + if (array_key_exists('lastStatus', $exec)) { + $e->setLastStatus($exec['lastStatus']); + } + + return $e; + }, + $executions + ) + ); + + return $repository->reveal(); + } + + private function buildEntityManager(array $persistArgsShouldBeCalled = []): EntityManagerInterface + { + $em = $this->prophesize(EntityManagerInterface::class); + + if ([] === $persistArgsShouldBeCalled) { + $em->persist(Argument::any())->shouldNotBeCalled(); + } else { + foreach ($persistArgsShouldBeCalled as $arg) { + $em->persist($arg)->shouldBeCalled(); + } + $em->flush()->shouldBeCalled(); + } + + // other methods + $em->clear()->shouldBeCalled(); + + $query = $this->prophesize(AbstractQuery::class); + $query->setParameters(Argument::type('array'))->willReturn($query->reveal()); + $query->execute()->shouldBeCalled(); + + $em->createQuery(Argument::type('string'))->willReturn($query->reveal()); + + return $em->reveal(); + } +} + +class JobCanRun implements CronJobInterface +{ + private string $key; + + public function __construct(string $key) + { + $this->key = $key; + } + + public function canRun(?CronJobExecution $cronJobExecution): bool + { + return true; + } + + public function getKey(): string + { + return $this->key; + } + + public function run(): void + { + } +} + +class JobCannotRun implements CronJobInterface +{ + public function canRun(?CronJobExecution $cronJobExecution): bool + { + return false; + } + + public function getKey(): string + { + return 'job-b'; + } + + public function run(): void + { + } +}