Importer des données, retour d’expérience


Dédicaçons la chanson de notre article au plus barbu de mes amis poneys.

J’ai importé des données un très grand nombre de fois dans ma vie. Depuis des APIs, des XML, des CSV, du filesystem, des formats binaires, des formats batards, etc

Pour tous les jobs d’import, Python est probablement le meilleur langage au monde. Autant j’aime Python, autant je suis lucide sur le fait qu’en dev Web, Ruby et Javascript sont d’excellentes alternatives. En programmation concurrente, Go dépasse Python. En IA, Lisp est le top de la concurrence.

Mais pour l’import de données, Python est simplement le meilleur langage au monde. Sa capacité à lire énormément de formats facilement, sa force de manipulation de données numériques et texte, sa philosophie d’itération, ses nombreuses libs en font un outil incroyablement souple et puissant.

Malgré cela, on retrouve toujours des grosses difficultés dans l’import de données. Elles sont les mêmes pour tous les langages.

Voici mes 2 centimes.

N’accordez aucune confiance à la donnée

Partez du principe que tout champ peut manquer. Que toute donnée peut être mal formée ou corrompue. Ou fausse.

Même si le service en face est sérieux. J’ai bossé avec des données du service de santé américain, de France Télécom, de startups, d’outils Open Source, de scrapping de sites, de mon pote Maurice, et de mes propres scripts

Vous savez ce qu’ils ont tous en commun ? Aucun ne sont fiables. Aucun.

Ils ont tous des données merdiques à un moment où à un autre.

Ayez donc une approche défensive. Pour CHAQUE champ, posez vous la question : que doit faire le script d’import si il manque ? Si la donnée contenue est foireuse ?

Les outils d’abstraction sont vos amis

Un import, c’est typiquement le genre de taff où les surcouches vont énormément vous aider. ORM, DSL, XML objectify et toute lib qui peut vous éviter de travailler trop proche du format va vous faire gagner un temps fou.

Prenez un peu de temps pour les mettre en place. Même si ils vous font perdre un peu en perf, un gros script d’import devient TRÈS VITE un sac de nœud. Et vous voulez que les problèmes soient facilement identifiables.

Pour cette même raison, virez toute la logique de l’insertion des données en dehors du script. Votre script doit avoir une logique découpée en 3 parties :

  1. Le script d’acquisition, qui charge les données et les passe sous forme brute à un importeur.
  2. Un importeur, qui est capable d’extraire des données raffinées à partir d’un format de données brutes et appeler le bon code d’insertion.
  3. Du code d’insertion, qui attend en paramètre une donnée toujours propre (sans aucun check), et qui se charge uniquement de prendre cette donnée et la mettre dans votre système de stockage (généralement la base de données).

Le script d’acquisition doit rester très simple. Une suite d’instructions logiques pour récupérer la donnée, l’énumérer et la passer à l’importeur. C’est lui qui fait les appels API, qui se connecte au FTP, qui ouvre le CSV, qui parse le XML, etc. Ainsi vous pouvez facilement interchanger les importeurs ou voir si il y a une couille dans la récupération des données.

L’importeur est généralement le code le plus crade. C’est une série de try / except, de logique métier, d’assainissement des données. Vous ne voulez pas de code d’insertion là dedans, car vous voulez que ce code, qui est difficile à débugger et va être celui qui va être modifié toutes les 5 minutes au fur et à mesure que vous découvrez toute les merdes, soit dédié à une seule logique : obtenir de la donnée saine. Ce code sera spaghetti, vous n’y pouvez rien. Mais vous pouvez l’isoler et le commenter à mort.

Le code d’insertion est un code réutilisable. Il se fout de savoir d’où vient la donnée. Il attend un format, et un seul, et toujours de données correctes, et propres. C’est le but des importeurs de lui filer une entrée normalisée et pertinente. Ce code est propre, et doit être très bien testé via des tests unittaires. Il va vous servir plusieurs fois, car c’est le même code qui sera utilisé que vous importiez d’un service X ou un service Y. Il représente VOTRE logique métier. N’insérez pas ce code dans le code d’une autre abstraction (type ORM), ainsi, si vous changez d’outils, vous changez simplement ce code, et l’interface reste la même pour vos importeurs.

Debugging

Votre script va planter. Beaucoup. Souvent.

Un champ absolument indispensable – que la spec papier notait comme toujours présent – va manquer. Un autre champ noté de type int dans le xld contient une lettre. L’encoding n’est pas le bon, alors qu’il l’a toujours été pendant 5 mois.

Ce n’est pas une question de si, c’est une question de quand.

Donc déjà, blindez votre script de log. Quand je dis blindez, je veux dire que chaque if, chaque résultat de check, doit être accompagné d’une ligne affichant l’action en cours, et son contexte (la donnée traitée, de préférence avec un truc pour l’identifier, genre un ID). Quand il plantera à 3 heure du matin sur un truc hubuesque et que le relancer pour obtenir le même état prendra une demi-journée, le log sera votre seul chance de réparer la panne sans engager un psy.

Mettez aussi un gros try / except générique qui loggue toute exception, pour pouvoir faire un debug post mortem. Idéalement, faites le dumper locals() et envoyez-vous un mail d’alerte. Vous ne voulez pas que le script ne tourne pas pendant une journée sans que vous le sachiez.

Mettez des options dans votre scripts pour pouvoir débugger plus facilement. Par exemple, si vous avez de code d’insertion qui est sous forme de tâche asynchrone (type celery), mettez une option --synchronous qui insère le code inline afin de pouvoir utiliser pdb sur tout le script en cas de besoin. Ou alors, si vous avez une grosse archive à décompresser, mettez une option --nozip pour pouvoir sauter cette étape.

Et si vous insérez un break point, mettez le dans une condition du genre :

if id_du_champ_ou_autre_moyen_identifiant == "valeur":
    send_mail('Alerte, on est peut être au bug. Bouge ton fion.')
    import ipdb; ipdp.set_trace()

Comme ça vous pouvez retourner à vos moutons le temps que le breakpoint s’active, ce qui, sur des gros jeux de données, peut prendre énormément de temps.

Enfin, je sais qu’on a tendance à être fainéant et vouloir toujours débugger en direct. Mais faites des mocks. Faites un faux XML, une API bidon, bref, un truc qui vous permet d’insérer des cas d’import avec une données dans le format attendu, et testez votre code avec ça. Pour les petits imports, c’est une perte de temps, mais pour les gros imports, ça va vous faire gagner des jours. Ainsi vous pouvez tester des cas isolé, rajouter des bugs rencontrés, etc. Et en plus ça sert de documentation.

Problèmes courants

Il y a mille et une manière d’avoir un import qui plante, mais il y a généralement 6 grosses foirades qu’on retrouve tout le temps.

Service défaillant

Le service (FTP, API, humain en face, NAS, etc) qui doit vous fournir les données est indisponible. Vous n’y pouvez rien. Envoyez-vous un SMS pour vous prévenir, aujourd’hui c’est facile et ça coûte presque rien. Ainsi vous pourrez dialoguer rapidement avec les personnes responsables de problème.

Champs manquants

Grand classique. Tout champ peut manquer. Tout. Même un ID unique sans lequel la donnée n’a aucune sens. Faites vous un wrapper du genre :

def get_data(champ):
    try:
        # extraire le champ
    except ChampAbsent, ChampMalFormé:
        return None

Et utilisez le partout. Et décidez ce que doit faire votre programme si il rencontre None. Pour TOUS les champs. Si None est une valeur possible, utilisez Ellipsis. Si Ellipsis est une valeur possible, faites vous une classe InvalidData.

Mauvais encoding

Super vicieux. Je vous renvoie à l’article sur l’encoding pour cela.

Donnée aberrante

Impossible à prévoir, très difficile à identifier. Donnée de mauvais type, date ou nombre hors limites, texte dans la mauvaise langue, etc. Vous ne pouvez pas tout prévoir. Pour ça, il faudra faire au fur et à mesure des plantages.

Données mal formatée et malicieuses

En plus de get_data, il vous faut un clean_data. Qui check si on peut processer la donnée sereinement. Pour tous les champs également. C’est con, mais si LEUR système n’escape pas les entrées utilisateurs, c’est VOTRE système dans lequel se retrouve les injections de code.

Performances

La vitesse de votre script sera généralement limitée par 3 facteurs :

  • Vitesse de lecture.
  • Vitesse d’écriture.
  • Vos plantages.

Ces 3 facteurs sont en général très liés.

La meilleur stratégie, c’est d’extrapoler un max de données, et de cacher tout ce qui est cacheable. Par exemple, copiez les données brutes sur vos serveurs (genre si c’est un fichier sur le leur). Copiez les références externes, même si vous n’en avez pas besoin afin d’éviter une query de plus, vous les supprimerez plus tard. Pré-calculez les champs, par exemple age, si vous avez la date de naissance, etc.

Si vous avez beaucoup de checks à faire pour l’assainissement des données, mettez vos données en cache (par exemple dans redis), pour que les looks up soient rapides, ou au moins, ajoutez les index qui vont bien dans la DB (on peut avoir des perfs X10 rien qu’avec ça).

Ensuite, partez du principe que ça va planter souvent, donc :

  • Faites des opérations idempotentes, c’est à dire qu’on peut les relancer autant de fois qu’on veut sans risque. Typiquement, vérifiez si une donnée existe avant l’insertion, et si oui, faites une mise à jour complète.
  • Mettez un historique. Sauvegardez quelque part l’avancement de votre import, afin de ne pas tout recommencer depuis le début après le plantage.

Ah oui, et faites des copies de sauvegarde des données brutes ET des données importées. Pour les premières, parce qu’on est pas à l’abri un “rm -fr” malencontreux. Ne rigolez pas, ça m’est arrivé la semaine dernière. Une semaine à tout DL à nouveau. Pour les secondes, parce que tant que le dernier import n’est pas terminé, on peut toujours corrompre toute sa base avec une couille de dernière minute. Comme un encoding qui change aléatoirement sur une donnée non datée.

Bon sens

Évidement, je parle ici d’un synthèse des problématiques rencontrées. Vous ne pouvez pas appliquer TOUT ça, ou en tout cas, pas au début, ou pas sur des petits scripts, etc. Selon le sérieux de votre source de données, il faudra plus ou moins être défensif. L’expérience et la douleur vous permettra de trouver la juste dose de morphine.

13 thoughts on “Importer des données, retour d’expérience

  • Labarbiche

    Super ton approche en 3 points… Super article !
    Il y a un outil assez dément pour manipuler des données, et extraire/identifier les valeurs absurdes (voir les vidéos): Google refine.
    Ca marchera pas forcément sur du big data, dans certains cas ça t’évitera de coder quoi que se soit, si c’est du one shot par exemple…
    Ca permettra en tout cas d’imaginer quels sont les cas pourris que ton algo devra gérer. :)

  • Fred

    Moi, pour l’import, c’est super facile

    def import():
        try:
            ... (open... read... insert into...)... # Bref ici tout le code d'import
        except:
            pass
        print("Import terminé, tout est ok"
    # import()
  • Stéphane

    Les remarques de maître Capello :

    Ellispis -> Ellipsis

    dans la mauvais langue -> dans la mauvaise langue

    si on peut processer la donnée -> si on peut traiter la donnée (?)

    LEUR système n’escape pas -> LEUR système ne protège pas

    une query de plus -> une requête supplémentaire

    ajoutez les indexes -> ajoutez les index

  • Recher

    Pour les références externes, on a parfois besoin de faire une “memoization” / “lazy evaluation” (je ne sais pas quel serait le terme exact).

    Exemple : on doit lire plusieurs milliards de ligne de données, dont l’un des champs est une valeur numérique : “id_position_sexuelle” (plusieurs lignes peuvent avoir la même valeur). Pour connaître le nom de la position correspondante, on doit interroger un serveur externe. Mais ce serveur est vilain, il ne donne qu’un seul nom à la fois, à partir de l’id qu’on lui envoie, et il ne peut pas donner le nombre total de position existante. On ne peut donc pas pré-récupérer tout les noms de positions pour les mettre en cache.

    Dans ce cas, à chaque fois qu’on trouve un id inconnu dans les données à lire, on interroge le serveur externe, et on enregistre le nom dans un fichier en local. Plus tard, si on retombe sur le même id, pas besoin de réinterroger le serveur.

  • Maxime L.

    Si je peux apporter ma pierre à l’édifice… En référence à ce paragraphe :

    Mettez aussi un gros try / except générique qui loggue toute exception, pour pouvoir faire un debug post mortem. Idéalement, faites le dumper locals() et envoyez-vous un mail d’alerte. Vous ne voulez pas que le script ne tourne pas pendant une journée sans que vous le sachiez.

    Pour la gestion des exceptions, je changerai carrément le hook de base de Python.

    import sys
     
    def myexcepthook(type, value, tb):
        import traceback
        from email.mime.text import MIMEText
        from subprocess import Popen, PIPE
        tbtext = ''.join(traceback.format_exception(type, value, tb))
        msg = MIMEText("There was a problem with your program:nn" + tbtext)
        msg["From"] = "root+importer@domain.com"
        msg["To"] = "admin@domain.com"
        msg["Subject"] = "[Sam&Max Importer] Crash report (Exception raised)"
        p = Popen(["/usr/sbin/sendmail", "-t"], stdin=PIPE)
        p.communicate(msg.as_string())
     
     
        reload()  # relaunch the importer if possible
     
    sys.excepthook = myexcepthook

    Ainsi, un mail est envoyé dès qu’une exception n’est pas géré dans le code (donc ça équivaut à un gros try/except), il exécute cette fonction. C’est plus propre et plus maintenable à mon goût :-)

    Référence StackOverflow de la réponse qui m’a inspiré

  • Sam Post author

    C’est vrai, mais il faut faire attention à ce que le code d’import ne soit pas exécuté dans la même VM qu’un autre projet, sinon les exceptions seront toutes logguées. Un tips très utile cela dit.

  • mentat

    D’accord avec TOUS les points cités.
    J’ajouterais que j’utilise des générateurs que j’enchaine :
    on lit une ligne | on traite/nettoie les données | on l’insère

    Comme un pipe sous un shell unix
    On décompose bien les traitements comme cela. Les tests peuvent être enchainés et ajouter/enlever très facilement.

    Et les performances sont top

  • Yan

    Bon j’arrive un peu tard mais pour moi l’insert doit se faire depuis la DB via un appel de SP par exemple, sinon pour le reste on est tous confronté aux mêmes problèmes, dire que mon boss pense que je m’amuse…

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.