Zadig&Voltaire vend sur neuf storefronts. Ils ne vivent pas au même rythme : une nouveauté peut ouvrir en France un mardi, en Allemagne la semaine suivante, et jamais aux États-Unis parce que le service logistique n’y est pas le même.

Pendant longtemps, « ouvrir sur un marché » voulait dire : une variable d’environnement, une modification de chart, un build, un déploiement. Pour un sujet qui relève du merchandising, c’est absurde. La personne qui sait quand ouvrir n’est pas celle qui sait déployer.

On a donc posé une règle : une fonctionnalité visible par les clientes est terminée quand le merchandising peut l’ouvrir, magasin par magasin, depuis le back-office Magento, sans déploiement. Voici comment ça marche, et ce que ça nous a coûté de le rendre fiable.

L’interrupteur vit dans Magento, et nulle part ailleurs

Notre règle d’architecture est simple : les règles métier vivent dans Magento, les storefronts affichent. Un interrupteur de fonctionnalité ne fait pas exception. C’est un champ de configuration Magento classique, déclaré dans le system.xml du module qui porte la fonctionnalité, avec une portée « magasin » :

<group id="delivery_estimate" showInDefault="1" showInWebsite="1" showInStore="1">
    <label>Delivery Estimate</label>
    <field id="enabled" type="select" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1">
        <label>Enabled</label>
        <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
    </field>
</group>

Trois formes suffisent : un booléen, un sélecteur de mode (par exemple algolia, meilisearch ou ab pour notre moteur de recherche), ou une liste de valeurs dont l’état vide veut dire « éteint ».

La valeur par défaut, dans le config.xml, est le comportement d’avant la fonctionnalité. Un environnement que personne n’a configuré se comporte comme hier. L’exception se déclare à côté : l’estimation de date de livraison est allumée par défaut et éteinte pour le magasin américain, dans le même fichier.

Le storefront, lui, lit ce champ et rien d’autre. Pas de variable NUXT_PUBLIC_* en parallèle : deux sources de vérité finissent toujours par se contredire, et c’est la cliente qui voit le résultat. On a d’ailleurs retiré cette année deux variables d’environnement qui doublaient un réglage Magento, celle du test de moteur de recherche et celle des offres de livraison sur la fiche produit.

Deux canaux pour un même champ

Entre une case cochée dans le back-office et un bloc qui apparaît sur une fiche produit, la valeur emprunte deux chemins indépendants.

Deux canaux pour un même interrupteur : au build, la CI interroge le storeConfig GraphQL de chaque magasin et fige les valeurs dans l'image ; en direct, une sauvegarde du back-office déclenche un observer qui pousse la configuration dans un Redis partagé, que les navigateurs relisent au plus une fois par minute Canal 1 · figé au build Canal 2 · poussé en direct Back-office portée : magasin Build polaris storeConfig Image Docker config figée HTML SSR mis en cache CDN Observer POST authentifié Redis partagé tous les pods Navigateur relu ≤ 1 fois/min valeur de départ à l'hydratation lu aussi par les routes serveur ETag : 304 si inchangé
Le même champ Magento voyage par deux chemins. Le build fige une valeur de départ, la poussée la met à jour sur une page déjà ouverte.

Il faut brancher les deux. L’un sans l’autre donne des bugs qui ne lèvent aucune erreur.

Canal 1 : la valeur figée au build

À chaque build du storefront, un script interroge le storeConfig GraphQL de Magento pour chacun des neuf magasins et écrit le résultat dans la configuration embarquée dans l’image. C’est la valeur que le rendu serveur utilise, et celle avec laquelle chaque page démarre.

Côté Magento, le champ doit donc être exposé sur le type StoreConfig du schéma GraphQL. Côté storefront, il doit figurer dans la requête du script. Le piège est sournois : un champ absent de la requête n’est jamais demandé, aucune erreur ne remonte, et la fonctionnalité est morte sur les neuf magasins.

L’autre difficulté vient de l’ordre des déploiements. Magento part en production avant le storefront, mais rien ne garantit que l’environnement interrogé au moment du build connaisse déjà le nouveau champ. Une requête GraphQL qui demande un champ inconnu échoue en entier. D’où une liste de champs dits optionnels, retirés de la requête quand Magento les refuse :

const OPTIONAL_STORE_CONFIG_FIELDS = ['is_size_finder_enabled', 'search_mode', 'is_pdp_shipping_offers_enabled', /* … */]

if (errors) {
  const fallback = stripUnknownFields(query, errors)
  if (fallback) {
    console.warn(`Unknown optional field on store ${storeCode}, retrying without it.`)
    return fetchMagentoGraphQL<T>(storeCode, fallback, variables)
  }
}

Le build passe, le champ manque, et le storefront applique sa valeur par défaut, false. Sur un environnement dont le Magento est plus ancien que la fonctionnalité, elle reste simplement éteinte.

Canal 2 : la poussée en direct

Le premier canal ne suffit pas : attendre un build pour qu’une case cochée prenne effet, c’est retomber dans le problème de départ.

Quand quelqu’un enregistre une section de configuration dans le back-office, Magento émet un événement admin_system_config_changed_section_<section>. Un observer l’écoute, vérifie que l’un des chemins modifiés fait partie de la liste des réglages exposés (une trentaine aujourd’hui), reconstruit la configuration des magasins concernés par la portée enregistrée, et la pousse au storefront par un POST authentifié.

Le storefront range ce blob dans un Redis partagé par tous ses pods. Les navigateurs le relisent à chaque navigation, au plus une fois par minute. La réponse porte un ETag calculé sur le contenu : identique sur tous les pods tant que rien n’a changé, il permet de renvoyer une 304 sans corps à l’immense majorité de ces lectures.

const etag = `W/"${createHash('sha1').update(JSON.stringify(storedConfig)).digest('base64')}"`
setResponseHeader(event, 'ETag', etag)

if (getRequestHeader(event, 'if-none-match') === etag) {
  setResponseStatus(event, 304)
  return null
}

Les routes serveur lisent le même Redis. Le proxy de recherche, par exemple, y prend l’ordre de relâchement des mots propre à chaque magasin, et ne retombe sur la valeur du build que si Redis ne répond pas.

Lire la valeur au bon endroit

Une poussée ne sert à rien si le composant a déjà copié la valeur. La configuration reçue est fusionnée dans l’appConfig réactive de Nuxt : un composant qui lit le réglage dans un computed se met à jour tout seul, un composant qui l’a lu une fois au setup garde l’ancienne valeur jusqu’au prochain chargement de page.

const countryConfig = useCountryConfig()

const defaultShippingMethod = computed(() => countryConfig.defaultShippingMethod || null)
const threshold = computed(() => defaultShippingMethod.value?.free_shipping_threshold || null)

C’est ce qui permet au seuil de livraison offerte de changer pour une cliente qui navigue déjà sur le site, sans qu’elle recharge la page. Quand on a changé ce seuil en France, la barre de progression a suivi sans déploiement.

Onglet Livraisons et retours d'une fiche produit, listant les offres Standard, En point relais et Express avec leur prix et leur seuil de gratuité
Les offres de livraison de la fiche produit sont lues dans la configuration Magento du magasin, derrière un interrupteur ouvert par défaut depuis fin septembre.

Quatre bugs, quatre leçons

Le mécanisme n’est pas sorti parfait. Chaque étape est née d’un bug qu’on n’avait pas anticipé.

Février : le streaming qui ne tient pas. La première version poussait les changements aux navigateurs par Server-Sent Events. Il fallait un battement de cœur, une reconnexion forcée toutes les cinq minutes contre les connexions zombies, un nettoyage soigné à chaque déconnexion : beaucoup de mécanique pour un réglage qui change quelques fois par semaine. Avant même la mise en ligne, on l’a remplacée par une simple lecture au fil de la navigation. Moins élégant, beaucoup plus robuste.

Avril : la poussée qui n’atteint qu’un pod. La configuration reçue était stockée en mémoire, dans le pod qui avait reçu le POST. Les autres continuaient de servir l’ancienne valeur, et une cliente pouvait voir le seuil de livraison changer selon le pod qui lui répondait. Le correctif tient en un plugin Nitro qui monte un stockage Redis partagé :

storage.mount('store-config', redisDriver({
  base: 'polaris:store-config:',
  host: redis.host,
  connectTimeout: 3000,
  commandTimeout: 3000,
  maxRetriesPerRequest: 1,
}))

Juillet : l’enregistrement qui efface les autres magasins. Une sauvegarde à la portée d’un seul magasin n’émet que ce magasin. Le storefront remplaçait tout le blob par ce qu’il recevait : après un enregistrement pour l’Espagne, seule l’Espagne gardait sa configuration poussée, et les huit autres magasins retombaient sur leur valeur du build. On fusionne désormais les magasins reçus avec ceux déjà stockés.

Septembre : le tableau qui ne raccourcit pas. updateAppConfig fusionne les tableaux indice par indice. Quand une poussée renvoyait une liste d’offres de livraison plus courte que celle du build, les dernières entrées du build restaient affichées. On tronque maintenant les tableaux avant la fusion.

Aucun de ces bugs ne levait d’erreur. Tous se voyaient à l’écran, sur un seul marché, de façon intermittente. C’est la signature des problèmes de configuration distribuée.

La limite : ce que le serveur a déjà rendu

Le canal direct met à jour le navigateur. Il ne réécrit pas le HTML déjà rendu. Le rendu serveur part de la valeur figée au build, et ce HTML est mis en cache par le CDN. Une ouverture de marché prend donc effet chez chaque cliente à sa prochaine lecture de la configuration, mais le premier affichage reste celui du build jusqu’à ce que le storefront soit reconstruit.

En pratique, notre procédure de mise en marché tient en une ligne : on bascule l’interrupteur, les clientes le voient à leur navigation suivante, et le marché est ouvert proprement au build suivant.

Un cas échappe à la règle : l’interrupteur qui décide de ce que le serveur rend, par exemple le système de contenu qui sert une page. L’hydratation doit retrouver exactement le choix du serveur, une bascule en direct casserait la page. Ce type de réglage reste une variable d’environnement, déclarée dans le chart avec une ligne qui dit quand elle disparaîtra.

Ce que ça change

Aujourd’hui, une trentaine de réglages passent par ce mécanisme : le mode du moteur de recherche et son ordre de relâchement, les surfaces Personalize une par une, les types de produits éligibles à l’essayage virtuel, le Size Finder, l’estimation de livraison, les offres de livraison sur la fiche produit, le seuil de livraison offerte, le nombre de pages vues avant la popin newsletter.

Ce qu’on a appris

  • Le bon propriétaire d’un interrupteur est celui qui décide de l’ouvrir. Mettre la case dans le back-office plutôt que dans un chart a changé qui peut lancer une fonctionnalité.
  • Une seule source de vérité. Un réglage Magento doublé d’une variable d’environnement finit toujours par diverger.
  • La valeur par défaut doit reproduire le comportement d’avant. C’est ce qui rend sûrs un environnement oublié et un build qui tourne contre un Magento plus ancien.
  • Dans un système distribué, le cache est partout. Mémoire du pod, HTML du CDN, configuration figée de l’image, valeur copiée dans un composant : chaque bug venait d’une copie qu’on avait oubliée.
  • Préférer l’ennuyeux qui marche. Une lecture par minute avec un ETag a remplacé un flux temps réel, et personne ne s’en est jamais plaint.