← Blog

Drupal

Drupal 11-rollback: het verhaal van 18.400 losse UUID's

Negen dagen na de start van een Drupal 9 naar 11-upgrade vond een Antwerpse softwareleverancier 18.400 supportcases die wezen naar UUID's die nergens meer naartoe leidden. Dit ging er mis.

Jacob Molkenboer· Oprichter · A Brand New Company· 6 jun 2025· 9 min
Open leren logboek met messing label, groen lint, inktstempel en rood lakfragment op ivoorkleurig papier.

Het was een dinsdag in mei toen de leadontwikkelaar bij de Antwerpse leverancier van productiesoftware een Slack-thread opende met de titel "we gaan de deadline niet halen". Hun Drupal 9-site stond al negen dagen halverwege een upgrade vast. Het klantportaal, met 18.400 actieve supportcases voor klanten in België en Nederland, stond technisch gezien nog online. Maar elke link van een klantnode naar een openstaande case wees nu naar een UUID die nergens meer naartoe leidde. De Drupal 11-doelomgeving draaide. De Drupal 9-bron stond onaangeroerd. En ergens daartussenin had de migratie zichzelf stilletjes teruggedraaid en de brokstukken achtergelaten.

Die alinea is de hele post in één keer. De rest is hoe het zover kwam, waarom het stil bleef, en wat we nu draaien om het niet opnieuw te laten gebeuren.

De opzet die brak

De leverancier zat sinds 2021 op Drupal 9. Drupal 10 hadden ze overgeslagen omdat het team één upgrade wilde doen, geen twee. Dat is een redelijk instinct. Drupal-core levert een schoon pad van 9 naar 10 en van 10 naar 11, en de officiële Drupal-upgradedocumentatie beschrijft een contrib-module-audit die je hoort te draaien voor je begint. Wat mensen in de problemen brengt zijn custom velden, custom entity types, en de lagen contrib-modules die zich stilletjes aan deprecated API's binden.

Hun klantportaal-nodetype had een entity-reference veld, field_assigned_engineer, dat naar gebruikersaccounts wees. Het was in 2019 toegevoegd op Drupal 8, geport naar 9, en daarna nooit meer aangeraakt. Ergens onderweg was de storage-configuratie van het veld gaan afdrijven. Het schema in de database zei het ene, de field config YAML zei het andere, en de entity definition cache hield al jaren de unie van beide vast. Drupal 9 maakte het niets uit. De strengere entity definition update manager van Drupal 10 wel.

Wat "silent rollback" eigenlijk betekent

De meeste Drupal-upgradedocumentatie beschrijft de Migrate API als transactioneel. Dat klopt grotendeels. Als een rij faalt bij het importeren, wordt die in de migrate_map_* tracking-tabel als MIGRATE_STATUS_FAILED gemarkeerd, en wordt de rij bij de volgende run overgeslagen of opnieuw geprobeerd.

Wat minder goed gedocumenteerd is, is wat er gebeurt als de fout niet in een rij zit maar in de prepareRow-setup van de migratie. Als een source plugin een exception gooit nadat er al rijen naar de destination zijn geschreven, draait Drupal die writes niet altijd terug. De migratie wordt als incompleet gemarkeerd, er gaat één regel naar dblog, en alles stopt. De destination houdt wat er op het moment van de throw stond. De source plugin markeert zichzelf voor een retry. De migrate map eindigt met een halfslachtig beeld van de werkelijkheid.

In de Antwerpse case betekende dat dat ongeveer 11.000 van de 18.400 supportcases als nieuwe nodes met verse UUID's in Drupal 11 waren binnengekomen. De overige 7.400 niet. Het field_assigned_engineer-veld op elke gemigreerde case wees naar engineer-accounts via hun oude Drupal 9-UUID's, die nog niet waren gemigreerd omdat de user-migratie pas in de wachtrij stond nadat de supportcase-migratie klaar was. Wat dus nooit gebeurde.

De monitoring van het team zag negen dagen lang "migration in progress" omdat de cron-job nog steeds gepland stond. Niemand werd gepiept. De Drupal 11-site haalde de health checks omdat de node-overzichten op de frontend prima rendereden. Het was de deep link van een klantdashboard naar "jouw openstaande cases" die lege arrays teruggaf. Klanten merkten het eerder dan het team.

Waarschuwing

Een Drupal-migratie die geen fatal error heeft gegooid is niet hetzelfde als een Drupal-migratie die klaar is. Check drush migrate:status, niet de cron-log.

De deprecated property waarmee het begon

De trigger was één deprecated property in de storage settings van het entity-reference veld. In Drupal 9 mocht je target_type impliciet laten als het veld bij installatie aan één node-type was gekoppeld. Drupal 10 maakte het expliciet, en de change record op Drupal.org markeerde dit als een harde eis, geen zachte waarschuwing. Wie drush updb op een onvoorbereid veld draaide, kreeg een warning, geen error. De update database routine voltooide. Het veld bleef werken in de UI. De Migrate API begon echter de target_type van het veld vanaf de nieuwe expliciete locatie te lezen, en die was leeg.

Toen de supportcase-migratie de engineer-referentie probeerde op te lossen, vond hij een target_type van null, besloot dat hij de referentie niet veilig kon wegschrijven, en gooide een MigrateSkipRowException. Na de derde skip raakte de batch-threshold van de migratie geraakt en draaide de hele batch stilletjes terug naar het laatste savepoint. De volgende batch startte nooit, omdat de batch-runner dacht dat hij nog in de vorige zat.

Om het lokaal te reproduceren draaide het team dit tegen een kloon van productie:

drush --uri=https://portal.local migrate:status --group=customer_portal
drush --uri=https://portal.local migrate:messages support_case --limit=5
drush --uri=https://portal.local sql:query \
  "SELECT COUNT(*) FROM migrate_map_support_case WHERE source_row_status = 2"

De derde query gaf 7.412 terug. Dat aantal kwam precies overeen met het gat tussen de cases die klanten in het oude portaal zagen en de cases die het nieuwe systeem had geladen.

Het herstel, in de volgorde waarin we het draaiden

Op dag acht werden we erbij gehaald. De eerste beslissing was: doorrollen of terugrollen. Terugrollen betekende de Drupal 9-database herstellen van vóór de upgrade. Dat was makkelijk, behalve dat klanten in de tussentijd 312 nieuwe cases hadden ingediend tegen de kapotte Drupal 11-site. Die kwijtraken was geen optie.

Doorrollen betekende de storage-configuratie van het entity-reference veld ter plekke repareren, de gefaalde migratie opnieuw draaien met --update, en de 312 nieuwe cases met de hand reconciliëren. Dat hebben we gedaan. De stappen:

# 1. Zet de site op read-only voor engineers
drush state:set system.maintenance_mode 1

# 2. Repareer de field storage config naar de nieuwe expliciete vorm
drush config:get field.storage.node.field_assigned_engineer > /tmp/before.yml
drush php:eval "
  \$config = \Drupal::configFactory()->getEditable('field.storage.node.field_assigned_engineer');
  \$config->set('settings.target_type', 'user')->save();
  \Drupal::entityDefinitionUpdateManager()->updateFieldStorageDefinition(
    \Drupal::service('entity_field.manager')->getFieldStorageDefinitions('node')['field_assigned_engineer']
  );
"

# 3. Maak de migrate map leeg voor alleen gefaalde rijen, NIET voor geïmporteerde
drush sql:query "DELETE FROM migrate_map_support_case WHERE source_row_status = 2"

# 4. Draai eerst de user-migratie opnieuw, daarna de supportcases ertegenaan
drush migrate:import user --group=customer_portal
drush migrate:import support_case --update --group=customer_portal

# 5. Reconcilieer de 312 cases die tijdens de outage zijn ingediend
drush scr scripts/reconcile-orphan-cases.php

Het reconciliatiescript liep de nieuwe cases af, matchte ze tegen de oorspronkelijke Drupal 9-klant-UUID's via een lookup-tabel die het team verstandig genoeg had bewaard, en herschreef de referenties. Hij draaide in 47 seconden. De site kwam vier uur later weer online, nadat het team had geverifieerd dat willekeurige steekproef-cases end-to-end resolveerden.

Wat twee uur pre-flight had opgevangen

Dit was te voorkomen. Niet achteraf gezien, maar vooraf. De fouten die hier samenkwamen zijn dezelfde die we bij elke verouderde Drupal-upgrade zien. Dit is wat we nu draaien voor iemand drush updb aanraakt op een productiedatabase.

Audit elke field storage op drift van impliciet naar expliciet

Draai Upgrade Status en lees elke warning, niet alleen de errors. Dump dan voor elk entity-reference, file-, image- en taxonomy-term veld de storage YAML en bevestig dat target_type, target_bundles en handler_settings allemaal gevuld zijn. Als er één leeg of afwezig is, fix het op de bron vóór de upgrade, niet erna.

for field in $(drush config:list | grep '^field.storage.node'); do
  drush config:get $field | grep -E '^\s*(target_type|handler):' \
    || echo "MISSING: $field"
done

Draai de migratie in dry-run tegen een productie-kloon

Drupal heeft geen native dry-run voor de Migrate API. Je kunt het goed genoeg faken met een destination plugin override die naar een shadow-tabel schrijft. Steek er een dag in om dat harnas te bouwen. Het bespaart je negen dagen later.

Houd de migrate map in de gaten, niet de cron-log

Voeg een check aan je monitoring toe die tijdens een upgrade-window elke vijftien minuten drush migrate:status draait en je pieept als een migratie rijen met source_row_status = 2 heeft die ouder zijn dan tien minuten. De dblog van Drupal 11 redt je niet. Je monitor wel.

Les

De gevaarlijke Drupal-upgradefouten zijn de stille. Behandel elke migratie die niet klaar is als een migratie die actief is gefaald.

Waarom dit door de hele contrib-stack heen blijft gebeuren

Het veld van het Antwerpse team was custom, maar hetzelfde patroon beet twee contrib-modules waar ze op leunden. Group had een vergelijkbaar veld met impliciete target-type in een oudere 2.x-branch. Webform had een subtielere versie van dezelfde drift in zijn handler settings. Beide waren upstream gepatcht, maar geen van beide patches was in de composer-lockfile van het team beland omdat ze waren vastgepind op een versie van vóór de fix.

Het contrib-ecosysteem is een van de sterke kanten van Drupal. Het is ook het ding dat als eerste breekt bij een multi-major upgrade. Steek een middag in het lezen van de change logs van elke contrib-module in je composer.json, terug tot de versie die je draait, en je vindt op de meeste sites minstens één van die stille drifts.

De volgorde die we op dag één hadden aangehouden

Als we vóór de upgrade aan tafel hadden gezeten, was het enige dat we hadden veranderd de volgorde. Migreer de source content-velden naar de expliciete config-vorm op Drupal 9 zelf, voordat je een versiewissel introduceert. De Drupal 9-site heeft die expliciete vorm niet nodig. Drupal 10 wel, en die ene wijziging uitrollen terwijl de rest van het systeem ongewijzigd blijft isoleert het failure mode. Als er iets breekt, weet je dat het het veld is, niet de upgrade.

Het team werkt nu zo. Elke storage-drift wordt op de huidige major gefixt voordat ze überhaupt aan verhuizen denken. De volgende upgrade staat voor september ingepland. Wij zitten er bij de pre-flight bij.

Toen wij de legacy migratie voor de Drupal 11-cutover van de Antwerpse leverancier draaiden, was het niet de Drupal 11-wijzigingen zelf die ons beten, maar zeven jaar drift tussen wat de database dacht dat het veld was en wat de geëxporteerde config zei. We hebben uiteindelijk een checklist van één pagina geschreven die tegen de bron-site draait voor iemand de target aanraakt, en die checklist draait nu tegen elke Drupal-klant in onze onderhoudsportefeuille.

Zit je vandaag op een Drupal 9-site, dan is het kleinste nuttige dat je vanmiddag kunt doen: dump de field storage YAML voor elk entity-reference veld op je content types en grep op lege target_type-waardes. Vijf minuten werk. De output is óf geruststellend, óf, voor ongeveer één op de drie sites die we auditen, het begin van een gesprek.

Kern

Een Drupal-migratie die geen fatal error heeft gegooid is niet hetzelfde als een afgeronde. Houd de migrate map in de gaten, niet de cron-log.

FAQ

Wat veroorzaakte de stille Drupal-migratie-rollback in deze case?

Een entity-reference veld met een impliciete target_type bleef in de Drupal 9-vorm staan. De Migrate API van Drupal 10 las de expliciete target_type als null en gooide een MigrateSkipRowException, wat de batch-rollback-threshold triggerde.

Hoe detecteer ik een vastgelopen Drupal-migratie voordat klanten het merken?

Monitor source_row_status in de migrate_map_*-tabellen en alert op rijen die langer dan tien minuten op status 2 blijven hangen. Cron-logs en dblog brengen dit niet vanzelf naar boven.

Kan ik Drupal 10 veilig overslaan en direct van 9 naar 11 upgraden?

Nee. Drupal 11 kent geen directe upgrade vanaf 9. Je moet eerst van 9 naar 10, daarna van 10 naar 11, en de deprecation-surface van elke stap moet schoon zijn voor je verder kunt.

Waarom breekt een lege target_type entity-reference velden?

Drupal 10 en later verwacht dat target_type expliciet in de field storage staat. Als die leeg is, geeft de referentie-resolutie null terug in plaats van een entity, en weigert Migrate de referentie weg te schrijven om hem niet kapot te maken.

Is doorrollen altijd veiliger dan de oude database terugzetten?

Nee. Het hangt ervan af of er nieuwe writes naar de kapotte target-omgeving zijn gegaan. Als klanten records hebben aangemaakt die je niet kunt missen, roll dan door. Als er niets is weggeschreven, is de clean restore meestal sneller en met minder risico.

drupalmigrationphpmysqllegacy sitescase study

Iets bouwen?

Start een project