- PostHog a réécrit son parseur SQL C++ basé sur ANTLR au fil de plusieurs sessions Claude Code, produisant un parseur Rust de 16 000 lignes, 5 000 lignes d’outils et des milliers de lignes de tests, avec un gain d’environ 70× sur ordinateur portable
- La nouvelle implémentation repose sur un parseur descendant récursif prédictif et un cœur d’expressions Pratt, avec un lookahead limité et du backtracking spéculatif uniquement là où c’est nécessaire, supprimant le coût de parcours de graphe générique d’ANTLR
- En conservant l’ancien parseur C++ comme implémentation de référence, l’équipe a combiné tests fondés sur les propriétés, requêtes réelles anonymisées, tests de régression, génération guidée par la couverture de code et réduction avec ShrinkRay pour éliminer itérativement les divergences entre les deux parseurs
- En mode shadow en production, les résultats de plusieurs millions d’analyses n’ont jamais divergé de ceux de l’ancien parseur, ce qui a permis de basculer le trafic en quelques heures, avec un gain de vitesse moyen de 454× en production
- Cette approche, où un générateur de parseur fournit la grammaire et l’implémentation de référence tandis qu’un LLM valide l’équivalence par fuzzing, montre qu’un développement de parseur haute performance qui demandait auparavant des mois d’expertise pourrait être ramené à quelques jours
Pourquoi PostHog a besoin d’un parseur SQL
- PostHog transforme le SQL saisi par les utilisateurs en SQL ClickHouse brut afin de leur permettre d’accéder directement aux données en SQL
- Fournit une vue logique des données indépendante de la disposition physique de la base
- Protège les requêtes existantes contre les changements de couche base de données
- Ajoute des optimisations de performance et du contrôle d’accès lors de la transformation
- La plupart des outils PostHog, comme l’analytics produit, la relecture de sessions et le suivi d’erreurs, font aussi passer les requêtes écrites en SQL par le même processus de transformation
- Pour transformer du SQL, il faut d’abord le convertir en arbre syntaxique abstrait (AST), qui est ensuite retraduit en SQL ClickHouse
- Le parseur est le premier composant à traiter des requêtes non fiables en entrée
- Les contrôles d’accès et optimisations qui suivent dépendent tous de l’arbre produit par le parseur
Structure et coût de l’ancien parseur ANTLR
- Avant le codage assisté par IA, écrire et maintenir un parseur à la main était très difficile, si bien que PostHog utilisait le générateur de parseur open source ANTLR
- Lorsqu’on fournit la grammaire sous forme de fichier .g4 déclaratif, ANTLR génère l’essentiel du code du parseur
- L’ancien parseur était déjà généré en C++, donc le gain de performance obtenu ici ne vient pas simplement d’un changement de langage vers Rust
- ANTLR est puissant et flexible, mais nécessite davantage de travail pour traiter chaque token
- Il compile la grammaire sous une forme proche d’un réseau de transitions augmenté (ATN), c’est-à-dire un automate fini non déterministe (NFA) avec pile
- À l’exécution, un interpréteur générique parcourt ce graphe
- Au lieu de fonctions écrites directement comme
parseExpression(), il passe par des couches supplémentaires d’abstraction et d’appels indirects
- Il prend aussi en charge un lookahead dynamique arbitraire, donc lorsqu’il existe plusieurs alternatives, il simule simultanément toutes les interprétations jusqu’à ce qu’une seule reste valide
- Même suffisamment optimisé, ANTLR peut difficilement devenir plus rapide qu’un parseur descendant récursif écrit à la main à cause de sa structure d’interpréteur de parcours de graphe
Deux implémentations de parseur testées en parallèle
- L’IA rend plus facile l’écriture et la maintenance d’un parseur fait main, mais demander simplement à Claude un parseur Rust sans erreur ne suffisait pas
- Claude faisait beaucoup d’erreurs
- Il remettait sans cesse en cause la faisabilité de la réécriture
- Il avait tendance à vouloir conclure le travail à la fin de chaque cycle de codage
- Plusieurs longues sessions Claude Code ont testé deux approches en parallèle
- L’approche axée sur la performance utilisait un parseur descendant récursif et une boucle d’expressions Pratt, avec du lookahead et du backtracking ajoutés uniquement là où c’était nécessaire
- L’approche axée sur les chances de réussite suivait autant que possible le comportement d’ANTLR, mais implémentait les transitions sous forme de code explicite plutôt que via un parcours de graphe générique
- Les deux approches ont fini par fonctionner à un niveau similaire, mais il a fallu plusieurs jours pour le confirmer
- L’objectif était de correspondre exactement à l’ancien parseur C++ pour toutes les requêtes réalistes, et de produire des résultats aussi proches que possible même pour des requêtes artificielles
- Des SQL valides mais anormaux en apparence, comme
SELECT SELECT FROM FROM WHERE WHERE AND AND, faisaient aussi partie des tests
- Des SQL valides mais anormaux en apparence, comme
- L’ancien parseur C++ a servi d’implémentation de référence (oracle) dans une approche de développement pilotée par les tests : trouver les SQL traités différemment, corriger le nouveau parseur, puis comparer à nouveau
Générer les divergences avec des tests fondés sur les propriétés
- Après avoir d’abord fait passer les tests de régression accumulés lors du développement de l’ancien parseur, l’équipe a généré systématiquement de nouvelles divergences
- Dans les tests fondés sur les propriétés (PBT) avec Hypothesis, la propriété vérifiée était définie comme « le nouveau parseur correspond à l’implémentation de référence »
- Les entrées sont des requêtes SQL
- Hypothesis cherche des requêtes pour lesquelles les résultats des deux parseurs ne correspondent pas
- Pour générer du SQL intéressant, l’équipe a créé avec Claude un outil qui génère du code de génération SQL à partir du fichier de grammaire ANTLR
- Un parseur séparé a aussi été écrit pour lire le fichier
.g4lui-même - Des mutations comme l’échange de tokens ou l’ajout de parenthèses ont ensuite été incluses dans l’étape de génération
- Un parseur séparé a aussi été écrit pour lire le fichier
Une structure de prompts pour éviter les corrections fragiles
- Les PBT produisaient de nouveaux tests de façon fiable, mais Claude répétait des corrections fragiles, par exemple corriger un cas précis avec un lookahead d’un token avant de découvrir plus tard qu’un lookahead de deux tokens était nécessaire
- Comme la fenêtre de contexte se remplissait souvent puis était compressée, il est possible qu’il ait effectué des corrections en ayant oublié la grammaire réelle ou le comportement du parseur de référence
- Le problème a été atténué en lui demandant de charger en contexte le fichier de grammaire pertinent et le code source C++ juste avant d’écrire le code corrigeant chaque divergence
Faire tourner CPU et Claude en continu
- Les PBT s’exécutaient en arrière-plan en continu, enregistrant les cas d’échec dans des fichiers, et les outils étaient configurés pour que Claude les récupère lorsqu’il n’avait rien d’autre à faire
- Les cas d’échec étaient collectés depuis plusieurs sources
- Tests de régression existants
- SQL généré par PBT
- Requêtes anonymisées issues des journaux de requêtes de production
- Cas créés en demandant à des agents en arrière-plan de « réfléchir vraiment en profondeur aux cas limites »
- Les deux parseurs développés en parallèle partageaient la suite de tests de régression, de sorte qu’un échec trouvé dans une session était immédiatement répercuté dans l’autre
- Hypothesis peut réduire les cas qu’il génère lui-même à des reproductions minimales, mais cela ne s’applique pas aux SQL externes ; pour ces cas, l’équipe a utilisé ShrinkRay
- Une génération de tests guidée par la couverture de code a ensuite été ajoutée
- Elle détecte les composants SQL qui n’ont pas encore été exécutés et biaise la génération pour produire plus souvent ces structures
- Elle n’était pas indispensable pour atteindre 100 % de précision sur l’ensemble des requêtes de production, mais a aidé à trouver des cas très subtils
Une boucle automatisée pour trouver et corriger les échecs
- La boucle finale de développement répétait de façon autonome les étapes suivantes
- Générer de nouveaux échecs via les PBT, l’ensemble de requêtes réelles, les tests de régression et l’exploration de cas limites
- Réduire les échecs et les ajouter à une liste de tests de régression en constante croissance
- Vérifier la grammaire et le traitement de l’implémentation C++ de référence, en privilégiant les solutions générales
- Après correction, produire un résumé d’un paragraphe lisible par un humain
- Exécuter l’ensemble complet des tests de régression pour vérifier que tous les cas passent
- Relancer le même processus de façon autonome
- Comme le nouveau parseur était beaucoup plus rapide, il pouvait être exécuté en production en mode shadow aux côtés de l’ancien parseur C++ afin de signaler les différences de résultats
Validation en production et bascule
- La comparaison préalable à partir des journaux de requêtes de production a testé environ 50 000 requêtes
- En mode shadow, plusieurs millions d’analyses ont été exécutées rapidement, sans qu’aucune divergence avec l’ancien parseur ne survienne
- Il était initialement prévu de conserver le mode shadow pendant plusieurs jours, mais les résultats étant suffisamment solides, le trafic de production a été basculé vers le nouveau parseur en quelques heures
- Après la bascule, un shadow inversé à 0,1 % a été conservé pour continuer à comparer avec l’ancien parseur
- Le nouveau parseur produit non seulement le même AST que le parseur C++ ANTLR, mais aussi les mêmes informations de position dans le code source
70× sur ordinateur portable, 454× en production
- Dans le benchmark sur ordinateur portable, les performances étaient environ 70× plus rapides que celles de l’ancien parseur
- Sur les requêtes de production, le parseur était en moyenne 454× plus rapide
- En production, il traitait surtout des SQL plus longs qui n’atteignent pas le cache du parseur, ce qui accentue l’écart par rapport au benchmark sur ordinateur portable
- Le résultat final comprend environ 16 000 lignes de code de parseur, 5 000 lignes d’outils et des milliers de lignes de tests
- Sur les requêtes réalistes, il est équivalent à l’ancien parseur ; les différences se limitent à un très petit nombre de requêtes construites artificiellement
Structure finale du parseur et évolution de la façon de développer
- Le parseur final est structuré ainsi
- La majeure partie est implémentée comme parseur descendant récursif prédictif
- Le traitement des expressions utilise un cœur de parseur Pratt
- Le curseur de base est LL(2), avec une exploration limitée par lookahead sans consommer l’entrée à certains points précis
- Un backtracking spéculatif local, fondé sur des choix ordonnés, n’est appliqué que pour une petite minorité de décisions où il est indispensable
- L’ensemble du parseur a été écrit en Rust par Claude Opus 4.7 en mai 2026, sans écriture manuelle directe du code
- La configuration de PBT qui génère les entrées depuis la grammaire et guide la génération par la couverture de code est proche des méthodes modernes de fuzzing de parseurs, et se distingue d’un simple « vibe coding »
- Un travail qui pourrait prendre des mois même à un développeur connaissant spécifiquement les parseurs a été réalisé en quelques jours
- À l’avenir, il pourrait devenir courant que des générateurs de parseurs comme ANTLR fournissent une implémentation de référence, tandis qu’un LLM écrit un parseur fait main plus rapide en alignant son équivalence via PBT et fuzzing
Aucun commentaire pour le moment.