diff --git a/docs/source/development/cronjob.rst b/docs/source/development/cronjob.rst new file mode 100644 index 000000000..df72fa922 --- /dev/null +++ b/docs/source/development/cronjob.rst @@ -0,0 +1,93 @@ + +.. Copyright (C) 2014-2023 Champs Libres Cooperative SCRLFS + Permission is granted to copy, distribute and/or modify this document + under the terms of the GNU Free Documentation License, Version 1.3 + or any later version published by the Free Software Foundation; + with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. + A copy of the license is included in the section entitled "GNU + Free Documentation License". + +.. _cronjob: + +Cron jobs +********* + +Some tasks must be executed regularly: refresh some materialized views, remove old data, ... + +For this purpose, one can programmatically implements a "cron job", which will be scheduled by a specific command. + +The command :code:`chill:cron-job:execute` +========================================== + +The command :code:`chill:cron-job:execute` will schedule a task, one by one. In a classical implementation, it should +be executed every 15 minutes (more or less), to ensure that every task can be executed. + +.. warning:: + + This command should not be executed in parallel. The installer should ensure that two job are executed concurrently. + +How to implements a cron job ? +============================== + +Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example: + +.. code-block:: php + + namespace Chill\MainBundle\Service\Something; + + use Chill\MainBundle\Cron\CronJobInterface; + use Chill\MainBundle\Entity\CronJobExecution; + use DateInterval; + use DateTimeImmutable; + + class MyCronJob implements CronJobInterface + { + public function canRun(?CronJobExecution $cronJobExecution): bool + { + // the parameter $cronJobExecution contains data about the last execution of the cronjob + // if it is null, it should be executed immediatly + if (null === $cronJobExecution) { + return true; + } + + if ($cronJobExecution->getKey() !== $this->getKey()) { + throw new UnexpectedValueException(); + } + + // this cron job should be executed if the last execution is greater than one day, but only during the night + + $now = new DateTimeImmutable('now'); + + return $cronJobExecution->getLastStart() < $now->sub(new DateInterval('P1D')) + && in_array($now->format('H'), self::ACCEPTED_HOURS, true) + // introduce a random component to ensure a roll of task execution when multiple instances are hosted on same machines + && mt_rand(0, 5) === 0; + } + + public function getKey(): string + { + return 'arbitrary-and-unique-key'; + } + + public function run(): void + { + // here, we execute the command + } + } + +How are cron job scheduled ? +============================ + +If the command :code:`chill:cron-job:execute` is run with one or more :code:`job` argument, those jobs are run, **without checking that the job can run** (the method :code:`canRun` is not executed). + +If any :code:`job` argument is given, the :code:`CronManager` schedule job with those steps: + +* the tasks are ordered, with: + * a priority is given for tasks that weren't never executed; + * then, the tasks are ordered, the last executed are the first in the list +* then, for each tasks, and in the given order, the first task where :code:`canRun` return :code:`TRUE` will be executed. + +The command :code:`chill:cron-job:execute` execute **only one** task. + + + diff --git a/docs/source/development/index.rst b/docs/source/development/index.rst index f35bc12db..52c541c8e 100644 --- a/docs/source/development/index.rst +++ b/docs/source/development/index.rst @@ -34,6 +34,7 @@ As Chill rely on the `symfony `_ framework, reading the fram Useful snippets manual/index.rst Assets + Cron Jobs Layout and UI ************** diff --git a/docs/source/installation/index.rst b/docs/source/installation/index.rst index 01a8c4aee..29856f2e4 100644 --- a/docs/source/installation/index.rst +++ b/docs/source/installation/index.rst @@ -18,6 +18,7 @@ Installation & Usage :maxdepth: 2 prod.rst + load-addresses.rst prod-calendar-sms-sending.rst msgraph-configure.rst @@ -170,7 +171,7 @@ There are several users available: The password is always ``password``. -Now, read `Operations` below. +Now, read `Operations` below. For running in production, read `prod_`. Operations diff --git a/docs/source/installation/load-addresses.rst b/docs/source/installation/load-addresses.rst new file mode 100644 index 000000000..779032fd0 --- /dev/null +++ b/docs/source/installation/load-addresses.rst @@ -0,0 +1,50 @@ + +.. _addresses: + +Addresses +********* + +Chill can store a list of geolocated address references, which are used to suggest address and ensure that the data is correctly stored. + +Those addresses may be load from a dedicated source. + +In France +========= + +The address are loaded from the `BANO `_. The postal codes are loaded from `the official list of +postal codes `_ + +.. code-block:: bash + + # first, load postal codes + bin/console chill:main:postal-code:load:FR + # then, load all addresses, by departement (multiple departement can be loaded by repeating the departement code + bin/console chill:main:address-ref-from-bano 57 54 51 + +In Belgium +========== + +Addresses are prepared from the `BeST Address data `_. + +Postal code are loaded from this database. There is no need to load postal codes from another source (actually, this is strongly discouraged). + +The data are prepared for Chill (`See this repository `_). +One can select postal code by his first number (:code:`1xxx` for postal codes from 1000 to 1999), or a limited list for development purpose. + +.. code-block:: bash + + # load postal code from 1000 to 3999: + bin/console chill:main:address-ref-from-best-addresse 1xxx 2xxx 3xxx + + # load only an extract (for dev purposes) + bin/console chill:main:address-ref-from-best-addresse extract + + # load full addresses (discouraged) + bin/console chill:main:address-ref-from-best-addresse full + +.. note:: + + There is a possibility to load the full list of addresses is discouraged: the loading is optimized with smaller extracts. + + Once you load the full list, it is not possible to load smaller extract: each extract loaded **after** will not + delete the addresses loaded with the full extract (and some addresses will be present twice). diff --git a/docs/source/installation/prod.rst b/docs/source/installation/prod.rst index 4e670b8f8..f51141e8d 100644 --- a/docs/source/installation/prod.rst +++ b/docs/source/installation/prod.rst @@ -1,10 +1,12 @@ .. Copyright (C) 2014-2019 Champs Libres Cooperative SCRLFS -Permission is granted to copy, distribute and/or modify this document -under the terms of the GNU Free Documentation License, Version 1.3 -or any later version published by the Free Software Foundation; -with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. -A copy of the license is included in the section entitled "GNU -Free Documentation License". + Permission is granted to copy, distribute and/or modify this document + under the terms of the GNU Free Documentation License, Version 1.3 + or any later version published by the Free Software Foundation; + with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. + A copy of the license is included in the section entitled "GNU + Free Documentation License". + +.. _prod: Installation for production ########################### @@ -36,12 +38,25 @@ This should be adapted to your needs: * Think about how you will backup your database. Some adminsys find easier to store database outside of docker, which might be easier to administrate or replicate. +Cron jobs +========= + +The command :code:`chill:cron-job:execute` should be executed every 15 minutes (more or less). + +This command should never be executed concurrently. It should be not have more than one process for a single instance. + +Post-install tasks +================== + +- import addresses. See :ref:`addresses`. + + Tweak symfony messenger ======================= Calendar sync is processed using symfony messenger. -You can tweak the configuration +You can tweak the configuration Going further: diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php index 4e3722ea8..2316a5408 100644 --- a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php @@ -51,8 +51,13 @@ class CalendarForShortMessageProvider */ public function getCalendars(DateTimeImmutable $at): iterable { - ['startDate' => $startDate, 'endDate' => $endDate] = $this->rangeGenerator - ->generateRange($at); + $range = $this->rangeGenerator->generateRange($at); + + if (null === $range) { + return; + } + + ['startDate' => $startDate, 'endDate' => $endDate] = $range; $offset = 0; $batchSize = 10; diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultRangeGenerator.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultRangeGenerator.php index 5cd4a6168..7d6da3ef0 100644 --- a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultRangeGenerator.php +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultRangeGenerator.php @@ -31,14 +31,14 @@ use UnexpectedValueException; */ class DefaultRangeGenerator implements RangeGeneratorInterface { - public function generateRange(\DateTimeImmutable $date): array + public function generateRange(\DateTimeImmutable $date): ?array { $onMidnight = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date->format('Y-m-d') . ' 00:00:00'); switch ($dow = (int) $onMidnight->format('w')) { case 6: // Saturday case 0: // Sunday - return ['startDate' => null, 'endDate' => null]; + return null; case 1: // Monday // send for Tuesday and Wednesday diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/RangeGeneratorInterface.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/RangeGeneratorInterface.php index 5ba5acb35..357e264d9 100644 --- a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/RangeGeneratorInterface.php +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/RangeGeneratorInterface.php @@ -23,7 +23,7 @@ use DateTimeImmutable; interface RangeGeneratorInterface { /** - * @return array + * @return ?array{startDate: DateTimeImmutable, endDate: DateTimeImmutable} when return is null, then no ShortMessage must be send */ - public function generateRange(DateTimeImmutable $date): array; + public function generateRange(DateTimeImmutable $date): ?array; } diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php index 261687515..f3e35ef93 100644 --- a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php @@ -22,6 +22,7 @@ use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Repository\CalendarRepository; use Chill\CalendarBundle\Service\ShortMessageNotification\CalendarForShortMessageProvider; use Chill\CalendarBundle\Service\ShortMessageNotification\DefaultRangeGenerator; +use Chill\CalendarBundle\Service\ShortMessageNotification\RangeGeneratorInterface; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; @@ -37,6 +38,32 @@ final class CalendarForShortMessageProviderTest extends TestCase { use ProphecyTrait; + public function testGenerateRangeIsNull() + { + $calendarRepository = $this->prophesize(CalendarRepository::class); + $calendarRepository->findByNotificationAvailable( + Argument::type(DateTimeImmutable::class), + Argument::type(DateTimeImmutable::class), + Argument::type('int'), + Argument::exact(0) + )->shouldBeCalledTimes(0); + $rangeGenerator = $this->prophesize(RangeGeneratorInterface::class); + $rangeGenerator->generateRange(Argument::type(DateTimeImmutable::class))->willReturn(null); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->clear()->shouldNotBeCalled(); + + $provider = new CalendarForShortMessageProvider( + $calendarRepository->reveal(), + $em->reveal(), + $rangeGenerator->reveal() + ); + + $calendars = iterator_to_array($provider->getCalendars(new DateTimeImmutable('now'))); + + $this->assertEquals(0, count($calendars)); + } + public function testGetCalendars() { $calendarRepository = $this->prophesize(CalendarRepository::class); diff --git a/src/Bundle/ChillMainBundle/ChillMainBundle.php b/src/Bundle/ChillMainBundle/ChillMainBundle.php index 4442bd19b..9fd6a9891 100644 --- a/src/Bundle/ChillMainBundle/ChillMainBundle.php +++ b/src/Bundle/ChillMainBundle/ChillMainBundle.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\MainBundle; +use Chill\MainBundle\Cron\CronJobInterface; use Chill\MainBundle\CRUD\CompilerPass\CRUDControllerCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\ACLFlagsCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\ExportsCompilerPass; @@ -59,6 +60,8 @@ class ChillMainBundle extends Bundle ->addTag('chill.count_notification.user'); $container->registerForAutoconfiguration(EntityWorkflowHandlerInterface::class) ->addTag('chill_main.workflow_handler'); + $container->registerForAutoconfiguration(CronJobInterface::class) + ->addTag('chill_main.cron_job'); $container->addCompilerPass(new SearchableServicesCompilerPass()); $container->addCompilerPass(new ConfigConsistencyCompilerPass()); diff --git a/src/Bundle/ChillMainBundle/Command/ExecuteCronJobCommand.php b/src/Bundle/ChillMainBundle/Command/ExecuteCronJobCommand.php new file mode 100644 index 000000000..0e81177dc --- /dev/null +++ b/src/Bundle/ChillMainBundle/Command/ExecuteCronJobCommand.php @@ -0,0 +1,55 @@ +cronManager = $cronManager; + } + + protected function configure() + { + $this + ->setDescription('Execute the cronjob(s) given as argument, or one cronjob scheduled by system.') + ->setHelp("If no job is specified, the next available cronjob will be executed by system.\nThis command should be execute every 15 minutes (more or less)") + ->addArgument('job', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'one or more job to force execute (by default, all jobs are executed)', []) + ->addUsage(''); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + if ([] === $input->getArgument('job')) { + $this->cronManager->run(); + + return 0; + } + + foreach ($input->getArgument('job') as $jobName) { + $this->cronManager->run($jobName); + } + + return 0; + } +} 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) { + if ($job->getKey() === $forceJob) { + $job->run(); + } + } + } +} diff --git a/src/Bundle/ChillMainBundle/Cron/CronManagerInterface.php b/src/Bundle/ChillMainBundle/Cron/CronManagerInterface.php new file mode 100644 index 000000000..d2292d455 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Cron/CronManagerInterface.php @@ -0,0 +1,20 @@ +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..a3c495d7d --- /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 CronJobExecution::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 @@ +connection = $connection; + } + + public function canRun(?CronJobExecution $cronJobExecution): bool + { + if (null === $cronJobExecution) { + return true; + } + + if ($cronJobExecution->getKey() !== $this->getKey()) { + throw new UnexpectedValueException(); + } + + $now = new DateTimeImmutable('now'); + + return $cronJobExecution->getLastStart() < $now->sub(new DateInterval('P1D')) + && in_array($now->format('H'), self::ACCEPTED_HOURS, true) + // introduce a random component to ensure a roll when multiple instances are hosted on same machines + && mt_rand(0, 5) === 0; + } + + public function getKey(): string + { + return 'refresh-materialized-view-address-to-geog-units'; + } + + public function run(): void + { + $this->connection->executeQuery('REFRESH MATERIALIZED VIEW view_chill_main_address_geographical_unit'); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Cron/CronManagerTest.php b/src/Bundle/ChillMainBundle/Tests/Cron/CronManagerTest.php new file mode 100644 index 000000000..4b812ce2b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Cron/CronManagerTest.php @@ -0,0 +1,201 @@ +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 + { + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Services/AddressGeographicalUnit/RefreshAddressToGeographicalUnitMaterializedViewCronJobTest.php b/src/Bundle/ChillMainBundle/Tests/Services/AddressGeographicalUnit/RefreshAddressToGeographicalUnitMaterializedViewCronJobTest.php new file mode 100644 index 000000000..f4e4a586e --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Services/AddressGeographicalUnit/RefreshAddressToGeographicalUnitMaterializedViewCronJobTest.php @@ -0,0 +1,46 @@ +connection = self::$container->get(Connection::class); + } + + public function testFullRun(): void + { + $job = new \Chill\MainBundle\Service\AddressGeographicalUnit\RefreshAddressToGeographicalUnitMaterializedViewCronJob( + $this->connection + ); + + $lastExecution = new CronJobExecution($job->getKey()); + $lastExecution->setLastStart(new DateTimeImmutable('2 days ago')); + + $this->assertIsBool($job->canRun($lastExecution)); + + $job->run(); + } +} diff --git a/src/Bundle/ChillMainBundle/config/services.yaml b/src/Bundle/ChillMainBundle/config/services.yaml index f99c80d2c..6248c508e 100644 --- a/src/Bundle/ChillMainBundle/config/services.yaml +++ b/src/Bundle/ChillMainBundle/config/services.yaml @@ -102,12 +102,19 @@ services: Chill\MainBundle\Security\Resolver\CenterResolverDispatcherInterface: '@Chill\MainBundle\Security\Resolver\CenterResolverDispatcher' - Chill\MainBundle\Service\Import\: - resource: '../Service/Import/' + Chill\MainBundle\Service\: + resource: '../Service/' autowire: true autoconfigure: true - Chill\MainBundle\Service\RollingDate\: - resource: '../Service/RollingDate/' + Chill\MainBundle\Cron\: + resource: '../Cron' autowire: true autoconfigure: true + + Chill\MainBundle\Cron\CronManager: + autoconfigure: true + autowire: true + lazy: true + arguments: + $jobs: !tagged_iterator chill_main.cron_job diff --git a/src/Bundle/ChillMainBundle/config/services/command.yaml b/src/Bundle/ChillMainBundle/config/services/command.yaml index e5f285545..f9a863f10 100644 --- a/src/Bundle/ChillMainBundle/config/services/command.yaml +++ b/src/Bundle/ChillMainBundle/config/services/command.yaml @@ -61,3 +61,9 @@ services: autowire: true tags: - { name: console.command } + + Chill\MainBundle\Command\ExecuteCronJobCommand: + autoconfigure: true + autowire: true + tags: + - {name: console.command } diff --git a/src/Bundle/ChillMainBundle/migrations/Version20221212163734.php b/src/Bundle/ChillMainBundle/migrations/Version20221212163734.php new file mode 100644 index 000000000..3ec61d38f --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20221212163734.php @@ -0,0 +1,36 @@ +addSql('DROP TABLE chill_main_cronjob_execution'); + } + + public function getDescription(): string + { + return 'Table for executed jobs'; + } + + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE chill_main_cronjob_execution (key TEXT NOT NULL, lastEnd TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + lastStart TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, lastStatus INTEGER DEFAULT NULL, PRIMARY KEY(key))'); + $this->addSql('COMMENT ON COLUMN chill_main_cronjob_execution.lastEnd IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_main_cronjob_execution.lastStart IS \'(DC2Type:datetime_immutable)\''); + } +} diff --git a/src/Bundle/ChillPersonBundle/Resources/views/HouseholdCompositionType/index.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/HouseholdCompositionType/index.html.twig index 252e932e8..3a9285aea 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/HouseholdCompositionType/index.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/HouseholdCompositionType/index.html.twig @@ -24,7 +24,7 @@
  • - +