[24-Feb-2024 10:12:03 UTC] PHP Warning: Undefined array key 'de' in /wp-content/plugins/site-i18n-sync/includes/class-sync.php on line 118. Cette ligne de log, répétée une dizaine de fois dans la même minute, a mis la puce à l’oreille : une tâche de resynchronisation des traductions, censée tourner une fois par heure, semblait s’exécuter deux fois de suite à quelques secondes d’intervalle — parfois avec des résultats contradictoires sur les mêmes chaînes.
Le symptôme visible pour l’équipe éditoriale était plus simple à décrire qu’à diagnostiquer : certaines chaînes fraîchement traduites en allemand redevenaient temporairement identiques au français, avant de revenir à la bonne valeur quelques minutes plus tard. Un bug intermittent, difficile à reproduire à la demande, le genre qui use la patience de tout le monde.
Symptôme : deux exécutions pour un seul événement planifié
Le hook en question, site_i18n_resync_batch, était programmé via wp_schedule_event() avec une récurrence horaire. En théorie, un seul déclenchement par heure. En pratique, sur un site à trafic soutenu, WP-Cron se déclenche à chaque chargement de page (le mécanisme pseudo-cron de WordPress repose sur les visites, pas sur un vrai daemon système), et rien n’empêche deux requêtes HTTP simultanées de constater toutes les deux qu’un événement est « prêt » et de le lancer chacune de son côté.
function site_i18n_check_due_events() {
$crons = _get_cron_array();
foreach ( $crons as $timestamp => $hooks ) {
if ( $timestamp > time() ) {
continue;
}
// Rien n'empêche deux requêtes d'atteindre ce point en parallèle
}
}
WordPress ne pose aucun verrou transactionnel sur cette vérification. Sur un hébergement avec plusieurs workers PHP-FPM actifs simultanément, deux requêtes peuvent parfaitement lire « cet événement est dû » au même instant et déclencher chacune leur propre traitement, avant que l’une ou l’autre n’ait eu le temps de reprogrammer l’événement suivant.
Diagnostic : reproduire le chevauchement
Pour confirmer l’hypothèse, on a ajouté un identifiant unique à chaque exécution, journalisé au début et à la fin du traitement :
add_action( 'site_i18n_resync_batch', function () {
$run_id = wp_generate_uuid4();
error_log( "sync start {$run_id}" );
// ... traitement des chaînes ...
error_log( "sync end {$run_id}" );
} );

En surveillant les logs sur une journée complète, deux run_id différents apparaissaient parfois avec un « start » à quelques secondes d’écart, sans qu’aucun « end » ne se soit produit entre les deux — la preuve du chevauchement. Le délai observé variait, mais dépassait rarement la minute et demie, ce qui correspondait bien à des requêtes concurrentes arrivant pendant un pic de trafic.
Correctif : un verrou applicatif via transient
La solution la plus simple, sans dépendance externe, consiste à poser un verrou avant de traiter le lot, avec une transient dont l’expiration protège contre un blocage permanent si le script plante en cours de route :
add_action( 'site_i18n_resync_batch', function () {
$lock_key = 'site_i18n_sync_lock';
if ( get_transient( $lock_key ) ) {
return; // Une exécution est déjà en cours
}
set_transient( $lock_key, 1, 5 * MINUTE_IN_SECONDS );
try {
site_i18n_run_resync();
} finally {
delete_transient( $lock_key );
}
} );
Le bloc finally garantit que le verrou est libéré même si une exception interrompt le traitement, évitant qu’une erreur bloque toute resynchronisation pendant les cinq minutes suivantes. La durée de la transient doit rester nettement supérieure au temps normal d’exécution du lot, sans quoi elle expirerait avant la fin d’un traitement légitime et laisserait passer un second chevauchement.
Une alternative plus robuste : verrou en base avec GET_LOCK
Sur un site à trafic très élevé, où deux workers pourraient lire la transient à quelques millisecondes d’écart (une transient stockée en wp_options n’est pas atomique par nature), une solution plus rigoureuse utilise le verrou nommé de MySQL :
global $wpdb;
$acquired = $wpdb->get_var( "SELECT GET_LOCK('site_i18n_sync', 2)" );
if ( '1' !== $acquired ) {
return;
}
Ce mécanisme, natif à MySQL, garantit qu’un seul processus obtient le verrou à un instant donné, sans la fenêtre de risque inhérente à une lecture puis écriture séparées sur une transient.
Prévention : surveiller la santé du planificateur
- Vérifier régulièrement
wp cron event listpour repérer des événements en double ou en retard - Sur un site à fort trafic, désactiver le pseudo-cron natif (
DISABLE_WP_CRON) et le remplacer par un vrai cron système appelantwp-cron.phpà intervalle fixe - Ajouter un identifiant d’exécution dans les logs de toute tâche planifiée sensible, dès sa mise en production
Un événement WP-Cron n’est jamais garanti unique par construction : c’est au code qui l’exécute de poser ses propres garde-fous contre le chevauchement.
En résumé
Le chevauchement de deux exécutions du même hook WP-Cron reste un piège classique dès qu’une tâche devient sensible aux effets de bord d’une double exécution. Un verrou applicatif simple — transient ou GET_LOCK selon le niveau de trafic — suffit à éliminer ce genre de résynchronisation à moitié faite, sans devoir remplacer tout le système de planification.