Come abbiamo costruito un motore SQL locale per DynamoDB (un database senza JOIN)
DynamoDB può esportare una tabella da 100 GB ma non può aggregarne una. Non esiste
JOIN, non esiste GROUP BY, non esiste COUNT(*) — non versioni lente di
questi, nessuna — e persino , la superficie SQL-simile di AWS
stessa, non li aggiunge. Così ogni team finisce per
scrivere lo stesso script usa e getta: paginare uno , ripiegare le
righe in una mappa, stampare la risposta, eliminare lo script.
Il Workbench di DynoTable è la nostra risposta a quello script:
una Tab dove scrivi un vero SELECT — JOIN … ON multi-tabella, WHERE,
GROUP BY, HAVING, DISTINCT, CASE, aggregati — su tabelle che non ne
supportano nessuno. Questo articolo racconta come funziona sotto il cofano, il
parser che ci mentiva in silenzio e perché in seguito abbiamo estirpato il modello
di memoria del motore.
Non costruire un motore di query
La decisione centrale è stata rifiutare di scrivere un esecutore relazionale. Le pagine di DynamoDB confluiscono in streaming in un database SQLite embedded, e SQLite — uno dei motori di query più testati al mondo — svolge il lavoro relazionale:
Ogni Item DynamoDB atterra in SQLite come il suo envelope tipizzato grezzo
({platform: {S: "ios"}}) in un'unica colonna item. Una piccola funzione
definita dall'utente estrae gli attributi al momento della query, così il tuo
SELECT platform compila in una chiamata attr(item, 'platform', …). Un
dettaglio di quella funzione è portante: avvolge il proprio parsing JSON in un
catch, perché il binding SQLite trasforma l'errore di parsing di una singola riga
malformata in un abort dell'intero prepared statement. Senza il catch, una
singola riga corrotta ucciderebbe la query.
Il vincolo onesto che abbiamo mantenuto: la fisica degli access pattern di
DynamoDB continua a valere. Il lato di destinazione di un JOIN deve essere una
chiave primaria o una partition key di — il motore risolve i join
tramite lookup per chiave sullo stream, mai tramite scansione a prodotto
cartesiano. Se ti servono join arbitrari su attributi non chiave, quella è una
questione di modellazione, non una funzionalità del motore
di query.

Il parser che ci mentiva
La prima versione usava un popolare parser SQL preconfezionato. Nell'arco di poche settimane, quattro bug di correttezza indipendenti sono stati ricondotti a esso:
SELECT DISTINCTveniva scartato in silenzio. Il parser lo accettava e l'emitter lo ignorava — ogni riga duplicata tornava indietro. Il bug report di un utente diceva: «mostra una tabella di tutti i valori, non dei contatori».- Qualsiasi cosa non gestita diventava il letterale SQL
NULL.CASE,CAST, subquery scalari — il fallback dell'emitter per un nodo di sintassi sconosciuto erareturn 'NULL'. Le query venivano eseguite, restituivano risposte sbagliate con sicurezza e non sollevavano alcun errore. - Il case degli identificatori non si comportava come in SQL.
SELECT PLATFORM FROM trestituiva null quando l'attributo eraplatform— l'opposto di ciò che ogni database SQL ti abitua ad aspettarti. - L'autocompletamento non concordava con il resolver, suggerendo nomi che il compilatore poi non riusciva a risolvere.
Il sostituto, sql-parser-cst, ha vinto su una primitiva che la nostra
ricognizione non ha trovato da nessun'altra parte: il suo albero conserva sia il
testo grezzo sia il nome normalizzato di ogni identificatore — così il
compilatore può distinguere platform da "PLATFORM". Quell'unica distinzione
permette a una sola grammatica di portare una semantica ANSI corretta: gli
identificatori non quotati si risolvono in modo case-insensitive, quelli quotati
sono la via di fuga case-sensitive, esattamente come Postgres o SQLite. Porta
inoltre un source range su ogni nodo, così una diagnostica sottolinea il costrutto
incriminato invece dell'intero statement.
E abbiamo sostituito il fallback return 'NULL' con una regola che ora trattiamo
come policy: mai un NULL silenzioso. Il ramo di default dell'emitter è un
errore con range preciso. Le window function ricevono un messaggio che dice
perché, non solo no:
Window functions are not supported in Workbench.
They have undefined semantics on the joined-row materialization.Il compromesso che abbiamo accettato: una dipendenza pre-1.0, fissata alla versione esatta, aggiornata solo a mano con la suite di test come cancello. Abbiamo valutato di patchare il vecchio parser (richiedeva un pre-tokenizzatore parallelo — analizzando ogni input due volte), di usare l'albero sintattico dell'editor (a livello di token, senza struttura delle clausole) e di scriverne uno a mano (gran parte di un parser SQLite, manutenzione illimitata). Un fallimento di correttezza su quattro fronti batte tutte quelle preoccupazioni.
Sapere cosa non bloccare
Una diagnostica deliberatamente non ti ferma. Quando un filtro fa riferimento a un attributo di cui lo schema non può confermare il case, non possiamo conoscere il case canonico — i filtri lato server di DynamoDB sono case-sensitive e il suo schema dichiara solo gli attributi chiave. Questo emette un avviso, e gli avvisi non disabilitano mai Run. Abbiamo imparato questa classe di lezione a nostre spese nel nostro lavoro sui validator: un cancello che rifiuta la forma di query legittima più comune è peggio di nessun cancello.
Il limite aveva la forma sbagliata
Il primo motore di aggregazione bufferizzava le righe scansionate in un SQLite in memoria con un limite di dimensione — 64 MB qui, 250 MB là, e un percorso senza limite che poteva mandare l'app in OOM su una tabella grande. Se raggiungevi il limite ottenevi una risposta parziale con un flag di overflow.
La riprogettazione è partita da un riinquadramento: il concorrente è lo script usa e getta che uno sviluppatore scriverebbe al suo posto. E uno script competente non bufferizza-poi-limita — fa streaming, ripiegando ogni pagina in un totale progressivo con memoria limitata. Il limite in memoria non era troppo piccolo; aveva la forma sbagliata. Peggio ancora, un aggregato parziale non è «incompleto» — è sbagliato. Il momento che ha reso tutto concreto: la nostra stessa demo AI ha narrato con sicurezza come un fatto una classifica parziale dei «top spender».
Il motore Compute ricostruito funziona come lo script, più tutto ciò di cui lo script non si preoccupa mai:
- Un planner classifica ogni query in una di tre corsie: stream-fold
(aggregati algebrici semplici —
COUNT/SUM/AVG/MIN/MAX— ripiegati pagina per pagina fuori dal processo main; la dimensione non condiziona mai una query ripiegabile, la rende solo più lenta), materialize (ordinamenti, join delimitati per chiave — riversati su SQLite su disco, senza limite di memoria) o un onesto rifiuto che raccomanda lo strumento pesante giusto (l'export su S3 di DynamoDB più Athena) per la coda che non possiamo servire. - La classificazione fold è deliberatamente ristretta. Un
HAVING, unORDER BY, una colonna nuda accanto all'aggregato — ognuno di essi forza la corsia materialize. Un fold che ignorasse una clausola e riportasse comunque «esatto» sarebbe esattamente il bug sbagliato-ma-sicuro che questo planner esiste per prevenire. - L'esattezza fa parte del risultato. Le colonne aggregate mostrano un badge
partial mentre le pagine sono ancora in streaming, e lo rimuovono solo quando
lo Scan si è esaurito. Persino l'onestà aritmetica è tracciata: le somme di
interi restano esatte oltre 2⁵³ (memorizzate come interi a 64 bit), mentre una
somma frazionaria che supera la mantissa del float si contrassegna da sola come
partial: precisionanziché arrotondare in silenzio. - I fusibili sostituiscono i popup per le macchine. L'app interattiva chiede prima di una scansione da cinque milioni di Item; l'assistente AI e i chiamanti MCP ricevono invece budget di scansione e abort al confine di pagina. E l'envelope dei risultati che il modello vede è deliberatamente ridotto — la classe di costo e le stime di dimensione vengono trattenute, perché mostrare una supposizione obsoleta invita il modello a narrarla come un fatto.
Una nota sui costi che ha sorpreso internamente: per un aggregato una tantum, una scansione live è all'incirca 6× più economica che generare un export DynamoDB→S3 — motivo per cui la corsia a forma di script è il default e il warehouse è la raccomandazione per analitiche pesanti e ripetute, non il riflesso. (Il calcolatore dei prezzi calcolerà il costo di una passata completa della tua tabella.)
È sicuro puntare SQL alla produzione?
Il Workbench è strutturalmente di sola lettura. L'SQL finale che raggiunge il
motore locale attraversa un cancello di difesa in profondità che tokenizza e
rifiuta qualsiasi cosa non sia un singolo SELECT — come una violazione di
contratto, non un errore dell'utente, perché il compilatore non avrebbe mai
dovuto produrlo. Il substrato SQLite gira con le built-in pericolose disabilitate,
e le scritture su DynamoDB hanno esattamente un percorso nell'intera app: l'area
di staging revisionabile, che l'SQL non può raggiungere.
Cosa si trasferisce se ne stai costruendo uno
- Non scrivere un motore di query; scrivi un compilatore fedele verso uno che esiste già. I tuoi bug vivranno nelle giunture (case degli identificatori, envelope di tipo, righe corrotte), non nell'algoritmo di join.
- Pretendi dal tuo parser la provenienza delle virgolette. Se non riesce a
distinguere
nameda"NAME", non puoi implementare la semantica del case di SQL, e i tuoi utenti lo scopriranno prima di te. - Rendi la «sintassi non gestita» un errore rumoroso, mai un valore di default. Il
NULLsilenzioso è il modo in cui vengono rilasciate risposte sbagliate con sicurezza. - Confronta il tuo progetto con lo script usa e getta che scriverebbe il tuo utente. Se lo script fa streaming e tu bufferizzi, hai costruito la forma sbagliata.
- Tratta gli aggregati parziali come risposte sbagliate da etichettare, non come credito parziale.
La documentazione del Workbench copre la superficie funzionale
quotidiana, e SQL per DynamoDB mappa cosa funziona
nell'intero ecosistema. Oppure semplicemente scarica DynoTable, apri
una Tab Workbench con ⌘⌥Q ed esegui un JOIN sul database che non ne
ha uno.


