Proposition d'une alternative aux traditionnelles crontabs, dans un projet Symfony
Nous utilisons des crontabs depuis tellement longtemps, que l'idée de chercher un terme alternatif peut même être un défi.
Pourtant, ce concept n'est pas exempt de défaut (déploiement, observabilité, etc...).
Cet article vous proposera une alternative, que vous pourrez facilement implémenter dans vos projets Symfony.
Oui, cette alternative repose sur le composant Scheduler, mais ce n'est pas tout.
Je vous réserve de quoi améliorer encore les choses.
Alors même si vous êtes familier avec ce composant, prenez le temps de lire.
J'ai préparé un projet Symfony (presque) vierge, contenant tout le code présenté ici.
Les exemples et les captures d'écrans ont été réalisés sur la base de celui-ci :
https://github.com/yann-eugone/yokai-batch-scheduler-ui
cron est un programme qui permet aux utilisateurs des systèmes Unix d’exécuter automatiquement des scripts, des commandes ou des logiciels à une date et une heure spécifiée à l’avance, ou selon un cycle défini à l’avance.
https://fr.wikipedia.org/wiki/Cron
Le format d'une crontab est assez simple à comprendre, c'est un fichier texte, qui contient 2 "colonnes", la fréquence à gauche, la commande à exécuter à droite.
mm hh jj MMM JJJ commande à exécuter
Lorsque vous ajoutez une commande à la crontab, vous devez penser que :
Pour compenser ces faiblesses, on écrit souvent les entrées des crontabs comme suit :
mm hh jj MMM JJJ flock -xn .commande_normalisée.lock commande à exécuter &>> commande_normalisée.log
On a entouré notre commande à exécuter
par :
flock -xn .commande_normalisée.lock
pour éviter que la même entrée ne tourne 2 fois &>> commande_normalisée.log
pour rediriger toute la sortie dans un fichierC'est efficace, mais légèrement fastidieux, et ça alourdi la lecture des crontabs.
Lorsque vous développez une nouvelle feature qui nécessite une entrée dans la crontab,
vous devez penser à la mettre à jour, juste après avoir fait votre déploiement.
Faute de quoi, votre feature ne sera pas complète.
Vous ne pouvez pas le faire en avance, car la commande que vous avez ajoutée à votre code n'existe probablement pas encore.
Même en redirigeant la sortie de la commande, le résultat que vous aurez, est un empilement des sorties de vos commandes dans un même fichier.
Et là encore plusieurs problèmes se posent :
Depuis Symfony 6.3, le composant Scheduler
se propose de remplacer vos crontabs.
Le composant lui-même repose sur le composant Messenger
, et permet de publier des messages dans le bus de manière programmée (plus qu'une simple crontab).
Elle est très bien documentée dans Symfony depuis le temps.
Mais pour faire simple, et rester dans notre contexte des crontabs, pour programmer une nouvelle entrée, voilà ce que vous devez faire :
namespace App\Scheduler\Task;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
#[AsCronTask('0 0 * * *')]
class SendDailySalesReports
{
public function __invoke()
{
// ...
}
}
Remplacer une crontab par le composant Scheduler résout plusieurs de nos problèmes précédents :
Malheureusement, il reste un problème non résolu, et c'est un peu le problème de Messenger de manière générale :
Observabilité : les messages sont envoyés à messenger, au mieux, ils auront produit des logs, mélangés avec le reste des logs de l'application.
Posons-nous les bonnes questions : de quoi avons-nous besoin ?
Chaque fois qu'une tâche programmée est exécutée, elle devrait pouvoir disposer de ses propres artefacts d'exécution.
A posteriori, lorsque je souhaite comprendre ce qu'il s'est passé, je peux me concentrer sur le rapport d'une seule occurrence.
La librairie yokai/batch
a été développée par plusieurs développeurs de PrestaConcept.
Nous nous en servons depuis plusieurs années maintenant pour gérer toutes nos tâches de traitement par lot (ou batch processing, d'où le nom de la librairie).
Dans ce contexte, l'observabilité est rapidement devenu un sujet, puisqu'une tâche d'import de plusieurs milliers de lignes, exécutée en asynchrone, nécessite d'être tracée afin d'indiquer à l'utilisateur le résultat.
Pour faire simple, la librairie met à votre disposition le concept de Job
.
Un Job
, ce n'est jamais qu'une classe qui implémente l'interface JobInterface
:
use Yokai\Batch\JobExecution;
use Yokai\Batch\Job\JobInterface;
class DoStuffJob implements JobInterface
{
public function execute(JobExecution $jobExecution): void
{
// ici la logique de votre job
}
}
Mais la librairie prend à sa charge la fabrication et le stockage de l'objet JobExecution
,
dans lequel vous pouvez sauvegarder des informations de ce qu'il s'est passé.
use Yokai\Batch\JobExecution;
use Yokai\Batch\Job\JobInterface;
class DoStuffJob implements JobInterface
{
public function execute(JobExecution $jobExecution): void
{
$jobExecution->getLogger()->error('Something failed.');
$jobExecution->getSummary()->increment('errors');
}
}
Et depuis n'importe quel emplacement de votre application, vous pouvez demander le lancement d'un job,
dès lors que vous mettez la main sur le service prévu à cet effet : JobLauncherInterface
.
namespace App\Job;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Yokai\Batch\Launcher\JobLauncherInterface;
final class ImportController extends AbstractController
{
public function __invoke(JobLauncherInterface $jobLauncher): Response
{
$jobExecution = $jobLauncher->launch('import');
// now you can look for information in JobExecution
// or if execution is asynchronous, redirect user to a UI where he will watch it ?
}
}
Depuis quelques temps, la librairie dispose d'un bridge avec Messenger
.
Il est donc possible de demander l'exécution d'un job au travers d'un bus de messages, quel qu'il soit.
Comme nous l'avons écrit plus haut, le composant Scheduler
repose sur Messenger
.
Donc, vous le voyez venir : les 2 librairies sont compatibles !
Qu'est-ce-qu'on cherche à faire ?
Scheduler
Symfony pour y être exécutésCommençons déjà par décrire ce Job
un peu particulier, et comme c'est encore la solution la plus simple,
nous allons lui créer une interface dédiée.
namespace App\Cron;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Yokai\Batch\Bridge\Symfony\Framework\JobWithStaticNameInterface;
use Yokai\Batch\Job\JobInterface;
#[AutoconfigureTag]
interface CronJobInterface extends JobInterface, JobWithStaticNameInterface
{
public static function schedule(): string;
}
Maintenant que Symfony sait trouver tous nos crons, nous allons pouvoir les injecter dans un ScheduleProviderInterface
,
comme Symfony le suggère dans sa documentation.
namespace App\Cron;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Yokai\Batch\Bridge\Symfony\Messenger\LaunchJobMessage;
#[AsSchedule(name: 'cron')]
final readonly class CronScheduleProvider implements ScheduleProviderInterface
{
public function __construct(
/** @var iterable<CronJobInterface> */
#[AutowireIterator(CronJobInterface::class)]
private iterable $tasks,
) {
}
public function getSchedule(): Schedule
{
$schedule = new Schedule();
foreach ($this->tasks as $task) {
$schedule->add(
RecurringMessage::cron($task::schedule(), new LaunchJobMessage($task::getJobName())),
);
}
return $schedule;
}
}
À ce moment-là de l'histoire, chaque fois que que Symfony exécute une tâche programmée, un nouveau job démarre.
Quand un job démarre, une JobExecution
est créée, puis sauvegardée avant, pendant et après que le job ait été exécuté.
On peut donc aller regarder dans le JobExecutionStorageInterface
ce qu'il s'est passé dans telle ou telle occurrence d'une tâche programmée.
Mais nous pouvons faire encore mieux !
En effet, on a bien séparé nos artefacts, mais pour l'instant, il est toujours nécessaire d'aller sur le serveur pour voir ce qu'il se passe.
Ne pourrait-on pas rendre tout ça visualisable depuis une interface graphique ?
La documentation Symfony parle d'une commande permettant de debug les Schedulers.
A priori, cette commande est capable d'accéder et de décrire toutes les tâches programmées qui sont enregistrées.
On devrait donc pouvoir s'en inspirer pour faire ce dont nous avons besoin.
Mais nous allons simplifier, car nous n'avons pas l'utilité de plusieurs Scheduler
ici.
Nous utiliserons simplement le service CronScheduleProvider
que nous avons écrit précédemment.
On peut en extraire les messages récurrents qui le composent, il ne reste plus qu'à décrire tout ce que l'on souhaite pour chaque message.
namespace App\Controller;
use App\Cron\CronScheduleProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Clock\Clock;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route(path: '/crons')]
final class CronsController extends AbstractController
{
public function __construct(
private readonly CronScheduleProvider $cronScheduleProvider,
) {
}
#[Route(path: '/list', name: 'admin_cron_list')]
public function list(): Response
{
$this->getCrons();
// ...
}
/**
* @return array<string, array{
* trigger: string,
* description: string,
* nextRunDate: \DateTimeImmutable|null,
* }>
*/
private function getCrons(): array
{
$crons = [];
foreach ($this->cronScheduleProvider->getSchedule()->getRecurringMessages() as $recurringMessage) {
$trigger = $recurringMessage->getTrigger();
$provider = $recurringMessage->getProvider();
$crons[$provider->getId()] = [
'trigger' => (string)$trigger,
'description' => $provider instanceof \Stringable ? (string)$provider : $provider->getId(),
'nextRunDate' => $trigger->getNextRunDate(Clock::get()->now()),
];
}
return $crons;
}
}
Reste à afficher ces données comme bon vous semble, dans un template, ou de les exposer dans une API de votre choix.
C'est assez dommage, avec ce que l'on a produit, la seule chose que voit Symfony de nos tâches programmées,
c'est le FQCN des messages que l'on va publier.
Sauf que nous publions toujours le même message : LaunchJobMessage
, l'interface ne ressemble donc pas à grand chose.
Essayons de décrire nos tâches un peu mieux.
namespace App\Cron;
+use Symfony\Component\Scheduler\Trigger\StaticMessageProvider;
+use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class CronScheduleProvider implements ScheduleProviderInterface
{
public function __construct(
+ private TranslatorInterface $translator,
) {
}
public function getSchedule(): Schedule
{
$schedule = new Schedule();
foreach ($this->tasks as $task) {
$schedule->add(
- RecurringMessage::cron($task::schedule(), new LaunchJobMessage($task::getJobName())),
+ RecurringMessage::cron(
+ expression: $task::schedule(),
+ message: new StaticMessageProvider(
+ messages: [new LaunchJobMessage($task::getJobName())],
+ id: $task::getJobName(),
+ description: $this->translator->trans(
+ id: 'job.job_name.' . $task::getJobName(),
+ domain: 'YokaiBatchBundle',
+ ),
+ ),
+ ),
);
}
return $schedule;
}
}
Voilà, maintenant Symfony sera capable d'afficher la traduction associée à chaque job, que ce soit dans l'interface que nous avons créé, ou même dans sa commande de debug.
Jusqu'à présent, on s'est contenté d'extraire les informations que le composant Scheduler nous met à disposition.
Est-ce-qu'on pourrait y ajouter les informations à propos des dernières exécutions de chaque tâche ?
On peut. Il faudra utiliser le JobExecutionStorageInterface
, fourni par Yokai Batch, pour demander la dernière exécution de chaque job.
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
+use Yokai\Batch\JobExecution;
+use Yokai\Batch\Storage\Query;
+use Yokai\Batch\Storage\QueryableJobExecutionStorageInterface;
+use Yokai\Batch\Storage\QueryBuilder;
final class CronsController extends AbstractController
{
public function __construct(
private readonly CronScheduleProvider $cronScheduleProvider,
+ private readonly QueryableJobExecutionStorageInterface $jobExecutionStorage,
) {
}
/**
* @return array<string, array{
* trigger: string,
* description: string,
* nextRunDate: \DateTimeImmutable|null,
+ * lastExecution: JobExecution|null,
* }>
*/
private function getCrons(): array
{
$crons = [];
foreach ($this->cronScheduleProvider->getSchedule()->getRecurringMessages() as $recurringMessage) {
$trigger = $recurringMessage->getTrigger();
$provider = $recurringMessage->getProvider();
$crons[$provider->getId()] = [
'trigger' => (string)$trigger,
'description' => $provider instanceof \Stringable ? (string)$provider : $provider->getId(),
'nextRunDate' => $trigger->getNextRunDate(Clock::get()->now()),
+ 'lastExecution' => $this->getExecutions($provider->getId(), 1)[0] ?? null,
];
}
return $crons;
}
+
+ /**
+ * @return array<JobExecution>
+ */
+ private function getExecutions(string $name, int $limit): array
+ {
+ try {
+ $query = (new QueryBuilder())
+ ->jobs([$name])
+ ->sort(Query::SORT_BY_START_DESC)
+ ->limit($limit, 0)
+ ->getQuery();
+ } catch (\Throwable $exception) {
+ throw new BadRequestHttpException(previous: $exception);
+ }
+
+ $executions = [];
+ foreach ($this->jobExecutionStorage->query($query) as $execution) {
+ $executions[] = $execution;
+ }
+
+ return $executions;
+ }
}
La dernière étape consiste à produire un écran de détail pour chacun de nos crons.
Depuis cet écran, on souhaite afficher les dernières exécutions d'une tâche donnée.
Là encore, on va pouvoir demander les dernières exécutions au JobExecutionStorageInterface
.
final class CronsController extends AbstractController
{
+ #[Route(path: '/{name}', name: 'admin_cron_show')]
+ public function show(string $name): Response
+ {
+ $cron = $this->getCrons()[$name] ?? throw $this->createNotFoundException();
+ $executions = $this->getExecutions($name, 10);
+ //...
+ }
}
Grâce à 2 librairies bien utiles, nous avons facilement pu nous libérer des défauts de la crontab.
Maintenant, nos tâches programmées sont pilotées par notre code, et systématiquement à jour.
Chaque fois que nous en aurons besoin, nous pourrons accéder au détail de chaque exécution de chaque tâche.
Avec quelques lignes de code, nous avons même été en mesure de construire une interface graphique pour visualiser tout ça,
de telle sorte que tous les utilisateurs peuvent maintenant aller voir ce qu'il se passe :