Magento
Van Magento 1.9 naar Medusa.js: cutover in vijf weken
Een onderdelendistributeur in Enschede draaide op Magento 1.9 en PHP 5.6 en had nog 90 dagen. Dit is de shadow-traffic cutover van vijf weken die 18.400 prijzen heelhield.

Op een dinsdagochtend in februari stuurde de operations lead bij een distributeur van aftermarket-onderdelen in Enschede ons een screenshot. Hun staging-Magento gaf een fatal bij elke productopslag. PHP 5.6. mysqlnd-warnings vier diep gestapeld. Het hostingbedrijf had ze 90 dagen gegeven om de runtime te upgraden, of ze gingen naar een shared box waarop hun codebase helemaal niet zou draaien.
Ze hadden 18.400 dealer-tier prijsafspraken in productie. Achtentwintig mensen gebruikten het portaal elke dag. De Exact Globe-sync draaide om 02:15 elke nacht en boekte de facturen de volgende ochtend. Een big-bang cutover was geen optie.
Dit is het playbook van vijf weken dat we draaiden om ze van Magento 1.9 af te krijgen en op een Medusa.js + Remix + Postgres stack te zetten, met nul verloren orders en geen gemiste grootboekregels.
Week 0: inventarisatie voor code
Voordat we één regel nieuwe code schreven, mapten we de oude stack in één spreadsheet. Drie kolommen: entiteit, huidige source of truth, downstream consumer.
Klinkt saai. Het is de belangrijkste week.
De bevindingen waren niet wat de klant verwachtte:
- Klantaccounts stonden in Magento's
customer_entity, maar de canonieke mapping van e-mail naar dealer leefde in een aparte MySQL-view die het magazijnteam gebruikte. - Tier-prijzen stonden in
catalog_product_entity_tier_price, maar 2.300 dealer-overrides leefden in een custom tabelnl_partner_pricesetdie sinds 2019 niemand meer had aangeraakt. - Orders zaten in Magento, maar een nachtelijke cron schreef regelitems om naar een platte CSV voor Exact Globe.
- Voorraadniveaus werden elk uur door een ERP-webhook in Magento geduwd, maar het magazijn vertrouwde zijn eigen telling, niet die van de website.
We begonnen niet aan de rebuild voordat die map was afgetekend. Twee engineers, drie dagen, 41 entiteiten gedocumenteerd. Goedkoop.
Week 1: de shadow-stack
We provisioneerden de doelomgeving náást productie, niet als vervanging. Nog niets verving iets. De dealers gingen nog steeds naar de oude Magento.
De keuzes die telden:
- Medusa.js voor de commerce-core. Headless, TypeScript, customer-, product- en order-primitives die we konden uitbreiden zonder te forken. We hadden er het jaar ervoor twee B2B-portalen op gebouwd en kenden de naden.
- Remix voor de storefront. Server-rendering doet ertoe als dealers inloggen vanaf magazijn-iPads op 4G. De route-based loaders lieten ons de dealer-pricing check op de edge draaien.
- Postgres 15 in plaats van MySQL. Het prijsafspraken-model had window functions en range types nodig die we niet wilden nabouwen.
De shadow-database spiegelde productie via een one-way logical replication slot vanuit MySQL. Elke customer_entity-insert verscheen binnen seconden in Postgres, getransformeerd door een Node-worker naar het nieuwe schema. Liep de worker achter, dan kregen we een page. Het lag-dashboard draaide op dezelfde Grafana die het magazijn al gebruikte.
Week 2: prijsafspraken als source of truth
De 18.400 dealer-tier prijzen waren het kwetsbaarste onderdeel van het hele systeem. Elke afspraak had een customer ID, een SKU-patroon, een hoeveelheidstrap, een kortingspercentage en een einddatum. Sommige waren in 2017 met de hand in productie aangepast en nooit terug naar het ERP gespiegeld.
We probeerden dit niet te modelleren in Medusa's price-list primitives. Die pasten niet. In plaats daarvan bouwden we een aparte price_agreement-tabel met een Postgres exclusion constraint die overlappende contracten voor hetzelfde dealer-SKU-paar tegenhield.
CREATE TABLE price_agreement (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL REFERENCES customer(id),
sku_pattern TEXT NOT NULL,
qty_min INT NOT NULL DEFAULT 1,
discount_pct NUMERIC(5,2) NOT NULL,
valid_during TSTZRANGE NOT NULL,
EXCLUDE USING gist (
customer_id WITH =,
sku_pattern WITH =,
valid_during WITH &&
)
);
De exclusion constraint ving 47 overlappende rijen tijdens de eerste import. Elk daarvan was een echte data-bug in productie. De ops lead besteedde een dag om ze met de hand op te ruimen. Het team wist niet dat die overlap bestond, want Magento's UI koos stilletjes de lagere prijs en ging door.
Als de eerste import van je migratie schoon is, heb je niet genoeg gevalideerd. Echte productiedata is verrot. Breng het naar boven vóór de cutover, niet erna.
Week 3: de Exact Globe-grootboeksync
Exact Globe is het stuk dat niemand graag aanraakt. Het draait op Windows, praat SQL Server, en exposed een SOAP-achtige XML-API die doet alsof het REST is. De nachtelijke sync was sinds 2014 een Python-script op een hosting-VPS, dat om 02:15 draaide omdat iemand ooit had gezegd dat het na de magazijnexport moest.
We herschreven het als een Medusa-workerjob met dezelfde XML-envelope als het oude script. Identieke velden, identieke volgorde, identieke regeleinden. Bit voor bit. De Exact-beheerder had een parser die niets had met whitespace-veranderingen en wij gingen dat niet op een vrijdag uittesten.
Voor de cutover draaiden we beide syncs veertien nachten parallel. Het oude script schreef naar de live Exact-instance. De nieuwe worker schreef naar een sandbox-database die de Exact-beheerder had opgezet. Elke ochtend om 09:00 draaide een diff-job over de twee grootboeken en plaatste eventuele delta's in een Slack-kanaal dat maar drie mensen konden zien.
Twaalf nachten, nul delta's. Op nacht dertien vonden we een afrondingsverschil op regels met aantal 2: het oude script rondde af na btw, het nieuwe ervoor. We matchten het oude gedrag en draaiden nog twee schone nachten.
Week 4: shadow-traffic mirroring
Dit is de week die de meeste migraties overslaan. Dat zouden ze niet moeten doen.
We zetten een kleine Go-proxy voor de load balancer. Elk inkomend request naar het Magento-portaal werd in realtime gedupliceerd naar de nieuwe Medusa + Remix stack. De gedupliceerde response gooiden we weg. Alleen de live Magento-response ging terug naar de dealer.
Wat we vanuit de shadow maten:
- Response-tijden. De Remix-productpagina's renderden op p95 240ms. Magento zat op 1,4s. Voorspelbaar.
- Diff'd outputs. Een aparte worker vergeleek voor elk request de dealer-tier prijs die elke stack berekende. Dag één liet 4% mismatch zien. Aan het einde van de week, na het fixen van twee pattern-matching bugs in de SKU-wildcard handler, zaten we op 0,02%.
- Error budgets. De nieuwe stack gooide een 500 op PATCH-requests zonder Content-Length. Magento accepteerde die stilletjes. We bouwden dezelfde tolerantie in en gingen door.
Niets hiervan was zichtbaar geweest vanuit een staging-omgeving. Echt dealerverkeer onthult de aannames waarvan je niet wist dat je ze maakte.
Week 5: de cutover en het verwijderprobleem
De cutover zelf was anticlimactisch. We flipten de load balancer om 04:00 op een dinsdag. De shadow-stack werd de live stack. De oude Magento ging read-only en bleef nog tien dagen staan als rollback-doel.
Eén ding hadden we niet goed gepland: het archiveren van de oude orderhistorie.
We hadden besloten om negen jaar Magento-orders te bewaren in een magento_legacy_orders-tabel in Postgres, bereikbaar via één admin-view voor compliance. Na cutover wilden we alles ouder dan zeven jaar opschonen. Het ging om zes miljoen rijen.
Hier verdiende een recente thread over Postgres-deletes (terecht getiteld dat de enige schaalbare delete in Postgres DROP TABLE is) zijn voorpagina-plek. Een naïeve DELETE op zes miljoen rijen met foreign keys zou onze admin-view vijftien minuten gaan locken tijdens kantooruren. We wilden geen vijftien minuten incident in week vijf.
Wat we wel deden:
- De legacy-tabel bij import range-gepartitioneerd op jaar. Negen partities, één per jaar.
- Compliance-retentie had alleen de laatste zeven nodig. Om op te schonen deden we geen
DELETE. We detachten en dropten de twee oudste partities. - De hele operatie duurde minder dan een seconde en hield geen row locks vast.
ALTER TABLE magento_legacy_orders
DETACH PARTITION magento_legacy_orders_2014;
DROP TABLE magento_legacy_orders_2014;
Als je ooit verwacht een groot stuk historische data te moeten verwijderen, partitioneer het op de dag dat je het importeert. Tegen de tijd dat je moet snoeien, is je enige goede optie de partitie droppen.
Magento 1 bereikte end-of-life op 30 juni 2020. Elke PCI-scan na die datum is een compliance-vraag, geen technische. Card processors gaan dat uiteindelijk opmerken. Adobe stopte met patchen, en de rest ook.
Wat we anders zouden doen
Drie dingen.
Eén: we onderschatten hoelang de Exact Globe-parser parallel moest draaien. Twee weken was genoeg. Volgende keer plannen we er drie. De asymmetrie zit erin dat één gemiste grootboeknacht een echt boekhoudkundig probleem is.
Twee: we hadden het diff-dashboard al in week twee aan de operations lead moeten geven, niet in week vier. Ze ving twee echte prijsbugs in het eerste uur dat ze toegang had. Engineers bewaakten de data en dat hadden ze niet moeten doen.
Drie: het rollback-plan was een flag in de load balancer. Het werkte, maar het zou niet hebben geholpen als de cutover de Exact-sync had gebroken op een manier die pas de volgende ochtend zichtbaar was. We eisen nu een grootboek-diff venster van 24 uur bovenop elke cutover voordat het rollback-doel uit dienst gaat.
Het patroon in vijf regels
Als je deze post voor volgende week scant:
- Map elke entiteit en zijn echte source of truth voordat je code schrijft.
- Draai de nieuwe stack náást de oude met shadow traffic gedurende minstens zeven dagen, inclusief een weekend en een maandafsluiting.
- Diff de outputs continu en breng delta's onder de aandacht van iemand die geen engineer is.
- Draai elke grootboeksync minimaal twee weken parallel.
- Partitioneer elke tabel waaruit je ooit zou kunnen moeten verwijderen.
Toen we dit voor de Enschedese distributeur draaiden, kende de cutover-dinsdag nul supporttickets. De CFO merkte het op omdat de magazijnrapporten twee seconden sneller waren, niet omdat de website was veranderd. Dat is het resultaat dat we zoeken bij elke legacy migratie: het werk was zwaar, maar op de dag dat de dealers inlogden, voelde alleen de snelheid anders.
Vijfminutenactie: open je productie-database, zoek je grootste order- of facturentabel, en kijk of die gepartitioneerd is. Zo niet en als het ooit zo ver komt, dan is dat het kleinste stuk schuld dat je deze week kunt aflossen.
Kern
Map elke entiteit naar zijn echte source of truth voordat je één regel code schrijft. De map is week nul van elke schone cutover.
FAQ
Waarom niet gewoon upgraden naar Magento 2?
Voor een distributeur van 28 mensen met zware customisation overstijgen de licentiekosten, hostingkosten en herschrijfomvang van Magento 2 vaak een schone rebuild op een headless stack. We wegen beide af en kiezen op basis van totale kosten over drie jaar.
Hoelang moet de shadow-traffic periode draaien?
Minimaal zeven kalenderdagen, inclusief een weekend en een maandafsluiting. Voor portalen met een nachtelijke grootboeksync zijn veertien nachten veiliger. We hebben er nooit spijt van gehad dat we het langer lieten draaien.
En SEO tijdens de cutover?
We bouwen de URL-map vooraf en 301-redirecten elke oude Magento-route naar de nieuwe Remix-route op de proxy-laag. We houden Search Console 30 dagen in de gaten en fixen 404-pieken dezelfde dag.
Kan Medusa.js B2B-pricing out of the box aan?
Gedeeltelijk. Tier- en customer-group pricing werkt. Iets complexers (contract-overrides, range-constraints, overlappende afspraken) heeft meestal een eigen tabel buiten de price-list primitive nodig.