Drupal
Drupal 10-migratie: 14.000 zoekgeraakte taxonomy-refs
De migrate-batch werd om 22:14 groen. QA vond om 23:40 een pagina zonder tags, om 02:00 waren het er 14.000 meer. De site moest om 09:00 live.

De migrate-batch was om 22:14 klaar. We zagen de laatste van de 31.000 node-rijen het nieuwe Drupal 10-systeem in tikken, deden een snelle smoke check op drie willekeurig gekozen contentpagina's, noemden het klaar en gingen naar bed. Om 23:40 opende de QA-lead van de klant een contentpagina en zag dat de tagstrip leeg was. Om 00:20 hadden we een steekproef van 40 pagina's zonder enige tag. Om 02:00 wisten we dat het aantal dichter bij 14.000 lag. De site moest om 09:00 live voor de lanceeraankondiging van de klant.
Dit is het verhaal van een stille fout in de Migrate API van Drupal, de diagnose die we om 03:00 stelden, en de SQL die we om 04:30 gebruikten om de referenties terug te zetten waar ze hoorden.
De opzet
De opdracht was een migratie van Drupal 7 naar Drupal 10 voor een middelgrote uitgever. Ongeveer 31.000 nodes verdeeld over zes contenttypes, drie taxonomy-vocabulaires met ongeveer 2.200 termen, en een lange staart van entity_reference-velden die nodes aan termen koppelden. De migratiepipeline volgde het standaardpatroon: eerst termen, dan media, dan nodes. Het volledige plan draaide binnen drush migrate:import --tag=publisher_d10.
We hadden dit twee keer op staging geoefend zonder zichtbare fouten. Beide droogloopjes lieten zien: Processed: 31,142, Created: 31,142, Updated: 0, Failed: 0. Dezelfde regel verscheen op productie. Geen errors in de log.
Hoe de fout ontstond
De weggevallen referenties kwamen allemaal uit één veld, field_topic, een entity_reference-veld naar de topics-vocabulaire. Het was het enige veld in de migratie dat een aangepaste migration_lookup-keten gebruikte, omdat de brondata de taxonomy als delimited string opsloeg in een Drupal 7-tekstveld in plaats van een nette taxonomy_term_reference. De ontwikkelaar uit 2018 die de oorspronkelijke site bouwde, had dat zo aan elkaar geknoopt om een node-edit-formulier te omzeilen dat hij niet mocht.
De process-pipeline zag er ongeveer zo uit:
field_topic:
- plugin: explode
delimiter: '|'
source: field_topic_raw
- plugin: callback
callable: trim
- plugin: migration_lookup
migration: publisher_topics
no_stub: true
- plugin: skip_on_empty
method: processDie laatste regel is de bug. skip_on_empty met method: process slaat niet de hele rij over, het slaat het veld over. Toen migration_lookup NULL teruggaf voor een term die niet door de topics-migratie was gekomen (omdat 187 brontermen waren gestrand op typografische aanhalingstekens in de naam, en de afhankelijke rijen daardoor nooit waren aangemaakt), werd het veld voor die delta leeg gezet. Over 31.000 nodes met een lange staart aan nichetopics groeide dat uit tot bijna 14.000 ontbrekende referenties.
De les die daarin verborgen zit: migrate:import rapporteert succes op rijniveau, niet op veldniveau. Een rij kan als Created worden gemarkeerd met elk veld leeg, en de Migrate API telt dat als winst.
Waarom de stagingruns er schoon uitzagen
We hadden de migratie twee keer op staging geoefend en beide runs gaven nul fouten terug. De staging-database was zes weken eerder ververst vanuit een geschoonde export. Die export had niet-ASCII-tekens uit de termnamen gestript als pre-flight-opschoning, het soort ding dat een vorige ontwikkelaar had ingesteld en niemand ooit had herzien. De 187 probleemtermen met typografische aanhalingstekens bestonden dus alleen op productie. Onze testfixture had niets om dat op te pikken.
De les is structureel. Een stagingomgeving die de rommel van de productiedata niet bevat, is alleen in naam staging. We doen nu een laatste cutover-repetitie tegen een verse, ongemanipuleerde productiedump binnen 48 uur voor go-live, en behandelen elk pre-flight-opschoningscript als verdachte in plaats van als helper.
Detectie om 23:40
Het was geluk dat de QA-lead een pagina opende die ertoe deed. Zonder dat ene paar mensenogen was het volgende signaal de lanceer-tweet van 09:00 geweest, met een link naar een pagina die zei: "Gearchiveerd onder: niets."
Het eerste wat we deden was de omvang bevestigen. Eén query tegen de nieuwe database gaf ons een aantal:
SELECT COUNT(*) AS nodes_missing_topic
FROM node_field_data n
LEFT JOIN node__field_topic t ON t.entity_id = n.nid
WHERE t.entity_id IS NULL
AND n.type IN ('article','feature','interview');Het getal kwam terug als 14.217. We hadden tien en een half uur.
Diagnose om 03:00
Er lagen twee paden open. De migratie opnieuw draaien met een gecorrigeerde pipeline, wat een migrate:rollback van elke afhankelijke migratie betekende plus nog eens vier uur importtijd. Of de referenties ter plekke herbouwen vanuit de brondata waar we nog toegang toe hadden.
Opnieuw draaien was het boekenwijsheidsantwoord. Het paste ook slecht bij de klok. De bron-database stond nog live bij de oude hoster van de klant, en de tabel migrate_map_publisher_topics bevatte de mapping van bron-ID naar bestemmings-ID voor elke topicterm die daadwerkelijk was gemigreerd. We konden de bron vanuit de nieuwe site bereiken door een tweede databaseconnectie te configureren in settings.local.php:
$databases['legacy']['default'] = [
'database' => 'publisher_d7',
'username' => 'readonly',
'password' => 'redacted',
'host' => 'legacy-db.internal',
'driver' => 'mysql',
'port' => '3306',
];Een read-only user, omdat we onder die mate van vermoeidheid niet het risico wilden lopen naar de bron te schrijven. Voordat de eerste INSERT op productie liep, dumpten we ook de nieuwe database naar een apart volume met mysqldump. Negentig seconden schijfgebruik, volledige omkeerbaarheid als iets uit de bocht vloog.
De herbouw om 04:30
Het plan was drie statements. Bouw een werktabel die elke legacy-node koppelde aan zijn legacy-topic-strings, geëxplodeerd tot één rij per topic. Join die aan migrate_map_publisher_topics om de bron-topicnaam te vertalen naar de bestemmings-term-ID. Voeg de gejoinde rijen toe aan node__field_topic en node_revision__field_topic in de nieuwe database.
Eerste statement, gedraaid tegen de legacy-database:
CREATE TABLE publisher_d7.legacy_topic_link AS
SELECT
n.nid AS source_nid,
n.vid AS source_vid,
TRIM(SUBSTRING_INDEX(
SUBSTRING_INDEX(t.field_topic_raw_value, '|', numbers.n),
'|', -1
)) AS topic_name,
numbers.n - 1 AS delta
FROM publisher_d7.field_data_field_topic_raw t
JOIN publisher_d7.node n ON n.vid = t.revision_id
JOIN (
SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4
UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8
) numbers
ON CHAR_LENGTH(t.field_topic_raw_value)
- CHAR_LENGTH(REPLACE(t.field_topic_raw_value, '|', '')) >= numbers.n - 1
WHERE t.field_topic_raw_value IS NOT NULL
AND t.field_topic_raw_value != '';De afgeleide tabel numbers is het standaard MySQL-patroon om een delimited string in rijen te splitsen zonder recursieve CTE, die de legacy MySQL 5.7-host niet ondersteunde. Acht rijen is het maximum aantal topics dat een node had, gevonden door eerst MAX(CHAR_LENGTH(...) - CHAR_LENGTH(REPLACE(...))) te draaien.
Tweede statement, gedraaid tegen de nieuwe database met de legacy-connectie als alias:
CREATE TABLE drupal10.rebuild_field_topic AS
SELECT
CAST(m_node.destid1 AS UNSIGNED) AS entity_id,
CAST(m_node.destid1 AS UNSIGNED) AS revision_id,
'en' AS langcode,
lt.delta AS delta,
CAST(m_topic.destid1 AS UNSIGNED) AS field_topic_target_id,
0 AS deleted,
'article' AS bundle
FROM publisher_d7.legacy_topic_link lt
JOIN drupal10.migrate_map_publisher_nodes m_node
ON m_node.sourceid1 = lt.source_nid
JOIN drupal10.migrate_map_publisher_topics m_topic
ON m_topic.sourceid1 = lt.topic_name
WHERE m_node.destid1 IS NOT NULL
AND m_topic.destid1 IS NOT NULL;De twee NOT NULL-guards waren belangrijk. Ongeveer 240 van de legacy-topic-strings kwamen met geen enkele gemigreerde term overeen, dezelfde verzameling typografische-aanhalingstekens-namen die de eerste import had gebroken. Die zouden een handmatige ronde nodig hebben, maar dat viel om 04:30 buiten de scope.
Derde statement, degene die daadwerkelijk naar productie schreef:
INSERT INTO drupal10.node__field_topic
(bundle, deleted, entity_id, revision_id, langcode, delta, field_topic_target_id)
SELECT bundle, deleted, entity_id, revision_id, langcode, delta, field_topic_target_id
FROM drupal10.rebuild_field_topic r
WHERE NOT EXISTS (
SELECT 1 FROM drupal10.node__field_topic existing
WHERE existing.entity_id = r.entity_id
AND existing.delta = r.delta
);
INSERT INTO drupal10.node_revision__field_topic
(bundle, deleted, entity_id, revision_id, langcode, delta, field_topic_target_id)
SELECT bundle, deleted, entity_id, revision_id, langcode, delta, field_topic_target_id
FROM drupal10.rebuild_field_topic r
WHERE NOT EXISTS (
SELECT 1 FROM drupal10.node_revision__field_topic existing
WHERE existing.entity_id = r.entity_id
AND existing.delta = r.delta
);Beide tabellen, omdat Drupal entity_reference-velden in twee parallelle tabellen opslaat, één gekoppeld aan de huidige revisie en één aan elke revisie. Sla de tweede over en de referenties verschijnen wel op de publieke pagina, maar verdwijnen in het editorformulier. Dat hebben we in 2021 op een andere migratie de harde weg ontdekt.
Nadat de inserts waren gedraaid, herbouwden we de entity cache en de zoekindex:
drush cache:rebuild
drush search-api:reset-tracker default_index
drush search-api:index --batch-size=200 default_indexDe Search API-stap deed ertoe omdat de site van de klant een topicfacet gebruikte die op het referentieveld draaide. Zonder herindexering zou de facet er leeg uit hebben gezien, terwijl de pagina-inhoud klopte.
Om 06:48 draaiden we de count-query opnieuw. Nodes zonder topic waar de bron wél een topic had: nul. Nodes zonder topic waar de bron ook geen topic had: 213, de verwachte echte lege gevallen. We sliepen een uur en zagen de lanceer-tweet op tijd uitgaan.
migrate:import rapporteert succes op rijniveau, niet op veldniveau. Een groene migratie is noodzakelijk maar niet voldoende. Valideer op veldniveau voordat je een site live noemt.
De 240 termen die we voor maandag lieten liggen
Het derde SQL-statement had twee NOT NULL-guards die stilletjes 240 legacy-topic-strings oversloegen die met geen enkele gemigreerde term overeenkwamen. Dat waren de slachtoffers van de typografische aanhalingstekens uit de oorspronkelijk gefaalde import. Maandagochtend ruimden we ze in drie stappen op. Een kort script normaliseerde de namen in de legacy-brondata en verving typografische aanhalingstekens door rechte. De topics-migratie werd opnieuw gedraaid met drush migrate:import publisher_topics --update, zodat de net opgeschoonde termen alsnog werden gemapt. Daarna draaiden we het tweede statement van die nacht opnieuw, zodat de werktabel de nieuwe term-ID's oppikte, en deden een gerichte INSERT voor alleen die nodes.
Twaalf nodes waren in de tussentijd onder een bijna-duplicaatterm gecategoriseerd, omdat twee brontopics na normalisatie waren samengevallen op dezelfde rechte-aanhalingstekens-naam. De redacteur ving het op tijdens de dagelijkse contentcontrole en we patchten die handmatig. Een redelijke prijs voor een verder op tijd geleverde lancering.
Wat we nu opleveren om dit eerder te vangen
In ons migrate-draaiboek zijn sinds dit incident twee dingen veranderd.
Ten eerste: elk entity_reference-veld krijgt een post-migrate-validator die het aantal legacy-rijen vergelijkt met het aantal rijen op de nieuwe site, op veldniveau, niet op rijniveau. Hij draait als Drush-commando in dezelfde CI-pipeline die de migratie draait. Een delta die niet nul is, breekt de build. De documentatie van Drupal beschrijft migration_lookup in detail, maar waarschuwt niet voor de row-count-illusie. De Drupal-community heeft een al lang lopende issue-thread over stil overslaan in process-plugins die het lezen waard is voor elke niet-triviale migratie.
De validator leest een YAML-manifest met veld-naar-bron-mappings en draait een count-vergelijking per item:
- field: field_topic
bundle: article
legacy:
connection: legacy
query: >
SELECT COUNT(*)
FROM field_data_field_topic_raw
WHERE entity_type = 'node' AND deleted = 0
new:
table: node__field_topic
where: bundle = 'article'
tolerance: 0Het Drush-commando draait elke legacy-query via dezelfde secundaire connectie die we die nacht hadden opgezet, telt rijen in de nieuwe tabel met de bijbehorende WHERE-clause en geeft een exit-code niet-nul bij een delta groter dan de tolerantie per veld. CI draait het als laatste stap van de droogloop-job. Een toekomstige migratie met hetzelfde bugpatroon laat de build falen voordat iemand naar bed gaat.
Ten tweede: de skip_on_empty-plugin in de falende pipeline werd vervangen door een custom process-plugin die het bron-ID en de veldnaam logt zodra een lookup NULL teruggeeft. Vijftien regels PHP. Het had 14.000 warnings opgeleverd tijdens de droogloop.
Een nuttige sanity check die we nu voor elke cutover draaien, geleend uit de auditronde in de Drupal core upgrade-docs: pak de tien URL's met het hoogste verkeer uit de analytics van de legacy-site, vraag ze op aan de nieuwe site en diff het woordental van de gerenderde HTML. Een daling van meer dan vijf procent op een pagina behandelen we als gefaald totdat onschuld is bewezen.
Drie gewoontes uit ons bestaande draaiboek hielden stand onder druk en die houden we alle drie aan. De legacy-database-credentials die we om 03:00 gebruikten waren standaard read-only, niet omdat we dit scenario precies hadden voorzien, maar omdat schrijfbare credentials nooit worden gebruikt voor inspectiewerk. Een gewoonte die je om 04:30 paraat wilt hebben, moet je om 14:00 al standaard hebben. De mysqldump voor de eerste INSERT kostte negentig seconden en gaf ons een volledig rollback-punt op productie. En de regel van twee staging-repetities, die de bug niet had opgespoord, had wel bewezen dat de migrate-pipeline zelf stabiel was. Daardoor konden we het herbouwpad vertrouwen in plaats van eraan te twijfelen.
De audit van vijf minuten die je vandaag kunt draaien
Heb je in de afgelopen maand een Drupal-migratie netjes zien aflopen en weet je niet zeker of die je de hele waarheid heeft verteld, dan is dit de kleinste controle die deze klasse bugs vangt. Draai voor elk entity_reference-veld op een contenttype dat ertoe deed:
SELECT
(SELECT COUNT(*) FROM legacy.field_data_field_topic) AS legacy_rows,
(SELECT COUNT(*) FROM new.node__field_topic) AS new_rows;Is het nieuwe getal merkbaar kleiner en kun je dat gat niet verklaren met ongepubliceerde of verwijderde brondata, dan heb je hetzelfde lek als wij. Vang het op voordat de lanceer-tweet het doet.
Wat ons die nacht redde tijdens de herbouw van de referentiedata van de uitgever, was dat de bron-database nog live en uitleesbaar was. Bij een recentere legacy-migratie voor een Nederlandse e-commercepartij houden we de bron-database nu standaard dertig dagen na cutover als snapshot op de nieuwe host. De kosten zijn een paar gigabyte schijfruimte. Het voordeel: de volgende 04:30 SQL-sessie, als die komt, begint met de data al binnen handbereik.
Kern
Een groene Drupal-migratierapportage kan duizenden lege referentievelden verbergen, want migrate:import telt rijen, geen velden.
FAQ
Waarom markeerde het migrate-commando van Drupal de weggevallen referenties niet als fouten?
Omdat migrate:import resultaten op rijniveau rapporteert. Een node-rij kan als Created worden gemarkeerd met een leeg referentieveld als een process-plugin zoals skip_on_empty (method: process) het veld leegt bij een NULL-lookup.
Is direct in de veld-tabellen van Drupal schrijven veilig?
Het is veilig als je eerst dumpt, naar zowel de huidige als de revisie-tabel voor het veld schrijft, en drush cache:rebuild plus een Search API-herindexering draait. Sla één van die stappen over en je ziet geestdata op de site.
Had je de migratie ook opnieuw kunnen draaien in plaats van SQL te schrijven?
Ja, maar alleen tegen de prijs van een volledige rollback plus vier uur importtijd. Met een lancering om 09:00 en een live bron-database was een gerichte SQL-herbouw het snellere en veiligere pad.
Hoe voorkom je stille velduitval bij de volgende migratie?
Vervang skip_on_empty door een custom process-plugin die elke NULL-lookup logt, en voeg een CI-check toe die het aantal legacy-rijen vergelijkt met het aantal rijen in de nieuwe site op veldniveau, niet op rijniveau.