Migrations incrémentales dans Drupal : bien utiliser high_water_property

Une migration qui tourne une fois, au moment d'une refonte, on la traite en force brute : on rejoue tout, on regarde le rapport, on recommence. Une migration qui tourne tous les jours contre une base source vivante, c'est un autre métier. Rejouer 200 000 lignes chaque nuit pour en importer 300 réellement modifiées, c'est un coût CPU, un coût I/O sur la base source, et surtout une fenêtre d'exécution qui finit par déborder.

Le mécanisme prévu par l'API Migrate pour ça s'appelle high_water_property.

Ce que fait réellement le high water mark

Le principe : la migration mémorise la valeur maximale du champ de suivi (typiquement un changed / updated_at) parmi les lignes déjà traitées. À l'exécution suivante, elle ne reprend qu'au-delà de cette valeur.

Concrètement, côté SqlBase :

  1. Le plugin source ajoute une condition champ > valeur_mémorisée à la requête.
  2. Il ajoute un ORDER BY sur ce champ — la progression du marqueur n'a de sens que si les lignes arrivent dans l'ordre croissant.
  3. Le marqueur est sauvegardé au fil de l'eau, ligne par ligne, pas en fin de run. Une migration interrompue à 60 % ne repart donc pas de zéro.

La valeur n'est pas stockée dans la table map, mais dans le key/value store, collection migrate:high_water, indexée par ID de migration. C'est une information hors map : c'est ce qui explique plusieurs des surprises listées plus bas.

Un point souvent mal compris : la condition de high water n'est pas exclusive. Elle est combinée en OR avec les lignes absentes de la table map et celles marquées STATUS_NEEDS_UPDATE. Une ligne jamais importée reste donc importée même si son changed est ancien.

Configuration

id: article_incremental
label: 'Articles — import incrémental'
source:
  plugin: article_source
  high_water_property:
    name: changed

Si la requête source fait des jointures et que le nom de colonne est ambigu, on précise l'alias de table :

  high_water_property:
    name: changed
    alias: n

Côté plugin source, il n'y a rien à câbler : SqlBase applique la condition et le tri à partir de cette configuration. Il reste une chose à faire, hors Drupal :

CREATE INDEX idx_node_changed ON node (changed);

Sans index sur le champ de high water, on remplace un scan complet par un scan complet plus un tri. C'est le premier gain à sécuriser.

Ce que ce n'est pas

Mécanisme Ce qu'il fait Coût
high_water_property Filtre la requête source sur > dernière valeur traitée Quasi nul si le champ est indexé
migrate:import --update Remet toutes les lignes déjà importées en « à mettre à jour » et rejoue tout Maximal
migrate:import --sync Compare les IDs source aux IDs de la map et supprime les destinations dont la source a disparu Lecture complète de la source
track_changes: true Calcule un hash de chaque ligne source et le compare à celui de la map Lecture complète + hash par ligne

track_changes est le concurrent direct. Il détecte davantage de choses — y compris une modification dans une table jointe qui ne remonte pas le changed du nœud — mais il paie une lecture intégrale de la source à chaque exécution. Règle simple : high_water_property quand la source expose une date de modification fiable, track_changes quand elle n'en expose pas.

Les pièges

1. Attention à la combinaison avec --sync. --sync déduit les suppressions en comparant la liste des IDs source à la map. Or le high water restreint justement la requête source. La liste retournée devient partielle, et tout ce qui n'apparaît pas peut être considéré comme supprimé. À tester sur une copie avant d'envisager les deux ensemble — pas en production un vendredi soir.

2. Les suppressions ne sont pas détectées. Le high water ne voit que ce qui bouge « vers le haut ». Un contenu supprimé ou dépublié côté source ne remonte jamais. Il faut un traitement séparé : soft delete côté source avec un changed mis à jour, ou passe de synchronisation périodique dédiée.

3. La comparaison est stricte (>), pas >=. Si plusieurs lignes partagent la même valeur de changed à la seconde près et que le run s'arrête au milieu de ce paquet, le reste du paquet est perdu au run suivant. Sur des sources à forte volumétrie, prévoir une marge de sécurité (repartir de high_water - 60s) plutôt que de faire confiance à la granularité de la seconde.

4. Le marqueur avance même sur les lignes ignorées. Une ligne écartée par une MigrateSkipRowException dans prepareRow() ne bloque pas la progression. Si la raison du skip disparaît plus tard (une donnée de référence enfin présente), la ligne ne sera pas reprise : son changed est désormais sous le marqueur.

5. Sources non-SQL : gain fonctionnel, pas gain de performance. Pour une source JSON ou CSV via migrate_plus, le filtrage se fait en PHP dans SourcePluginBase::next(), après récupération. On évite les écritures inutiles en destination, pas le téléchargement ni le parsing du flux complet.

6. Le champ doit être monotone. Un changed que la source réécrit à la baisse, ou un ID auto-incrémenté réutilisé, casse le mécanisme silencieusement. Rien ne remonte en erreur : des lignes cessent simplement d'être importées.

7. Rollback et high water sont deux choses distinctes. La table map est purgée par le rollback, le key/value pas nécessairement. Une migration rollbackée puis relancée peut donc ne rien réimporter du tout. C'est le symptôme classique du « ma migration ne fait plus rien ».

Inspecter et réinitialiser le marqueur

# Lire la valeur courante
drush php:eval 'var_dump(\Drupal::keyValue("migrate:high_water")->get("article_incremental"));'

# Réinitialiser (prochain run = import complet)
drush php:eval '\Drupal::keyValue("migrate:high_water")->delete("article_incremental");'

# Repositionner sur une date précise (rattrapage ciblé)
drush php:eval '\Drupal::keyValue("migrate:high_water")->set("article_incremental", strtotime("2026-08-01"));'

Le troisième cas est le vrai outil de production : après un incident, on ne rejoue pas tout, on recule le marqueur de la fenêtre concernée.

À ne pas confondre avec migrate:reset-status, qui débloque une migration restée en statut Importing après un kill, et n'a aucun effet sur le high water.

Checklist avant mise en production

  • Le champ de high water est indexé côté source.
  • Il est monotone et mis à jour par toutes les écritures applicatives, y compris les batchs et les imports tiers.
  • Le cas des suppressions est traité par un mécanisme explicite et documenté.
  • La procédure de rattrapage (repositionnement du marqueur) est écrite quelque part, pas dans la tête d'une seule personne.
  • Le run quotidien est monitoré sur le nombre de lignes traitées, pas seulement sur son code de sortie : une migration incrémentale cassée réussit très bien à importer zéro ligne.