6min.

Maîtrisez la planification des tâches avec Symfony Scheduler

This blog post is also available in 🇬🇧 English: Master task scheduling with Symfony Scheduler.

Section intitulée introductionIntroduction

Aujourd’hui, utiliser une crontab pour nos tâches récurrentes est assez courant mais pas très pratique car complètement déconnecté de notre application. Le composant Scheduler se présente comme une excellente alternative. Il a été introduit en 6.3 par Fabien Potencier lors de sa keynote d’ouverture du SymfonyLive Paris 2023. Le composant est maintenant réputé comme stable depuis la sortie de Symfony 6.4. Regardons comment l’utiliser !

Section intitulée installationInstallation

Installons le composant :

composer require symfony/messenger symfony/scheduler

Comme toutes les fonctionnalités du composant se basent sur Messenger, il est nécessaire de l’installer aussi.

Section intitulée une-premiere-tacheUne première tâche

Créons un premier message à planifier :

// src/Message/Foo.php
readonly final class Foo {}

// src/Handler/FooHandler.php
#[AsMessageHandler]
readonly final class FooHandler
{
    public function __invoke(Foo $foo): void
    {
        sleep(5);
    }
}

De la même manière qu’un Message dispatché dans Messenger, nous dispatchons ici un Message, que Scheduler traitera de façon similaire à Messenger, excepté que le déclenchement du traitement se fera sur une base temporelle

En plus du couple Message/Handler, nous avons besoin de définir un « Schedule » :

#[AsSchedule(name: 'default')]
class Scheduler implements ScheduleProviderInterface
{
    public function getSchedule(): Schedule
    {
        return (new Schedule())->add(
            RecurringMessage::every('2 days', new Foo())
        );
    }
}

Il va permettre d’indiquer à notre application que nous avons un Schedule « default » qui contient un message lancé tous les deux jours. Ici, la fréquence est simple, mais il est tout à fait possible de configurer cela plus finement :

RecurringMessage::every('1 second', $msg)
RecurringMessage::every('15 day', $msg)

# format relatif
RecurringMessage::every('next friday', $msg)
RecurringMessage::every('first sunday of next month', $msg)

# se lance à un horaire spécifique tous les jours
RecurringMessage::every('1 day', $msg, from: '14:42')
# vous pouvez donner un objet DateTime aussi
RecurringMessage::every('1 day', $msg,
    from: new \DateTimeImmutable('14:42', new \DateTimeZone('Europe/Paris'))
)

# définir la fin de la récurrence
RecurringMessage::every('1 day', $msg, until: '2023-09-21')

# vous pouvez aussi utiliser des expressions cron
RecurringMessage::cron('42 14 * * 2', $msg) // every Tuesday at 14:42
RecurringMessage::cron('#midnight', $msg)
RecurringMessage::cron('#weekly', $msg)

Ici, nous pouvons voir des formats relatifs; vous trouverez plus d’informations sur ce format en PHP sur la page de documentation.

Pour les syntaxes cron, il vous faudra installer une librairie tierce qui permet à Scheduler de les interpréter :

composer require dragonmantank/cron-expression

Une fois votre Schedule défini, comme pour un transport Messenger, il vous faudra un worker qui va écouter sur le Schedule de la façon suivante:

bin/console messenger:consume -v scheduler_default

Le préfix scheduler_ est le nom générique du transport pour tous les Schedule, auquel nous ajoutons le nom du Schedule créé.

Section intitulée les-collisionsLes collisions

Plus nous avons de tâches, plus nous avons de chances d’avoir des tâches qui vont arriver au même moment. Mais si une collision arrive, comment Scheduler va-t-il gérer ça ? Imaginons le cas suivant :

(new Schedule())->add(
    RecurringMessage::every('2 days', new Foo()),
    RecurringMessage::every('3 days', new Foo())
);

Tous les 6 jours, les deux messages vont entrer en collision :

Schedule collision

Si nous avons qu’un seul worker, alors il prendra la première tâche configurée dans le Schedule puis, une fois la première tâche finie, il exécutera la seconde tâche. Autrement dit, l’heure d’exécution de la 2ème tâche est dépendante de la durée d’exécution de la 1ère.

Souvent nous voulons que nos tâches soient exécutées à un moment précis, pour régler ce soucis il existe deux solutions:

  • La bonne pratique serait de préciser la date et heure d’exécution de notre tâche grâce au paramètre from, par exemple: RecurringMessage::every('1 day', $msg, from: '14:42') pour un des messages et fixer à 15:42 pour l’autre tâche (aussi possible avec une syntaxe cron) ;
  • Avoir plusieurs workers qui tournent: si vous avez 2 workers, alors il pourra gérer 2 tâches en même temps !

Section intitulée plusieurs-workersPlusieurs workers ?

Mais aujourd’hui, si nous lançons 2 workers, notre tâche sera exécutée deux fois !

Schedule workers

Scheduler fournit les outils pour éviter ça ! Mettons un peu à jour notre Schedule :

#[AsSchedule(name: 'default')]
class Scheduler implements ScheduleProviderInterface
{
    public function __construct(
        private readonly CacheInterface $cache,
        private readonly LockFactory $lockFactory,
    ) {
    }

    public function getSchedule(): Schedule
    {
        return (new Schedule())
            ->add(RecurringMessage::every('2 days', new Foo(), from: '04:05'))
            ->add(RecurringMessage::cron('15 4 */3 * *', new Foo()))
            ->stateful($this->cache)
            ->lock($this->lockFactory->createLock('scheduler-default'))
        ;
    }
}

Nous récupèrons un service pour gérer son cache et pour créer des locks (penser à installer symfony/lock auparavant). Puis nous indiquons que notre schedule peut maintenant bénéficier d’un état et possède un lock grâce à ces nouveaux éléments.

Et voilà 🎉, maintenant nous pouvons avoir autant de workers que nous voulons, ils ne lanceront pas plusieurs fois le même message :)

Schedule stateful workers

Section intitulée du-toolingDu tooling !

Section intitulée debug-de-nos-schedulesDebug de nos Schedules

Une commande console a été ajoutée depuis cette PR, elle permet de lister toutes les tâches des Schedules que vous avez créé !

$ bin/console debug:scheduler

Scheduler
=========

default
-------

 -------------- -------------------------------------------------- ---------------------------------
  Trigger    	Provider                                       	Next Run                    	
 -------------- -------------------------------------------------- ---------------------------------
  every 2 days   App\Messenger\Foo(O:17:"App\Messenger\Foo":0:{})   Sun, 03 Dec 2023 04:05:00 +0000
  15 4 */3 * *   App\Messenger\Foo(O:17:"App\Messenger\Foo":0:{})   Mon, 04 Dec 2023 04:15:00 +0000
 -------------- -------------------------------------------------- ---------------------------------

En plus de voir les tâches de vos Schedules, vous aurez aussi la prochaine date d’exécution.

Section intitulée changer-le-transport-de-vos-tachesChanger le transport de vos tâches

Parfois un message peut prendre du temps à être traité. Il est donc possible de dire dans son Schedule que notre message doit être traité par un transport donné. Par exemple :

(new Schedule())->add(
    RecurringMessage::cron('15 4 */3 * *', new RedispatchMessage(new Foo(), ‘async’)))
);

Ici, quand le message doit être distribué, le worker va le renvoyer vers le transport async qui s’occupera alors de le traiter. Très pratique pour les tâches lourdes car cela libérera le worker scheduler_default pour traiter les prochains messages.

Section intitulée gerer-les-erreursGérer les erreurs

Scheduler permet d’écouter plusieurs événements via le composant EventDispatcher. Il existe 3 événements écoutables: PreRunEvent, PostRunEvent et FailureEvent. Les deux premiers seront déclenchés, respectivement, avant et après chaque tâche exécutée. Le dernier, quant à lui, sera lancé en cas d’exception dans une tâche. Cela peut être très pratique pour monitorer de façon efficace vos erreurs :

#[AsEventListener(event: FailureEvent::class)]
final class ScheduleListener
{
    public function __invoke(FailureEvent $event): void
    {
        // triggers email to yourself when your schedules have issues
    }
}

Avec ce code, lorsqu’un événement FailureEvent arrive, vous pourrez vous envoyer un email ou rajouter des logs pour mieux comprendre le soucis.

Section intitulée console-as-schedulerConsole as Scheduler

Une des fonctionnalités les plus intéressantes de Scheduler selon moi : les attributs AsCronTask et AsPeriodicTask ! Ceux-ci permettent de transformer une commande console en une tâche périodique de façon très simple ! AsPeriodicTask permet de définir une tâche via une récurrence simple: 2 days par exemple, et AsCronTask permet de faire la même chose via une expression cron.

#[AsCommand(name: 'app:foo')]
#[AsPeriodicTask('2 days', schedule: 'default')]
final class FooCommand extends Command
{
    public function execute(InputInterface $input, OutputInterface $output): int
    {
        // run you command

        return Command::SUCCESS;
    }
}

Et voilà, la commande sera exécutée dans le Schedule default tous les 2 jours !

Nous retrouvons souvent des doublons entre les commandes console et vos tâches récurrentes, c’est la fonctionnalité parfaite pour faire le lien entre les deux !

Section intitulée conclusionConclusion

Le composant Scheduler s’impose comme un outil essentiel pour intégrer efficacement les tâches récurrentes dans Symfony. Sa simplicité d’utilisation, sa flexibilité, la gestion des expressions cron, ainsi que son intégration transparente avec les commandes console en font un choix incontournable.

Commentaires et discussions

Nos articles sur le même sujet

Nos formations sur ce sujet

Notre expertise est aussi disponible sous forme de formations professionnelles !

Voir toutes nos formations

Ces clients ont profité de notre expertise