Como construímos um mecanismo SQL local para o DynamoDB (um banco de dados sem JOINs)
O DynamoDB consegue exportar uma tabela de 100 GB, mas não consegue agregá-la. Não
há JOIN, não há GROUP BY, não há COUNT(*) — não versões lentas deles,
nenhum — e mesmo o , a própria superfície SQL-esca da AWS,
não os adiciona. Então toda equipe acaba escrevendo o
mesmo script descartável: paginar um , dobrar as linhas em um mapa,
imprimir a resposta, deletar o script.
O Workbench do DynoTable é nossa resposta a esse script: uma aba
onde você escreve um SELECT de verdade — JOIN … ON multi-tabela, WHERE,
GROUP BY, HAVING, DISTINCT, CASE, agregados — contra tabelas que não
suportam nada disso. Este post é sobre como ele funciona por baixo dos panos, o
parser que mentiu silenciosamente para nós, e por que depois arrancamos o modelo de
memória do mecanismo.
Não construa um mecanismo de query
A decisão central foi recusar escrever um executor relacional. As páginas do DynamoDB fazem streaming para dentro de um banco de dados SQLite embarcado, e o SQLite — um dos mecanismos de query mais testados do planeta — faz o trabalho relacional:
Cada item do DynamoDB cai no SQLite como seu envelope tipado bruto
({platform: {S: "ios"}}) em uma única coluna item. Uma pequena função definida
pelo usuário desembrulha os atributos em tempo de query, então seu
SELECT platform compila para uma chamada attr(item, 'platform', …). Um
detalhe nessa função é estrutural: ela envolve seu parsing de JSON em um catch,
porque o binding do SQLite transforma o erro de parse de uma linha malformada em um
abort da instrução preparada inteira. Sem o catch, uma linha corrompida mataria a
query.
A restrição honesta que mantivemos: a física do padrão de acesso do DynamoDB
ainda se aplica. O lado de destino de um JOIN precisa ser uma chave primária ou
uma chave de partição de — o mecanismo resolve joins por lookup de
chave contra o stream, nunca por scan de produto cartesiano. Se você precisa de
joins arbitrários em atributos que não são chave, isso é uma conversa de
modelagem, não um recurso do mecanismo de query.

O parser que mentiu para nós
A primeira versão usava um parser SQL popular de prateleira. Ao longo de algumas semanas, quatro bugs de correção independentes foram rastreados até ele:
SELECT DISTINCTera descartado silenciosamente. O parser o aceitava e o emissor o ignorava — toda linha duplicada voltava. O relato de bug de um usuário dizia: "mostra uma tabela de todos os valores, não contadores."- Qualquer coisa não tratada virava o
NULLliteral do SQL.CASE,CAST, subqueries escalares — o fallback do emissor para um nó de sintaxe desconhecido erareturn 'NULL'. As queries rodavam, retornavam respostas erradas com confiança e não levantavam nenhum erro. - O case dos identificadores não se comportava como SQL.
SELECT PLATFORM FROM tretornava nulos quando o atributo eraplatform— o oposto do que todo banco de dados SQL te ensina a esperar. - O autocompletar discordava do resolver, sugerindo nomes que o compilador então falharia em resolver.
O substituto, sql-parser-cst, venceu por uma primitiva que a pesquisa não
encontrou em nenhum outro lugar: sua árvore preserva tanto o texto bruto quanto
o nome normalizado de cada identificador — então o compilador consegue
distinguir platform de "PLATFORM". Essa única distinção permite que uma
gramática carregue a semântica ANSI adequada: identificadores sem aspas resolvem de
forma insensível a maiúsculas/minúsculas, os com aspas são a saída de emergência
sensível a maiúsculas/minúsculas, exatamente como o Postgres ou o SQLite. Ela
também carrega um intervalo de origem em cada nó, então um diagnóstico sublinha a
construção problemática em vez da instrução inteira.
E substituímos o fallback return 'NULL' por uma regra que agora tratamos como
política: nenhum NULL silencioso, nunca. O braço padrão do emissor é um erro
com intervalo preciso. Funções de janela recebem uma mensagem que diz por quê, não
apenas não:
Window functions are not supported in Workbench.
They have undefined semantics on the joined-row materialization.A troca que aceitamos: uma dependência pré-1.0, fixada na versão exata, atualizada apenas manualmente com a suíte de testes como gate. Avaliamos corrigir o parser antigo (ele precisava de um pré-tokenizador paralelo — fazendo parse de cada entrada duas vezes), usar a árvore de sintaxe do editor (a nível de token, sem estrutura de cláusula) e escrever à mão (a maior parte de um parser SQLite, manutenção ilimitada). Uma falha de correção em quatro frentes supera todas essas preocupações.
Saber o que não bloquear
Um diagnóstico deliberadamente não te impede. Quando um filtro referencia um atributo cujo case o schema não consegue confirmar, não temos como saber o case canônico — os filtros do lado do servidor do DynamoDB são sensíveis a maiúsculas/minúsculas e seu schema só declara atributos de chave. Isso emite um aviso, e avisos nunca desativam o Run. Aprendemos essa classe de lição do jeito difícil no nosso trabalho com validadores: um gate que rejeita o formato de query legítimo mais comum é pior que nenhum gate.
O limite tinha o formato errado
O primeiro mecanismo de agregação bufferizava as linhas escaneadas em um SQLite em memória com um limite de tamanho — 64 MB aqui, 250 MB ali, e um caminho sem limite que podia estourar a memória (OOM) do app em uma tabela grande. Atinja o limite e você recebia uma resposta parcial com uma flag de overflow.
O redesign começou com um reenquadramento: o concorrente é o script descartável que um desenvolvedor escreveria no lugar. E um script competente não bufferiza-e-limita — ele faz streaming, dobrando cada página em um total corrente com memória limitada. O limite em memória não era pequeno demais; ele tinha o formato errado. Pior, um agregado parcial não é "incompleto" — ele é errado. O momento que tornou isso concreto: nossa própria demo de IA narrou com confiança um ranking parcial de "maiores gastadores" como fato.
O mecanismo Compute reconstruído funciona do jeito que o script funciona, mais tudo aquilo com que o script nunca se importa:
- Um planejador classifica cada query em uma de três vias: stream-fold
(agregados algébricos simples —
COUNT/SUM/AVG/MIN/MAX— dobrados página por página fora do processo principal; o tamanho nunca barra uma query dobrável, só a deixa mais demorada), materialize (ordenações, joins limitados por chave — despejados em SQLite apoiado em disco, sem limite de memória), ou uma recusa honesta que recomenda a ferramenta pesada certa (a exportação para S3 do DynamoDB mais o Athena) para a cauda que não conseguimos atender. - A classificação de fold é deliberadamente estreita. Um
HAVING, umORDER BY, uma coluna solta ao lado do agregado — qualquer um deles força a via materialize. Um fold que ignorasse uma cláusula e ainda reportasse "exato" seria precisamente o bug errado-mas-confiante que este planejador existe para evitar. - A exatidão faz parte do resultado. As colunas de agregado mostram um badge de
parcial enquanto as páginas ainda estão em streaming, e só o largam quando o
scan se esgota. Até a honestidade aritmética é rastreada: somas de inteiros
permanecem exatas além de 2⁵³ (armazenadas como inteiros de 64 bits), enquanto
uma soma fracionária que excede a mantissa do float se sinaliza como
partial: precisionem vez de arredondar silenciosamente. - Fusíveis substituem popups para máquinas. O app interativo pergunta antes de um scan de cinco milhões de itens; o assistente de IA e os chamadores MCP recebem, em vez disso, orçamentos de scan e aborts em fronteiras de página. E o envelope de resultado que o modelo vê é deliberadamente enxugado — a classe de custo e as estimativas de tamanho são omitidas, porque expor um palpite desatualizado convida o modelo a narrá-lo como fato.
Uma nota de custo que surpreendeu as pessoas internamente: para um agregado pontual, um scan ao vivo é cerca de 6× mais barato que gerar uma exportação DynamoDB→S3 — e é por isso que a via em formato de script é o padrão e o data warehouse é a recomendação para analytics pesado e repetido, não o reflexo. (A calculadora de preços vai precificar uma passada completa da sua própria tabela.)
É seguro apontar SQL para produção?
O Workbench é estruturalmente somente leitura. O SQL final que chega ao mecanismo
local passa por um gate de defesa em profundidade que tokeniza e rejeita qualquer
coisa que não seja um único SELECT — como uma violação de contrato, não um erro
de usuário, porque o compilador nunca deveria tê-lo produzido. O substrato SQLite
roda com os built-ins perigosos desativados, e as escritas no DynamoDB têm
exatamente um caminho no app inteiro: a área de staging revisável,
que o SQL não consegue alcançar.
O que se transfere se você está construindo um
- Não escreva um mecanismo de query; escreva um compilador fiel para um que já existe. Seus bugs vão viver nas junções (case de identificador, envelopes de tipo, linhas corrompidas), não no algoritmo de join.
- Exija proveniência de aspas do seu parser. Se ele não consegue distinguir
namede"NAME", você não consegue implementar a semântica de case do SQL, e seus usuários vão descobrir antes de você. - Faça da "sintaxe não tratada" um erro barulhento, nunca um valor padrão.
NULLsilencioso é como respostas erradas e confiantes são entregues. - Compare seu design com o script descartável que seu usuário escreveria. Se o script faz streaming e você bufferiza, você construiu o formato errado.
- Trate agregados parciais como respostas erradas que precisam de rótulo, não como crédito parcial.
A documentação do Workbench cobre a superfície de recursos do
dia a dia, e SQL para DynamoDB mapeia o que funciona em
todo o ecossistema. Ou simplesmente baixe o DynoTable, abra uma aba do
Workbench com ⌘⌥Q, e rode um JOIN contra o banco de dados que não tem
um.


