Comment nous avons construit un moteur SQL local pour DynamoDB (une base de données sans JOIN)
DynamoDB peut exporter une table de 100 Go mais ne peut pas en agréger une. Il
n’y a pas de JOIN, pas de GROUP BY, pas de COUNT(*) — pas de versions
lentes de tout ça, aucune — et même , la surface SQL-esque
d’AWS elle-même, ne les ajoute pas. Alors chaque équipe
finit par écrire le même script jetable : paginer un , replier les
lignes dans une map, afficher la réponse, supprimer le script.
Le Workbench de DynoTable est notre réponse à ce script : un
onglet où tu écris un vrai SELECT — JOIN … ON multi-tables, WHERE,
GROUP BY, HAVING, DISTINCT, CASE, des agrégats — contre des tables qui
n’en supportent aucun. Cet article, c’est comment ça marche sous le capot, le
parser qui nous a silencieusement menti, et pourquoi nous avons plus tard arraché
le modèle mémoire du moteur.
Ne construis pas de moteur de requêtes
La décision centrale a été de refuser d’écrire un exécuteur relationnel. Les pages DynamoDB streament dans une base de données SQLite embarquée, et SQLite — l’un des moteurs de requêtes les plus testés au monde — fait le travail relationnel :
Chaque item DynamoDB atterrit dans SQLite sous forme de son enveloppe typée brute
({platform: {S: "ios"}}) dans une seule colonne item. Une petite fonction
définie par l’utilisateur déballe les attributs au moment de la requête, si bien
que ton SELECT platform compile vers un appel attr(item, 'platform', …).
Un détail de cette fonction est porteur : elle enveloppe son parsing JSON dans un
catch, parce que le binding SQLite transforme l’erreur de parsing d’une seule
ligne malformée en un abandon de l’instruction préparée tout entière. Sans le
catch, une seule ligne corrompue tuerait la requête.
La contrainte honnête que nous avons gardée : la physique des schémas d’accès
de DynamoDB s’applique toujours. Le côté « vers » d’un JOIN doit être une clé
primaire ou une clé de partition de — le moteur résout les jointures
par recherche de clé contre le stream, jamais par scan en produit cartésien. Si
tu as besoin de jointures arbitraires sur des attributs non-clés, c’est une
conversation de modélisation, pas une fonctionnalité de
moteur de requêtes.

Le parser qui nous a menti
La première version utilisait un parser SQL sur étagère populaire. Sur quelques semaines, quatre bugs de correction indépendants remontaient jusqu’à lui :
SELECT DISTINCTétait silencieusement ignoré. Le parser l’acceptait et l’émetteur l’ignorait — chaque ligne dupliquée revenait. Le rapport de bug d’un utilisateur disait : « affiche une table de toutes les valeurs, pas des compteurs ».- Tout ce qui n’était pas géré devenait le
NULLSQL littéral.CASE,CAST, les sous-requêtes scalaires — le repli de l’émetteur pour un nœud de syntaxe inconnu étaitreturn 'NULL'. Les requêtes s’exécutaient, renvoyaient des réponses fausses avec assurance, et ne levaient aucune erreur. - La casse des identifiants ne se comportait pas comme en SQL.
SELECT PLATFORM FROM trenvoyait des nuls quand l’attribut étaitplatform— l’inverse de ce à quoi toute base de données SQL t’entraîne à t’attendre. - L’autocomplétion était en désaccord avec le résolveur, suggérant des noms que le compilateur échouait ensuite à résoudre.
Le remplaçant, sql-parser-cst, a gagné sur une primitive que l’étude n’a
trouvée nulle part ailleurs : son arbre préserve à la fois le texte brut et le
nom normalisé de chaque identifiant — si bien que le compilateur peut
distinguer platform de "PLATFORM". Cette seule distinction permet à une
grammaire de porter une sémantique ANSI correcte : les identifiants non cités se
résolvent sans tenir compte de la casse, ceux entre guillemets sont l’échappatoire
sensible à la casse, exactement comme Postgres ou SQLite. Il porte aussi une plage
source sur chaque nœud, si bien qu’un diagnostic souligne la construction fautive
au lieu de l’instruction entière.
Et nous avons remplacé le repli return 'NULL' par une règle que nous traitons
désormais comme une politique : pas de NULL silencieux, jamais. Le bras par
défaut de l’émetteur est une erreur à plage précise. Les fonctions de fenêtrage
obtiennent un message qui dit pourquoi, pas seulement non :
Window functions are not supported in Workbench.
They have undefined semantics on the joined-row materialization.Le compromis que nous avons accepté : une dépendance pré-1.0, épinglée à la version exacte, mise à jour uniquement à la main avec la suite de tests comme garde-fou. Nous avons évalué le patch de l’ancien parser (il fallait un pré-tokeniseur parallèle — parser chaque entrée deux fois), l’usage de l’arbre syntaxique de l’éditeur (au niveau des tokens, sans structure de clauses), et le fait maison (l’essentiel d’un parser SQLite, maintenance sans limite). Un échec de correction sur quatre fronts bat toutes ces préoccupations.
Savoir ce qu’il ne faut pas bloquer
Un diagnostic ne t’arrête délibérément pas. Quand un filtre référence un attribut dont le schéma ne peut pas confirmer la casse, nous ne pouvons pas connaître la casse canonique — les filtres côté serveur de DynamoDB sont sensibles à la casse et son schéma ne déclare que les attributs de clé. Cela émet un avertissement, et les avertissements ne désactivent jamais Run. Nous avons appris cette classe de leçon à la dure dans notre travail sur les validateurs : un garde qui rejette la forme de requête légitime la plus courante est pire que pas de garde du tout.
Le plafond avait la mauvaise forme
Le premier moteur d’agrégation tamponnait les lignes scannées dans un SQLite en mémoire avec un plafond de taille — 64 Mo ici, 250 Mo là, et un chemin sans plafond qui pouvait faire manquer de mémoire à l’app sur une grande table. Atteins le plafond et tu obtenais une réponse partielle avec un drapeau de débordement.
La refonte a commencé par un recadrage : le concurrent est le script jetable qu’un développeur écrirait à la place. Et un script compétent ne tamponne-puis-plafonne pas — il streame, repliant chaque page dans un total courant avec une mémoire bornée. Le plafond en mémoire n’était pas trop petit ; il avait la mauvaise forme. Pire, un agrégat partiel n’est pas « incomplet » — il est faux. Le moment qui a rendu ça concret : notre propre démo IA a raconté avec assurance un classement partiel des « plus gros dépensiers » comme un fait.
Le moteur Compute reconstruit fonctionne comme le script, plus tout ce que le script ne s’embête jamais à faire :
- Un planificateur classe chaque requête dans l’une de trois voies :
stream-fold (agrégats algébriques simples —
COUNT/SUM/AVG/MIN/MAX— repliés page par page hors du processus principal ; la taille ne conditionne jamais une requête repliable, elle prend juste plus de temps), materialize (tris, jointures bornées par clé — déversés vers un SQLite adossé au disque, sans plafond mémoire), ou un refus honnête qui recommande le bon outil lourd (l’export S3 de DynamoDB plus Athena) pour la traîne que nous ne pouvons pas servir. - La classification de fold est délibérément étroite. Un
HAVING, unORDER BY, une colonne nue à côté de l’agrégat — n’importe lequel force la voie materialize. Un fold qui ignorerait une clause et rapporterait quand même « exact » serait précisément le bug faux-mais-confiant que ce planificateur existe pour prévenir. - L’exactitude fait partie du résultat. Les colonnes d’agrégat affichent un
badge partiel tant que les pages streament encore, et ne le lâchent que
lorsque le Scan est vidé. Même l’honnêteté arithmétique est suivie : les sommes
d’entiers restent exactes au-delà de 2⁵³ (stockées en entiers 64 bits), tandis
qu’une somme fractionnaire qui dépasse la mantisse flottante se signale
elle-même
partial: precisionplutôt que d’arrondir en silence. - Les fusibles remplacent les popups pour les machines. L’app interactive demande avant un Scan de cinq millions d’items ; l’assistant IA et les appelants MCP obtiennent des budgets de Scan et des abandons à la frontière de page à la place. Et l’enveloppe de résultat que le modèle voit est délibérément rognée — la classe de coût et les estimations de taille sont retenues, parce que présenter une supposition périmée invite le modèle à la raconter comme un fait.
Une note de coût qui a surpris les gens en interne : pour un agrégat ponctuel, un Scan en direct est environ 6× moins cher que de générer un export DynamoDB→S3 — c’est pourquoi la voie en forme de script est le défaut et l’entrepôt est la recommandation pour de l’analytique lourde répétée, pas le réflexe. (Le calculateur de tarifs chiffrera une passe complète de ta propre table.)
Est-il sûr de pointer du SQL vers la production ?
Le Workbench est structurellement en lecture seule. Le SQL final qui atteint le
moteur local passe par un garde en défense en profondeur qui tokenise et rejette
tout ce qui n’est pas un unique SELECT — en tant que violation de contrat, pas
d’erreur utilisateur, parce que le compilateur n’aurait jamais dû le produire. Le
substrat SQLite tourne avec les built-ins dangereux désactivés, et les écritures
vers DynamoDB ont exactement un chemin dans toute l’app : la
zone de staging vérifiable, que le SQL ne peut pas atteindre.
Ce qui se transfère si tu en construis un
- N’écris pas de moteur de requêtes ; écris un compilateur fidèle vers un moteur qui existe déjà. Tes bugs vivront dans les jointures (casse des identifiants, enveloppes de types, lignes corrompues), pas dans l’algorithme de jointure.
- Exige la provenance des guillemets de ton parser. S’il ne peut pas distinguer
namede"NAME", tu ne peux pas implémenter la sémantique de casse de SQL, et tes utilisateurs le découvriront avant toi. - Fais de la « syntaxe non gérée » une erreur bruyante, jamais une valeur par
défaut. Le
NULLsilencieux, c’est ainsi que les réponses fausses et confiantes sont livrées. - Benchmarke ta conception contre le script jetable que ton utilisateur écrirait. Si le script streame et que tu tamponnes, tu as construit la mauvaise forme.
- Traite les agrégats partiels comme des réponses fausses qui ont besoin d’une étiquette, pas comme une note partielle.
La doc du Workbench couvre la surface fonctionnelle au
quotidien, et SQL pour DynamoDB cartographie ce qui
marche à travers tout l’écosystème. Ou simplement télécharge
DynoTable, ouvre un onglet Workbench avec ⌘⌥Q, et exécute
un JOIN contre la base de données qui n’en a pas.


