{"componentChunkName":"component---src-templates-post-jsx","path":"/fr/drupal-migrations-incrementales-high-water-property","result":{"data":{"markdownRemark":{"html":"<p>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 <strong>tous les jours</strong> 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.</p>\n<p>Le mécanisme prévu par l'API Migrate pour ça s'appelle <code class=\"language-text\">high_water_property</code>.</p>\n<h2>Ce que fait réellement le high water mark</h2>\n<p>Le principe : la migration mémorise la <strong>valeur maximale</strong> du champ de suivi (typiquement un <code class=\"language-text\">changed</code> / <code class=\"language-text\">updated_at</code>) parmi les lignes déjà traitées. À l'exécution suivante, elle ne reprend qu'au-delà de cette valeur.</p>\n<p>Concrètement, côté <code class=\"language-text\">SqlBase</code> :</p>\n<ol>\n<li>Le plugin source ajoute une condition <code class=\"language-text\">champ &gt; valeur_mémorisée</code> à la requête.</li>\n<li>Il ajoute un <code class=\"language-text\">ORDER BY</code> sur ce champ — la progression du marqueur n'a de sens que si les lignes arrivent dans l'ordre croissant.</li>\n<li>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.</li>\n</ol>\n<p>La valeur n'est pas stockée dans la table map, mais dans le key/value store, collection <code class=\"language-text\">migrate:high_water</code>, indexée par ID de migration. C'est une information <strong>hors map</strong> : c'est ce qui explique plusieurs des surprises listées plus bas.</p>\n<p>Un point souvent mal compris : la condition de high water n'est pas exclusive. Elle est combinée en <code class=\"language-text\">OR</code> avec les lignes absentes de la table map et celles marquées <code class=\"language-text\">STATUS_NEEDS_UPDATE</code>. Une ligne jamais importée reste donc importée même si son <code class=\"language-text\">changed</code> est ancien.</p>\n<h2>Configuration</h2>\n<div class=\"gatsby-highlight\" data-language=\"yaml\"><pre class=\"language-yaml\"><code class=\"language-yaml\"><span class=\"token key atrule\">id</span><span class=\"token punctuation\">:</span> article_incremental\n<span class=\"token key atrule\">label</span><span class=\"token punctuation\">:</span> <span class=\"token string\">'Articles — import incrémental'</span>\n<span class=\"token key atrule\">source</span><span class=\"token punctuation\">:</span>\n  <span class=\"token key atrule\">plugin</span><span class=\"token punctuation\">:</span> article_source\n  <span class=\"token key atrule\">high_water_property</span><span class=\"token punctuation\">:</span>\n    <span class=\"token key atrule\">name</span><span class=\"token punctuation\">:</span> changed</code></pre></div>\n<p>Si la requête source fait des jointures et que le nom de colonne est ambigu, on précise l'alias de table :</p>\n<div class=\"gatsby-highlight\" data-language=\"yaml\"><pre class=\"language-yaml\"><code class=\"language-yaml\">  <span class=\"token key atrule\">high_water_property</span><span class=\"token punctuation\">:</span>\n    <span class=\"token key atrule\">name</span><span class=\"token punctuation\">:</span> changed\n    <span class=\"token key atrule\">alias</span><span class=\"token punctuation\">:</span> n</code></pre></div>\n<p>Côté plugin source, il n'y a rien à câbler : <code class=\"language-text\">SqlBase</code> applique la condition et le tri à partir de cette configuration. Il reste une chose à faire, hors Drupal :</p>\n<div class=\"gatsby-highlight\" data-language=\"sql\"><pre class=\"language-sql\"><code class=\"language-sql\"><span class=\"token keyword\">CREATE</span> <span class=\"token keyword\">INDEX</span> idx_node_changed <span class=\"token keyword\">ON</span> node <span class=\"token punctuation\">(</span>changed<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>Sans index sur le champ de high water, on remplace un scan complet par un scan complet <strong>plus un tri</strong>. C'est le premier gain à sécuriser.</p>\n<h2>Ce que ce n'est pas</h2>\n<table>\n<thead>\n<tr>\n<th>Mécanisme</th>\n<th>Ce qu'il fait</th>\n<th>Coût</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code class=\"language-text\">high_water_property</code></td>\n<td>Filtre la requête source sur <code class=\"language-text\">&gt; dernière valeur traitée</code></td>\n<td>Quasi nul si le champ est indexé</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">migrate:import --update</code></td>\n<td>Remet <strong>toutes</strong> les lignes déjà importées en « à mettre à jour » et rejoue tout</td>\n<td>Maximal</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">migrate:import --sync</code></td>\n<td>Compare les IDs source aux IDs de la map et <strong>supprime</strong> les destinations dont la source a disparu</td>\n<td>Lecture complète de la source</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">track_changes: true</code></td>\n<td>Calcule un hash de chaque ligne source et le compare à celui de la map</td>\n<td>Lecture complète + hash par ligne</td>\n</tr>\n</tbody>\n</table>\n<p><code class=\"language-text\">track_changes</code> est le concurrent direct. Il détecte davantage de choses — y compris une modification dans une table jointe qui ne remonte pas le <code class=\"language-text\">changed</code> du nœud — mais il paie une lecture intégrale de la source à chaque exécution. Règle simple : <code class=\"language-text\">high_water_property</code> quand la source expose une date de modification fiable, <code class=\"language-text\">track_changes</code> quand elle n'en expose pas.</p>\n<h2>Les pièges</h2>\n<p><strong>1. Attention à la combinaison avec <code class=\"language-text\">--sync</code>.</strong> <code class=\"language-text\">--sync</code> 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.</p>\n<p><strong>2. Les suppressions ne sont pas détectées.</strong> 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 <code class=\"language-text\">changed</code> mis à jour, ou passe de synchronisation périodique dédiée.</p>\n<p><strong>3. La comparaison est stricte (<code class=\"language-text\">&gt;</code>), pas <code class=\"language-text\">&gt;=</code>.</strong> Si plusieurs lignes partagent la même valeur de <code class=\"language-text\">changed</code> à 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 <code class=\"language-text\">high_water - 60s</code>) plutôt que de faire confiance à la granularité de la seconde.</p>\n<p><strong>4. Le marqueur avance même sur les lignes ignorées.</strong> Une ligne écartée par une <code class=\"language-text\">MigrateSkipRowException</code> dans <code class=\"language-text\">prepareRow()</code> 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 <code class=\"language-text\">changed</code> est désormais sous le marqueur.</p>\n<p><strong>5. Sources non-SQL : gain fonctionnel, pas gain de performance.</strong> Pour une source JSON ou CSV via <code class=\"language-text\">migrate_plus</code>, le filtrage se fait en PHP dans <code class=\"language-text\">SourcePluginBase::next()</code>, après récupération. On évite les écritures inutiles en destination, pas le téléchargement ni le parsing du flux complet.</p>\n<p><strong>6. Le champ doit être monotone.</strong> Un <code class=\"language-text\">changed</code> 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.</p>\n<p><strong>7. Rollback et high water sont deux choses distinctes.</strong> 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 ».</p>\n<h2>Inspecter et réinitialiser le marqueur</h2>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token comment\"># Lire la valeur courante</span>\ndrush php:eval <span class=\"token string\">'var_dump(\\Drupal::keyValue(\"migrate:high_water\")->get(\"article_incremental\"));'</span>\n\n<span class=\"token comment\"># Réinitialiser (prochain run = import complet)</span>\ndrush php:eval <span class=\"token string\">'\\Drupal::keyValue(\"migrate:high_water\")->delete(\"article_incremental\");'</span>\n\n<span class=\"token comment\"># Repositionner sur une date précise (rattrapage ciblé)</span>\ndrush php:eval <span class=\"token string\">'\\Drupal::keyValue(\"migrate:high_water\")->set(\"article_incremental\", strtotime(\"2026-08-01\"));'</span></code></pre></div>\n<p>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.</p>\n<p>À ne pas confondre avec <code class=\"language-text\">migrate:reset-status</code>, qui débloque une migration restée en statut <code class=\"language-text\">Importing</code> après un kill, et n'a aucun effet sur le high water.</p>\n<h2>Checklist avant mise en production</h2>\n<ul>\n<li>Le champ de high water est indexé côté source.</li>\n<li>Il est monotone et mis à jour par <strong>toutes</strong> les écritures applicatives, y compris les batchs et les imports tiers.</li>\n<li>Le cas des suppressions est traité par un mécanisme explicite et documenté.</li>\n<li>La procédure de rattrapage (repositionnement du marqueur) est écrite quelque part, pas dans la tête d'une seule personne.</li>\n<li>Le run quotidien est monitoré sur le <strong>nombre de lignes traitées</strong>, pas seulement sur son code de sortie : une migration incrémentale cassée réussit très bien à importer zéro ligne.</li>\n</ul>","excerpt":"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…","frontmatter":{"date":"2026-08-20","metaDate":"2026-08-20","title":"Migrations incrémentales dans Drupal : bien utiliser high_water_property","tags":["Drupal","Drupal 11","Migrate","PHP","Performance"],"path":"/drupal-migrations-incrementales-high-water-property","cover":{"childImageSharp":{"fluid":{"base64":"data:image/jpeg;base64,/9j/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9PDkzODdASFxOQERXRTc4UG1RV19iZ2hnPk1xeXBkeFxlZ2P/2wBDARESEhgVGC8aGi9jQjhCY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2P/wgARCAALABQDASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAAIDAQX/xAAXAQADAQAAAAAAAAAAAAAAAAAAAgME/9oADAMBAAIQAxAAAAHlvKmuSGA3/8QAGBAAAgMAAAAAAAAAAAAAAAAAAAEQETH/2gAIAQEAAQUCErHs/wD/xAAVEQEBAAAAAAAAAAAAAAAAAAABEP/aAAgBAwEBPwFn/8QAFBEBAAAAAAAAAAAAAAAAAAAAEP/aAAgBAgEBPwE//8QAFBABAAAAAAAAAAAAAAAAAAAAIP/aAAgBAQAGPwJf/8QAFxABAQEBAAAAAAAAAAAAAAAAAQAhMf/aAAgBAQABPyE4sOIw5Cku3//aAAwDAQACAAMAAAAQbB//xAAXEQEBAQEAAAAAAAAAAAAAAAABADFB/9oACAEDAQE/EA8jL//EABYRAQEBAAAAAAAAAAAAAAAAAAEQIf/aAAgBAgEBPxAdn//EABkQAQEBAQEBAAAAAAAAAAAAAAEAESExQf/aAAgBAQABPxA6oXPJDwNgg+SdGspxmV2//9k=","aspectRatio":1.7777777777777777,"src":"/static/23d58d98a1703412cd6d666e78dfca55/88110/cover.jpg","srcSet":"/static/23d58d98a1703412cd6d666e78dfca55/0b320/cover.jpg 480w,\n/static/23d58d98a1703412cd6d666e78dfca55/60b32/cover.jpg 960w,\n/static/23d58d98a1703412cd6d666e78dfca55/88110/cover.jpg 1920w,\n/static/23d58d98a1703412cd6d666e78dfca55/40175/cover.jpg 2880w,\n/static/23d58d98a1703412cd6d666e78dfca55/e58c2/cover.jpg 3840w,\n/static/23d58d98a1703412cd6d666e78dfca55/e742d/cover.jpg 5120w","srcWebp":"/static/23d58d98a1703412cd6d666e78dfca55/d1a9d/cover.webp","srcSetWebp":"/static/23d58d98a1703412cd6d666e78dfca55/bc3bf/cover.webp 480w,\n/static/23d58d98a1703412cd6d666e78dfca55/39337/cover.webp 960w,\n/static/23d58d98a1703412cd6d666e78dfca55/d1a9d/cover.webp 1920w,\n/static/23d58d98a1703412cd6d666e78dfca55/fcbe1/cover.webp 2880w,\n/static/23d58d98a1703412cd6d666e78dfca55/c136d/cover.webp 3840w,\n/static/23d58d98a1703412cd6d666e78dfca55/39b6f/cover.webp 5120w","sizes":"(max-width: 1920px) 100vw, 1920px"},"resize":{"src":"/static/23d58d98a1703412cd6d666e78dfca55/c4f3a/cover.jpg"}}}}}},"pageContext":{"isCreatedByStatefulCreatePages":false,"pathSlug":"/drupal-migrations-incrementales-high-water-property","locale":"fr","prev":{"fields":{"locale":"fr"},"frontmatter":{"path":"/post-mortem-cpu-spike-100-production-drupal","title":"Post-mortem : CPU spike à 100% en production Drupal — analyse et résolution","tags":["Drupal","Production","Performance","Post-mortem","New Relic","Cloudflare","Bot Traffic","Incident","Drupal 11"]}},"next":null}}}