Aller au contenu

Dépannage

Cette page recense les pannes réellement rencontrées pendant la mise en place, avec leur cause racine. Elles sont représentatives : la plupart se reproduiront sur une installation neuve ou une montée de version.

PostgreSQL refuse de démarrer

Error: in 18+, these Docker images are configured to store database data in a
format which is compatible with "pg_ctlcluster" ... there appears to be
PostgreSQL data in: /var/lib/postgresql/data (unused mount/volume)

Cause. Depuis PostgreSQL 18, l'image officielle attend le volume monté sur /var/lib/postgresql et non plus /var/lib/postgresql/data. Les données vivent dans un sous-répertoire par version majeure, ce qui permet pg_upgrade --link sans franchir de point de montage.

Correctif. Monter le volume sur /var/lib/postgresql.


Toutes les tâches Python d'Airflow échouent sur ModuleNotFoundError: airflow

Cause. L'image ajoute /opt/dbt/bin en tête du PATH. L'interpréteur Python du virtualenv dbt masque alors celui d'Airflow, qui ne contient pas le paquet airflow.

Correctif. Ne jamais mettre le virtualenv dbt dans le PATH. Exposer DBT_BIN=/opt/dbt/bin/dbt et l'appeler par son chemin absolu.


number of parameters must be between 0 and 65535

Cause. DataFrame.to_sql(method="multi") construit un INSERT géant dont chaque valeur est un paramètre lié. Le protocole PostgreSQL en plafonne le nombre à 65 535 : la limite est atteinte à chunksize × nb_colonnes. Le bug ne se déclenche donc que sur certaines tables, selon leur largeur.

Correctif. Utiliser COPY, qui n'a pas cette limite et charge un ordre de grandeur plus vite. Voir dags/lib/warehouse.py.


cannot drop table bronze.raw_customers because other objects depend on it

Cause. to_sql(if_exists="replace") exécute un DROP TABLE, or les vues de la couche silver dépendent des tables bronze.

Correctif. Sur remplacement, vider la table (TRUNCATE) au lieu de la supprimer. Le DROP ... CASCADE n'est utilisé qu'en dernier recours, quand la structure a réellement changé — dbt reconstruit alors les vues.


column "eolien" is of type bigint but expression is of type text

Cause. L'API ODRÉ est faiblement typée : selon les jours, une même colonne arrive en entier ou en chaîne ("0"). pandas déduit les types lot par lot, si bien que la table du jour et la table cible divergent.

Correctif. Imposer le schéma à l'ingestion : pd.to_numeric(..., errors="coerce") sur les colonnes de mesure, astype("string") sur les colonnes de libellé.


function round(double precision, integer) does not exist

Cause. PostgreSQL ne fournit round(x, n) que pour numeric. Le modèle référençait la colonne source en bronze, typée double precision par pandas — et non l'alias casté de la même requête.

Correctif. Caster explicitement : round(expression::numeric, 4).

Portabilité

DuckDB, BigQuery et Snowflake acceptent la forme non castée. Un modèle qui fonctionne sur l'un peut échouer sur PostgreSQL.


L'API ODRÉ renvoie 400 IncompatibleTypesInComparisonFilter

Cause. Le champ date du jeu eco2mix-regional-tr est typé texte. Une comparaison avec un littéral date (date = date'2026-08-18') est rejetée.

Correctif. Comparer des chaînes : where=date = "2026-08-18".


Le DAG d'ingestion échoue en déclenchement manuel

AttributeError: 'NoneType' object has no attribute 'in_timezone'

Cause. En Airflow 3, une exécution déclenchée manuellement n'a pas forcément d'intervalle de données : data_interval_start vaut None.

Correctif. Prévoir un repli — paramètre explicite, sinon intervalle, sinon la veille. Le bouton « Trigger » de l'interface doit fonctionner sans configuration.


La transformation dbt ne se déclenche plus toute seule

Cause. schedule=[ASSET_A, ASSET_B] signifie ET : Airflow attend que tous les assets aient été mis à jour. Le jeu synthétique n'étant rechargé que manuellement, la transformation attendait indéfiniment.

Correctif. Utiliser un OU logique : schedule=ASSET_A | ASSET_B.

Panne silencieuse

Aucune erreur n'est levée : le DAG reste simplement à l'arrêt. C'est exactement ce que la planification par asset est censée éviter.


Traefik renvoie 404 sur des chemins pourtant configurés

Cause. Les règles sont portées par les étiquettes Docker du conteneur. Modifier le docker-compose.yml ne suffit pas : tant que le conteneur n'est pas recréé, il porte les anciennes étiquettes.

Correctif.

docker compose up -d --force-recreate <service>
docker inspect metrika-<service>-1 --format '{{json .Config.Labels}}' | python3 -m json.tool

Laisser aussi quelques secondes à Traefik pour recharger sa configuration.


L'URI d'un asset Airflow est rejetée

ValueError: URI format postgres:// must contain database, schema, and table names

Cause. Les providers normalisent les URI d'asset selon leur schéma. Un postgres:// doit respecter la forme hôte/base/schéma/table.

Correctif. Pour un identifiant purement logique, utiliser un nom simple : Asset(name="warehouse.bronze.demo").


Metabase n'envoie pas de courriel malgré email-configured? = true

Cause. La route PUT /api/email n'accepte que les paramètres smtp-*. L'adresse d'expéditeur reste vide, et les messages partent sans expéditeur déclaré — donc massivement classés en indésirables.

Correctif. Poser email-from-address, email-from-name et email-reply-to via PUT /api/setting/<clé>.


Un tableau de bord intégré en iframe reste blanc

Cause. Le gestionnaire d'événement load vidait le conteneur (innerHTML = '') puis y réinsérait l'iframe. Vider le conteneur détache l'iframe du document ; la réinsérer la fait recharger, ce qui redéclenche load — et boucle indéfiniment sur un écran blanc.

Le même tableau de bord ouvert en plein écran fonctionnait, puisqu'il n'y avait alors aucune iframe.

Correctif. Insérer l'iframe une seule fois, et retirer uniquement le message d'attente au chargement.


Une barre SVG animée déborde en diagonale

Cause. En SVG, transform-origin se calcule par rapport au repère du SVG entier, et non par rapport à l'élément — contrairement au HTML. Une animation scaleX(0) → scaleX(1) avec transform-origin: left center part donc du coin du graphique.

Correctif. Ajouter transform-box: fill-box sur l'élément animé.


Metabase refuse une carte GeoJSON personnalisée

Trois causes distinctes, dans l'ordre où on les rencontre :

Message Cause Correctif
« doit commencer par http:// ou https:// … hôtes internes interdits » URL pointant vers le réseau Docker (http://web/…) — protection contre les requêtes côté serveur Servir le fichier sur une URL publique
« content-type invalide » nginx ne connaît pas l'extension .geojson et sert en application/octet-stream Déclarer types { application/geo+json geojson; }
Carte figée sur un chargement perpétuel Le navigateur appelle /api/geojson/<clé> S'assurer que ce chemin est routé vers Metabase

Le contenu d'une page n'apparaît pas en capture ou à l'impression

Cause. Les animations d'apparition au défilement mettent le contenu à opacity: 0 en attendant l'IntersectionObserver. Une capture pleine page ne déclenche aucun défilement : rien n'apparaît jamais.

Correctif. Ne masquer que si l'observateur existe réellement, révéler tout à l'événement beforeprint, et prévoir un filet de sécurité qui affiche tout au bout de quelques secondes.


Une série temporelle synthétique paraît multipliée par quarante

Cause. Deux artefacts de génération cumulés :

  1. les clients étaient créés uniformément sur la fenêtre observée — les premiers mois n'avaient donc presque aucun client ;
  2. la saisonnalité était appliquée après coup, en décalant une partie des commandes de fin d'année vers une date ultérieure tirée au hasard. Cela déplace systématiquement la masse vers la fin de la période.

Correctif. Faire préexister les deux tiers du parc à la fenêtre observée, et tirer le mois de commande par échantillonnage pondéré (coefficient saisonnier × croissance) plutôt que de déplacer une date déjà tirée.


Débordement horizontal sur mobile

Symptôme. document.documentElement.scrollWidth dépasse innerWidth.

Diagnostic. Parcourir les éléments et repérer ceux dont le bord droit dépasse la fenêtre :

[...document.querySelectorAll('body *')]
  .filter(e => e.getBoundingClientRect().right > innerWidth + 1)
  .map(e => `${e.tagName}.${e.className}`);

Dans le cas rencontré, une barre de navigation sans flex-wrap dont le dernier lien dépassait de 41 px.