Skip to content

Commit

Permalink
Add a cron service and command with cron expressions (see #1098)
Browse files Browse the repository at this point in the history
Description
-----------

This is a revamp of my original PR #708. The basic features and service tag options are the same - with the addition of being able to use full CRON expressions, instead of just `minutely`, `hourly`, etc.!

### Command

Cron jobs can now be executed via the following command:

```
vendor/bin/contao-console contao:cron
```

This can be used in a minutely crontab for example. When using the command, only cron jobs with no scope definition (`null`) or the `cli` scope will be executed, jobs with the scope `web` will be skipped.

### Service Tag

The new service tag `contao.cronjob` has the following options:

| Option | Description |
| --- | --- |
| `interval` | Can be `minutely`, `hourly`, `daily`, `weekly`, `monthly`, `yearly` or a full CRON expression, like `*/5 * * * *`. |
| `method` | Will default to `__invoke` or `onMinutely` etc. when a named interval is used. Otherwise a method name has to be defined. |

_Example:_

```yml
services:
  App\Cron\ExampleCron:
    tags:
      -
        name: contao.cronjob
        interval: * 0 * * *
        method: onDaily
```

### Annotation

Tagging services is also possible via Annotations:

```php
// src/Cron/ExampleCronJob.php
namespace App\Cron;

use Contao\CoreBundle\ServiceAnnotation\CronJob;
use Terminal42\ServiceAnnotationBundle\ServiceAnnotationInterface;

/**
 * @cronjob("* 0 * * *")
 */
class ExampleCronJob implements ServiceAnnotationInterface
{
    public function __invoke(): void
    {
        // …
    }
}
```

When the annotation is used on the class, either the `__invoke` method will be used - or an auto generated method name (e.g. `onMinutely`), if present. Basically the same concept as in #1063.

_Note:_ If you need an interval like `*/5 * * *` you need to escape either the `*` or `/` with `\`, since `*/` would close the PHP comment. This is what line 56 in the `Cron` annotation class is for. @aschempp may be this is something, that the ServiceAnnotationBundle should handle by itself?

### Front End Cron & Route

The `/_contao/cron` route still exists but now uses the new `Cron` service instead of the `Contao\FrontendCron` controller class. Same with the `Contao\CoreBundle\EventListener\CommandSchedulerListener` (poor man's cron). Cron jobs tagged with the scope `cli` will __not__ be executed for either.

### Additional Dependency

I am using [dragonmantank/cron-expression](https://github.com/dragonmantank/cron-expression) to parse CRON expressions and check if a cron job is, _or was_, due. I did not use [cron/cron](https://github.com/cron/cron) because it lacks the latter functionality. The Cron within Contao might or will be executed in irregular intervals, thus it needs to be checked whether a cron job with a defined interval would currently be _overdue_ relative to its last execution.

_Example:_ say you have cron job with an interval of `*/5 * * * *` and you are still using the front end command scheduler. This cron job would be due every 5 minutes of every hour - e.g. `00:00`, `00:05`, `00:10` etc. However, there might not be a request which would execute the Cron service every minute. If the Cron service is executed at `00:03:58` for example and the cron job has not been executed before, the cron job will be executed right away. Then if the next run of the Cron service is at `00:07:10` the cron job will be executed again, since its next run would have been at `00:05:00`, relative to its last execution time. If another request is made at `00:09:46`, the cron job will not be executed, since its next scheduled execution time would be at `00:10:00` - etc.

As for the package itself: it looks well maintained, has lots of installs and dependents - and apparently Laravel uses it too.

### New Entity

The PR introduces a new `Cron` entity, which simply saves the "name" of a cron job (consisting of the class name and method) and its last execution.

![tl_cron](https://user-images.githubusercontent.com/4970961/70894253-f4eae880-1fec-11ea-8d59-23b2068127ee.png)

### Backwards Compatibility

I have completely deleted the `tl_cron` DCA and am now using the `tl_cron` table for the new format (via an entity). This is probably not ideal for BC? ;)

If we want to keep the old `tl_cron` table as is, we could use a new `tl_cron_runs` table instead. In that case: should we still update the values in the `tl_cron` table? May be simply through the legacy cron.

### Scope

In some cases a cron job might want to know in which "scope" it is executed in - i.e. as part of a front end request as part of the cron command on the command line interface. The `Cron` service will pass a scope parameter to the cron job's method.

```php
namespace App\Cron;

use Contao\CoreBundle\Cron\Cron;

class HourlyCron
{
    public function __invoke(string $scope): void
    {
        // Do not execute this cron job in the web scope
        if (Cron::SCOPE_WEB === $scope) {
            return;
        }

        // …
    }
}
```

Commits
-------

028ca37 add cron command and scheduler using CRON expressions
6a43e20 remove superfluous use statement
e4dd67e implement changes reported by code analyzer
51ec0c1 remove priority and scope
b9f6766 add simple test for CronCommand
547df99 rename CronJobs
2004f2a implement various improvements
1305bde fix merge error
340d701 remove definition tests
e61623a fix unit tests
9900a32 update database dump for functional tests
b4be3a6 code style fix
5894ddb use Cron::class for reference check
75faa5d change database table for canRunDbQuery
d86c6ee try to fix code analysis errors
97f5487 fix return type declaration in test
1b23547 add CronJob class and scope interface
a7c28dc rename service annotation class to CronJob
2eb3915 Fix the coding style
aff1123 make CronJob method optional
5b2a38e remove final from CronJob class
f9f8122 rename interface to ScopeAwareCronJob
c39a210 use DateTimeImmutable
4f532c3 trigger TL_CRON deprecation warning also for core cron jobs
3453541 ammend interface renaming
ea85e8f update unit tests
dd8e05b remove connection checkt
9731e1a use constant for empty response
1b97ad3 add TODO comment
1751e23 add test for invalid scope argument
e74a701 CS fix
72ed188 change constants values
cc5a87f Fix the coding style
0cc1a4e make scope mandatory and remove scope interface
bcddfae remove superfluous service tag
b1918a4 change addCronJob parameter to CronJob object
c421efa remove persistAndFlush method from CronJobRepository
e288a7e fix rebase errors
80a2dab remove service argument for FrontendController
  • Loading branch information
fritzmg authored and leofeyer committed Jan 15, 2020
1 parent 1d68530 commit ad125f7
Show file tree
Hide file tree
Showing 30 changed files with 1,495 additions and 185 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"doctrine/doctrine-bundle": "^1.8",
"doctrine/doctrine-cache-bundle": "^1.3.1",
"doctrine/orm": "^2.6.3",
"dragonmantank/cron-expression": "^2.3",
"friendsofsymfony/http-cache": "^2.6",
"friendsofsymfony/http-cache-bundle": "^2.6",
"imagine/imagine": "^0.7 || ^1.0",
Expand Down
1 change: 1 addition & 0 deletions core-bundle/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"doctrine/doctrine-bundle": "^1.8",
"doctrine/doctrine-cache-bundle": "^1.3.1",
"doctrine/orm": "^2.6.3",
"dragonmantank/cron-expression": "^2.3",
"friendsofsymfony/http-cache": "^2.6",
"friendsofsymfony/http-cache-bundle": "^2.6",
"imagine/imagine": "^0.7 || ^1.0",
Expand Down
54 changes: 54 additions & 0 deletions core-bundle/src/Command/CronCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

/*
* This file is part of Contao.
*
* (c) Leo Feyer
*
* @license LGPL-3.0-or-later
*/

namespace Contao\CoreBundle\Command;

use Contao\CoreBundle\Cron\Cron;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class CronCommand extends Command
{
/**
* @var Cron
*/
protected $cron;

public function __construct(Cron $cron)
{
$this->cron = $cron;

parent::__construct();
}

/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setName('contao:cron')
->setDescription('Runs all registered cron jobs on the command line.')
;
}

/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->cron->run(Cron::SCOPE_CLI);

return 0;
}
}
2 changes: 2 additions & 0 deletions core-bundle/src/ContaoCoreBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
namespace Contao\CoreBundle;

use Contao\CoreBundle\DependencyInjection\Compiler\AddAssetsPackagesPass;
use Contao\CoreBundle\DependencyInjection\Compiler\AddCronJobsPass;
use Contao\CoreBundle\DependencyInjection\Compiler\AddPackagesPass;
use Contao\CoreBundle\DependencyInjection\Compiler\AddResourcesPathsPass;
use Contao\CoreBundle\DependencyInjection\Compiler\AddSessionBagsPass;
Expand Down Expand Up @@ -97,5 +98,6 @@ public function build(ContainerBuilder $container): void
$container->addCompilerPass(new RegisterHookListenersPass(), PassConfig::TYPE_OPTIMIZE);
$container->addCompilerPass(new SearchIndexerPass());
$container->addCompilerPass(new EscargotSubscriberPass());
$container->addCompilerPass(new AddCronJobsPass());
}
}
13 changes: 7 additions & 6 deletions core-bundle/src/Controller/FrontendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@

namespace Contao\CoreBundle\Controller;

use Contao\FrontendCron;
use Contao\CoreBundle\Cron\Cron;
use Contao\FrontendIndex;
use Contao\FrontendShare;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\LogoutException;
Expand All @@ -40,13 +41,13 @@ public function indexAction(): Response
/**
* @Route("/_contao/cron", name="contao_frontend_cron")
*/
public function cronAction(): Response
public function cronAction(Request $request, Cron $cron): Response
{
$this->initializeContaoFramework();

$controller = new FrontendCron();
if ($request->isMethod(Request::METHOD_GET)) {
$cron->run(Cron::SCOPE_WEB);
}

return $controller->run();
return new Response('', Response::HTTP_NO_CONTENT);
}

/**
Expand Down
120 changes: 120 additions & 0 deletions core-bundle/src/Cron/Cron.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

declare(strict_types=1);

/*
* This file is part of Contao.
*
* (c) Leo Feyer
*
* @license LGPL-3.0-or-later
*/

namespace Contao\CoreBundle\Cron;

use Contao\CoreBundle\Entity\CronJob as CronJobEntity;
use Contao\CoreBundle\Repository\CronJobRepository;
use Cron\CronExpression;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;

class Cron
{
public const SCOPE_WEB = 'web';
public const SCOPE_CLI = 'cli';

/**
* @var CronJobRepository
*/
private $repository;

/**
* @var EntityManagerInterface
*/
private $entityManager;

/**
* @var LoggerInterface
*/
private $logger;

/**
* @var array<CronJob>
*/
private $cronJobs = [];

public function __construct(CronJobRepository $repository, EntityManagerInterface $entityManager, LoggerInterface $logger = null)
{
$this->repository = $repository;
$this->entityManager = $entityManager;
$this->logger = $logger;
}

public function addCronJob(CronJob $cronjob): void
{
$this->cronJobs[] = $cronjob;
}

/**
* Run all the registered Contao cron jobs.
*/
public function run(string $scope): void
{
// Validate scope
if (self::SCOPE_WEB !== $scope && self::SCOPE_CLI !== $scope) {
throw new \InvalidArgumentException('Invalid scope "'.$scope.'"');
}

/** @var array<CronJob> */
$cronJobsToBeRun = [];
$now = new \DateTimeImmutable();

try {
// Lock cron table
$this->repository->lockTable();

// Go through each cron job
foreach ($this->cronJobs as $cron) {
$interval = $cron->getInterval();
$name = $cron->getName();

// Determine the last run date
$lastRunDate = null;

/** @var CronJobEntity $lastRunEntity */
$lastRunEntity = $this->repository->findOneByName($name);

if (null !== $lastRunEntity) {
$lastRunDate = $lastRunEntity->getLastRun();
} else {
$lastRunEntity = new CronJobEntity($name);
$this->entityManager->persist($lastRunEntity);
}

// Check if the cron should be run
$expression = CronExpression::factory($interval);

if (null === $lastRunDate || $now >= $expression->getNextRunDate($lastRunDate)) {
// Update the cron entry
$lastRunEntity->setLastRun($now);

// Add job to the crons to be run
$cronJobsToBeRun[] = $cron;
}
}

$this->entityManager->flush();
} finally {
$this->repository->unlockTable();
}

// Execute all crons to be run
foreach ($cronJobsToBeRun as $cron) {
if (null !== $this->logger) {
$this->logger->debug(sprintf('Executing cron job "%s"', $cron->getName()));
}

$cron($scope);
}
}
}
83 changes: 83 additions & 0 deletions core-bundle/src/Cron/CronJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

/*
* This file is part of Contao.
*
* (c) Leo Feyer
*
* @license LGPL-3.0-or-later
*/

namespace Contao\CoreBundle\Cron;

use InvalidArgumentException;

class CronJob
{
/**
* @var object
*/
private $service;

/**
* @var string
*/
private $method;

/**
* @var string
*/
private $interval;

/**
* @var string
*/
private $name;

public function __construct(object $service, string $interval, string $method = null)
{
$this->service = $service;
$this->method = $method;
$this->interval = $interval;
$this->name = \get_class($service);

if (!\is_callable($service)) {
if (null === $this->method) {
throw new InvalidArgumentException('Service must be a callable when no method name is defined');
}

$this->name .= '::'.$method;
}
}

public function __invoke(string $scope): void
{
if (\is_callable($this->service)) {
($this->service)($scope);
} else {
$this->service->{$this->method}($scope);
}
}

public function getService(): object
{
return $this->service;
}

public function getMethod(): string
{
return $this->method;
}

public function getInterval(): string
{
return $this->interval;
}

public function getName(): string
{
return $this->name;
}
}
Loading

0 comments on commit ad125f7

Please sign in to comment.