Accueil /  Blog / Symfony / Une crontab sous stéroïdes

Une crontab sous stéroïdes

Publié le jeudi 3 octobre 2024

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

Utilisation d'une crontab

C'est quoi ?

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

Quel est le problème ?

Composition

Lorsque vous ajoutez une commande à la crontab, vous devez penser que :

  • elle n'offre aucune garantie quant à la parallélisation de commandes
  • si votre commande a une sortie, tout sera envoyé par email système

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 fichier

C'est efficace, mais légèrement fastidieux, et ça alourdi la lecture des crontabs.

Déploiement

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.

Observabilité

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 :

  • vous devez vous connecter sur le serveur et parcourir le fichier de log
  • le fichier de log contient toutes les sorties de chacune des itérations de votre crontab
    • vous devez donc fouiller dans tout le fichier pour y chercher une occurrence
    • attention à ce que ce fichier ne vous pose pas de problème d'espace disque

L'alternative Symfony Scheduler

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).

Mise en place

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()
    {
        // ...
    }
}

Problèmes résolus

Remplacer une crontab par le composant Scheduler résout plusieurs de nos problèmes précédents :

  • Composition : plus de crontab, donc plus de commande à rallonge à produire
  • Déploiement : votre crontab est décrite par votre code, qui porte les tâches et leur fréquence, elle se met donc à jour à chaque déploiement

Problèmes non résolus

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.

Scheduler sous stéroïdes

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 qui a tout changé

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 ?
    }
}

Combiner les deux librairies

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 ?

  • En tant que développeur, je souhaite pouvoir décrire une tâche cron, comme un job
  • À ce job, je vais devoir associer une fréquence d'exécution
  • Tous ces jobs devront être collectés et envoyés au Scheduler Symfony pour y être exécutés

Commenç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.

Rendre l'ensemble disponible sur une UI

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 ?

Collecter les crons

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.

Améliorer notre description des crons

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.

Ajouter des informations de Yokai Batch

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;
+    }
}

Historique d'exécution d'une tâche

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);
+        //...
+    }
}

Conclusion

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 :

Une crontab sous stéroïdes - CRON list

Une crontab sous stéroïdes - CRON detail

Une crontab sous stéroïdes - Job detail

Suivez notre actualité en avant première. Pas plus d’une newsletter par mois.