Automatiser des logiques WordPress en arrière-plan avec un Cron ou Action Scheduler

Autre besoin régulier lors de la création de systèmes métiers WordPress complexes : la mise en place de tâches récurrentes ou le lancement de tâches asynchrones en arrière-plan.

Dans cette leçon, nous allons analyser deux modules disponibles dans l’écosystème WordPress qui vont bien aider les développeurs à simplifier leur code :

  • le WP Cron pour simplifier la création de tâches Crons récurrentes,
  • la librairie Action Scheduler pour mettre en place des files d’attente de tâches à exécuter en arrière-plan (uniques ou régulières) de manière asynchrone.

Améliorer ses compétences WordPress

Cet article fait partie d’une série de leçons présentant les différentes APIs et fonctions PHP disponibles dans WordPress. Découvrez l’article de présentation Comment devenir un bon développeur back-end WordPress ? pour en savoir plus et accéder aux autres leçons.

Qu’est-ce que le WP-Cron ?

Pourquoi créer des tâches récurrentes avec un Cron WordPress ?

Le Cron WP permet de gèrer la planification d’actions PHP dans un contexte de site WordPress. Nativement, plusieurs fonctionnalités dans WordPress utilisent le WP Cron comme :

  • la recherche de mises à jour du cœur,
  • la publication d’articles programmés,
  • la vérification de mises à jour des extensions ou des thèmes, voire leur téléchargement automatique,
  • le renouvellement d’abonnements dans WooCommerce Subscriptions.

Voici quelques exemples concrets de tâches Cron mises en place durant la création de solutions sur-mesure pour des clients :

  • import de données quotidien provenant d’une API externe pour LesProgrammesNeufs,
  • envoi d’e-mails aux utilisateurs inscrits ces dernières 24 heures,
  • nettoyage/anonymisation de données RGPD après 90 jours pour mesTravaux,
  • envoi de notifications in-app aux utilisateurs à proximité après la création d’une nouvelle annonce pour freebulle.

Comment ça marche dans les coulisses ?

WP-Cron fonctionne en vérifiant, à chaque chargement de page, une liste de tâches planifiées pour estimer celles qui doivent être exécutées. Toutes les tâches devant être exécutées seront appelées pendant le chargement de cette page (fonctionnement par défaut, modifiable).

Attention

WP-Cron ne s’exécute pas automatiquement/constamment comme le fait le système cron UNIX : il n’est déclenché qu’au chargement de la page lors d’une visite. Des erreurs de planification peuvent donc se produire si vous planifiez une tâche pour 14h00 et qu’aucun chargement de page ne se produit avant 17h00.

Cette logique est suffisante sur des petits sites éditoriaux où l’exécution de tâches récurrentes n’impacte pas le bon fonctionnement du site.

Mais si vous êtes sérieux et souhaitez assurer la régularité de ces actions automatiques, il est tout à fait possible de désactiver l’exécution « à la visite » décrite ci-dessus pour lancer les Crons WordPress via un vrai crontab UNIX qui, soit :

  1. fera un appel HTTP sur le fichier wp-cron.php de votre site WordPress,
  2. ou exécutera la commande WP CLI wp cron event run --due-now pour traîter touter les tâches Cron en attente d’exécution.

Pour en savoir plus sur la mise en place d’une tâche Cron sur votre serveur ou hébergement, je vous conseille cet article de Cyrille Sanson.

Exemple pratique : sauvegarder des playlists de chansons Jamendo dans des articles WordPress

Code sur GitHub

Une extension WordPress fonctionnelle a été créée pour accompagner cet article. Elle est disponible sur GitHub : en l’installant et l’activant, une tâche Cron quotidienne importera (dans des posts WordPress) 10 playlists Jamendo contenant le mot « electro », et importera ensuite — via une tâche asynchrone — les chansons de chaque playlist.

Comment développer des tâches Cron récurrentes dans WordPress ?

Pour cela, il va falloir tout d’abord :

  • indiquer à WordPress que nous souhaitons programmer un événement récurrent, ce qui se traduira par l’exécution d’un hook à chaque récurrence,
  • puis enregistrer une ou plusieurs actions sur ce hook afin de lancer les actions que nous souhaitons.

Programmer l’événement

La fonction qui nous intéresse pour enregistrer une nouvelle tâche récurrente avec le Cron WP est wp_schedule_event(). Elle requiert les paramètres suivants :

  1. un timestamp à partir duquel le prochain événement sera exécuté,
  2. la récurrence de l’événement (chaque heure, chaque jour, etc.) selon les possibilités disponibles dans wp_get_schedules() (ou que vous aurez ajouté via le filtre cron_schedules), c’est-à-dire :
    1. hourly pour exécuter un job chaque heure,
    2. twicedaily pour le lancer toutes les 12 heures,
    3. daily pour les tâches quotidiennes,
    4. weekly pour les tâches hebdomadaires.
  3. le nom du hook qui sera exécuté par le Cron, à intervalle régulier, via do_action( $hook ),
  4. et d’éventuels arguments à passer aux fonctions qui seront appelées par ce hook.

Voici son usage dans le plugin créé pour cet article :

if ( ! wp_next_scheduled( 'msk/cron/import-playlists' ) ) {
	wp_schedule_event( strtotime( 'tomorrow 02:00' ), 'daily', 'msk/cron/import-playlists', [ 'keyword' => 'electro' ] );
}

Le code ci-dessus va lancer l’action intitulée msk/cron/import-playlists chaque jour à 2h du matin (UTC) avec en paramètre une variable $keyword ayant pour valeur electro.

N’enregistrez vos Crons qu’une seule fois !

Comme dans l’exemple ci-dessus, utiliser wp_next_scheduled() pour vous assurer qu’une tâche régulière n’a pas déjà été enregistrée par votre code.

Injecter des actions à exécuter sur l’événement

Après avoir enregistré notre Cron, on peut maintenant créer des fonctions et les relier au hook programmé ci-dessus. Voici ce qui est fait dans le plugin de cet article :

function fetch_playlists_from_api_and_import_them( $keyword = '' ) {
	$playlists = search_playlists( $keyword );
	...
}
add_action( 'msk/cron/import-playlists', __NAMESPACE__ . '\\fetch_playlists_from_api_and_import_them', 10, 1 );

C’est donc cette fonction qui est appelée par le Cron et qui se charge de récupérer les dernières playlists Jamendo via l’API, et de les importer dans des posts WordPress.

On injecte donc cette fonction fetch_playlists_from_api_and_import_them sur le hook msk/cron/import-playlists déclenché par le cron.

Comme vous le voyez, notre fonction reçoit le paramètre $keyword que l’on a défini dans wp_schedule_event() ci-dessus. Ce mot-clef est utilisé lors de notre appel API pour rechercher les playlists correspondantes.

On pourrait très bien étaler l’import de données sur plusieurs heures pour importer d’autres playlists correspondant à d’autres mots-clefs en faisant ainsi :

wp_schedule_event( strtotime( 'tomorrow 03:00' ), 'daily', 'msk/cron/import-playlists', [ 'keyword' => 'jazz' ] );
wp_schedule_event( strtotime( 'tomorrow 04:00' ), 'daily', 'msk/cron/import-playlists', [ 'keyword' => 'rock' ] );
wp_schedule_event( strtotime( 'tomorrow 05:00' ), 'daily', 'msk/cron/import-playlists', [ 'keyword' => 'classical' ] );

Déprogrammer les Crons

N’oubliez pas de déprogrammer vos tâches au moment opportun ! Par exemple, si l’utilisateur désactive votre extension, il faut bien informer WordPress de supprimer l’événement récurrent avec wp_clear_scheduled_hook().

Dans notre extension, on fait ainsi :

function on_deactivation( $plugin, $network ) {
	if ( $plugin === MSK_AUTOMATE_WP_BASENAME ) {
		wp_clear_scheduled_hook( 'msk/cron/import-playlists' );
	}
}
add_action( 'deactivate_plugin', __NAMESPACE__ . '\\on_deactivation', 10, 2 );

A la désactivation du plugin via le hook deactivate_plugin, on supprime notre tâche récurrente pour éviter qu’elle tourne toujours régulièrement alors que l’extension est inactive sur le site. Même si aucune fonction ne serait exécutée par notre plugin désactivé, le hook serait toujours appelé régulièrement par WordPress, chose que l’on ne veut plus une fois l’extension désactivée ou désinstallée.

Comment lister toutes les tâches Crons d’un site WordPress ?

Affichage en back-end des détails du Cron Job enregistré par notre extension

Pour connaître tous les Crons enregistrés sur un site WordPress, et surtout toutes les actions qui s’exécutent de manière récurrente, je vous conseille d’installer l’extension WP Crontrol.

Après activation, vous verrez une nouvelle page d’administration dans le menu Outils > Evènements Cron et pourrez lister les hooks et leur récurrence, les filtrer par type, en rechercher par nom, et bien plus.

Une autre astuce, moins détaillée mais plus rapide, est d’utiliser la commande WP CLI wp cron event list qui retourne un tableau listant les hooks programmés, leur prochaine exécution théorique et leur récurrence :

 ❯ ~ wp cron event list
+------------------------------------+---------------------+-----------------------+------------+
| hook                               | next_run_gmt        | next_run_relative     | recurrence |
+------------------------------------+---------------------+-----------------------+------------+
| action_scheduler_run_queue         | 2022-05-24 09:33:51 | 1 second              | 1 minute   |
| wp_privacy_delete_old_export_files | 2022-05-24 10:13:01 | 39 minutes 11 seconds | 1 hour     |
| wp_https_detection                 | 2022-05-24 20:13:01 | 10 hours 39 minutes   | 12 hours   |
| wp_version_check                   | 2022-05-24 20:13:01 | 10 hours 39 minutes   | 12 hours   |
| wp_update_plugins                  | 2022-05-24 20:13:01 | 10 hours 39 minutes   | 12 hours   |
| wp_update_themes                   | 2022-05-24 20:13:01 | 10 hours 39 minutes   | 12 hours   |
| msk/cron/import-playlists          | 2022-05-25 02:00:00 | 16 hours 26 minutes   | 1 day      |
| recovery_mode_clean_expired_keys   | 2022-05-25 08:13:01 | 22 hours 39 minutes   | 1 day      |
| wp_scheduled_delete                | 2022-05-25 08:13:37 | 22 hours 39 minutes   | 1 day      |
| delete_expired_transients          | 2022-05-25 08:13:37 | 22 hours 39 minutes   | 1 day      |
| wp_scheduled_auto_draft_delete     | 2022-05-25 08:13:37 | 22 hours 39 minutes   | 1 day      |
| wp_site_health_scheduled_check     | 2022-05-31 08:13:01 | 6 days 22 hours       | 1 week     |
+------------------------------------+---------------------+-----------------------+------------+

Qu’est-ce que la librairie Action Scheduler ?

Une file d’attente intelligente pour lancer des tâches WordPress en arrière-plan

Action Scheduler est une librairie qui permet d’exécuter un hook WordPress à un moment précis dans le futur, ou dès que possible dans le cas d’une action asynchrone.

Liste d'attente des tâches asynchrones à exécuter par notre extension WordPress
Liste des tâches asynchrones du plugin en attente d’être exécutées

Pour quoi faire ?

L’intérêt est simple mais vital pour optimiser votre développement back-end WordPress. Imaginons le scénario suivant ; après remplissage et envoi d’un formulaire de contact par un visiteur de votre site, vous aimeriez :

  1. sauvegarder un post privé dans votre base de données WordPress,
  2. envoyer un e-mail à l’administrateur du site,
  3. diffuser un message par un bot dans votre Slack interne,
  4. et créer un nouveau contact dans votre liste d’abonnés SendInBlue.

Exécuter toutes ces actions immédiatement après que le visiteur ait cliqué sur le bouton « Envoyer » du formulaire n’est vraiment pas optimal car votre utilisateur devra attendre patiemment la fin de toutes ces actions pour voir la page réagir à son clic (affichage d’un message ou redirection, par exemple).

On peut donc optimiser cette logique et la repenser de la manière suivante, grâce à un fin usage d’Action Scheduler. Après clic sur le bouton :

  1. la demande est sauvegardée immédiatement dans un post privé dans votre base WordPress avec wp_insert_post() (action peu coûteuse),
  2. chaque autre action est planifiée avec as_enqueue_async_action() pour qu’elle soit exécutée de manière asynchrone, en arrière-plan et au plus vite, séparément de la requête lancée par le clic de l’utilisateur. Elle recevrait en paramètre l’identifiant du post créé précédemment, qui contient toutes les données de la demande (en contenu ou en métadonnées),
  3. quelques secondes plus tard, le Cron d’Action Scheduler traitera la file d’attente et exécutera ces actions (dans un thread séparé de la requête initiale déclenchée par l’utilisateur).

En faisant cela, on n’impacte pas négativement l’expérience utilisateur et il n’aura pas à patienter après avoir envoyé sa demande via le formulaire.

Installation

Action Scheduler n’est pas disponible nativement dans le cœur de WordPress, mais est accessible si vous avez l’extension WooCommerce. Sinon, référez-vous à la documentation pour l’installer soit comme librairie, soit comme extension, soit avec composer.

Un système de file d’attente testé sur le champ de bataille

Chaque hook peut être programmé avec des données uniques, pour permettre aux rappels d’effectuer des opérations uniques sur des données spécifiques. Le hook peut également être planifié pour s’exécuter à une ou plusieurs reprises de manière récurrente, ce que l’on verra plus tard. C’est en quelque sorte une extension de do_action() mais qui offre surtout la possibilité de retarder et de répéter un crochet.

Action Scheduler crée également une file d’attente robuste de tâches (queue en anglais) pour le traitement en arrière-plan d’actions plus ou moins coûteuses dans un système WordPress.

Cette librairie a été testée sur des sites traitant des files d’attente de plus de 50.000 tâches et effectuant des opérations gourmandes en ressources, comme le traitement des paiements et la création de commandes, dans 10 files d’attente simultanées à un rythme de plus de 10.000 actions/heure sans impact négatif sur les opérations normales du site.

Tableau d'historique des actions exécutées par Action Scheduler

Dans les coulisses d’Action Scheduler

Fonctionnement général

Action Scheduler enregistre le nom du hook, ses arguments et la date d’exécution prévue pour une action qui devra être déclenchée à un moment donné dans le futur.

Le planificateur a pour mission de s’exécuter toutes les minutes (via un Cron qui déclenche le hook action_scheduler_run_schedule). Chaque minute, il vérifie s’il existe des actions en attente. S’il y en a, il lance le traitement de la file d’attente via une requête asynchrone.

Lorsqu’il est déclenché, le planificateur d’actions vérifiera les actions dont la date d’échéance est immédiate ou passée. Les actions programmées pour s’exécuter de manière asynchrone (c’est-à-dire sans horodatage) seront toujours vues comme exécutables « au plus vite », quelle que soit la date de traitement, afin d’être traitées « ASAP« .

Des performances optimales

Le processus PHP en charge d’exécuter la file d’attente continuera ensuite à traiter des lots de 25 actions jusqu’à ce qu’il utilise 90% de la mémoire disponible ou qu’il ait atteint un temps d’exécution de 30 secondes.

À ce stade, s’il y a des actions supplémentaires à traiter dans la file d’attente, une demande asynchrone sera faite au site pour continuer à traiter les actions dans une nouvelle demande.

Ce processus et les demandes suivantes se poursuivront jusqu’à ce que toutes les actions à traiter aient été traitées.

Comment exécuter des actions PHP en arrière-plan dans WordPress avec Action Scheduler ?

Peu importe le type de tâche que vous souhaitez programmer (asynchrone, planifiée ou récurrente), la logique est relativement similaire. Les fonctions de planification d’Action Scheduler attendent :

  1. le nom d’un hook qui sera appelé lorsque la tâche sera exécutée,
  2. d’éventuels paramètres envoyés aux fonctions qui intercepteront ce hook.

Enregistrer une tâche asynchrone

Planification de la tâche

Dans notre exemple, on planifie une action à lancer en arrière-plan dès qu’une playlist Jamendo a été importée dans notre base de données WordPress :

as_enqueue_async_action(
	'msk/cron/import-tracks',
	[ 'playlist_wp_id' => $playlist_wp_id, 'playlist_jamendo_id' => $playlist->id ]
);

Les arguments de as_enqueue_async_action() sont :

  • le nom du hook à appeler (ici msk/cron/import-tracks),
  • un tableau de paramètres envoyé aux fonctions hookées. Chaque clé du tableau sera le nom d’une variable passée en entrée des fonctions (dans notre cas, une variable $playlist_wp_id et une variable $playlist_jamendo_id).

Une fois ce code exécuté, une action sera ajoutée dans la file d’attente Action Scheduler et sera traitée au plus vite.

Exécution d’actions lors du traitement de la tâche

Maintenant que cette tâche a été planifiée, il faut écrire les actions qui devront être exécutées lors de son traitement. Voila la technique :

function import_jamendo_playlist_tracks( $playlist_wp_id = 0, $playlist_jamendo_id = 0 ) {
	$tracks = get_playlist_tracks( $playlist_jamendo_id );
	update_post_meta( $playlist_wp_id, 'tracks', $tracks );
	update_post_meta( $playlist_wp_id, 'tracks_count', count( $tracks ) );
	wp_update_post( [ 'ID' => $playlist_wp_id, 'post_status' => 'publish' ] );
}
add_action( 'msk/cron/import-tracks', __NAMESPACE__ . '\\import_jamendo_playlist_tracks', 10, 2 );

Comme vous le constatez, on s’injecte sur le hook msk/cron/import-tracks mentionné précédemment. En entrée, la fonction import_jamendo_playlist_tracks() recevra deux paramètres que nous pourrons utiliser dans le cœur de la fonction pour faire ce que nous souhaitons.

Ici, on appelle une seconde route d’API pour récupérer la liste des chansons appartenant à la playlist Jamendo précédemment importée. Cette liste est enregistrée en métadonnée du post playlist, et ce post est enfin publié.

On peut très bien cumuler d’autres actions complémentaires à la suite, de cette manière :

function execute_action2( $playlist_wp_id = 0, $playlist_jamendo_id = 0 ) {
	//
}
add_action( 'msk/cron/import-tracks', __NAMESPACE__ . '\\execute_action2', 20, 2 );

function execute_action3( $playlist_wp_id = 0, $playlist_jamendo_id = 0 ) {
	//
}
add_action( 'msk/cron/import-tracks', __NAMESPACE__ . '\\execute_action3', 30, 2 );

Notez bien les paramètres de priorité (10, 20, 30) qui permettent d’indiquer l’ordre dans lequel ces actions devront être exécutées lors du cycle de vie de la tâche déclenchée par Action Scheduler.

Point important à connaître

La colonne SQL stockant les arguments des tâches est limitée à 191 caractères (pour cause d’indexation de la colonne et des raisons de performance lors de recherche d’actions par arguments).
Si vous souhaitez passer des grands tableaux ou objets à vos fonctions asynchrones, il faudra bricoler un peu !
Dans ces cas, j’enregistre un transient ou une donnée temporaire dans la table d’options, et ne passe qu’un identifiant en argument de tâche.
Dans mes fonctions hookées, je me réfère à cet identifiant pour récupérer la donnée complète stockée, l’utilise puis supprime le transient ou l’option temporaire créé à cet effet.

Planifier une tâche à exécuter dans le futur à un moment précis

Cette fois, on utilise la fonction as_schedule_single_action() pour planifier à un jour et horaire précis l’exécution d’une action. Elle fonctionne comme as_enqueue_async_action() décrite ci-dessus, mais avec un premier argument en plus qui est un timestamp futur (UTC) indiquant le moment où l’action devra être traitée.

Voici un exemple concret :

/**
 * After an artisan reads a message, schedule an automatic email in 24h (if no decision).
 *
 * @param integer $message_id
 * @param integer $artisan_id
 * @return void
 */
function schedule_email_after_artisan_reads_message( $message_id, $artisan_id ) {
	as_schedule_single_action(
		time() + DAY_IN_SECONDS,
		'mt/send-email-if-no-decision-taken-on-message',
		[ 'message_id' => $message_id, 'artisan_id' => $artisan_id ]
	);
}
add_action( 'mt/message-read-by-artisan', __NAMESPACE__ . '\\schedule_email_after_artisan_reads_message', 10, 2 );

/**
 * Maybe send an e-mail to the artisan who has read a message but not taken any decision.
 *
 * @param integer $message_id
 * @param integer $artisan_id
 * @return void
 */
function maybe_send_email_if_artisan_has_not_taken_decision_on_read_message( $message_id, $artisan_id ) {
	$message = new Message( $message_id );
	$artisan = new Artisan( $artisan_id );

	if ( ! $artisan->has_taken_decision_on( $message ) ) {
		wp_mail( … );
	}
}
add_action( 'mt/send-email-if-no-decision-taken-on-message', __NAMESPACE__ . '\\maybe_send_email_if_artisan_has_not_taken_decision_on_read_message', 10, 2 );

Sur l’annuaire mesTravaux.com, un artisan peut consulter les messages de particuliers qu’il a reçus. Lors de la lecture d’un message dans son tableau de bord, une action est planifiée 1 jour après pour éventuellement lui envoyer un e-mail s’il n’a pris aucune décision (acceptation de la demande ou refus) sur ce message.

La fonction as_schedule_single_action() est donc appelée avec les arguments suivants :

  1. le jour/heure d’exécution désiré : time() + DAY_IN_SECONDS veut donc dire « dans 24 heures »,
  2. le nom du hook à déclencher : ici, mt/send-email-if-no-decision-taken-on-message
  3. un tableau d’arguments spécifiques à utiliser lors de l’action : ici, l’ID du message lu et l’ID de l’artisan ayant lu le message.

Ces arguments sont reçus en paramètre d’une fonction maybe_send_email_if_artisan_has_not_taken_decision_on_read_message() injectée sur le hook mt/send-email-if-no-decision-taken-on-message. Si 24 heures après, l’artisan n’a pris aucune décision sur ce message (acceptation ou refus), alors un e-mail de rappel lui parvient afin de l’inciter à communiquer son souhait d’intervention (ou non) pour la demande du particulier en question.

Une API complète

Nous n’avons présenté que deux fonctions de la librairie mais elle peut faire bien plus. Visitez sa page de documentation pour découvrir comment créer une action récurrente à répéter à intervalle régulier, comment déprogrammer une action spécifique, récupérer l’horodatage de la prochaine exécution d’une action, ou vérifier si une action spécifique est présente dans la file d’attente.

Cron ou tâche Action Scheduler ?

D’expérience, je favorise de plus en plus l’usage d’Action Scheduler lorsque j’ai besoin de planifier des tâches (récurrentes ou non), car la logique liée à son fonctionnement est plus simple à mettre en place qu’un Cron.

Reprenons l’exemple précédent de mesTravaux.com.

Avec un Cron quotidien, il faudrait écrire une requête pour cibler tous les messages lus par les artisans, mais où les artisans n’ont pas encore pris de décision. Techniquement, cela implique la création d’une WP_Query mélangeant un paramètre date_query et meta_query : pas très simple à écrire, et probablement pas très performante.

Avec Action Scheduler, on prévoit la tâche à exécuter au moment souhaité (24 heures après la lecture d’un message). Lorsque la file d’attente traitera cette tâche, il nous suffit de vérifier si l’artisan a pris une décision (ou non) sur ce message pour déclencher (ou non) notre logique. La requête et le code relatifs à cette logique sont plus simples à construire et à écrire, à mon humble avis.

Cela permet aussi d’étaler ces actions (requêtes, envois de mails) tout au long de la journée, à des intervalles dépendant des actions de l’utilisateur et non pas d’un Cron quotidien exécuté à heure fixe. Moins coûteux pour le serveur !

Mais dans le cas d’import de données automatisé via des APIs externes, un Cron a plus de sens : on souhaite ici effectuer une action récurrente (quotidienne, hebdomadaire, mensuelle…), de préférence à une heure où le site est peu visité afin de bénéficier du maximum de ressources côté serveur, sans impacter les visites en cours. Ce genre de logique métier est parfaitement adaptée à un Cron WP sur-mesure.


Et vous, avez-vous des usages intéressants du Cron WP ou d’Action Scheduler à nous partager en commentaire ?


Vous avez aimé cet article ?

Partagez-le sur vos réseaux sociaux en guise de remerciement :)


Laisser un commentaire