Dados geográficos
Substituir consultas geoespaciais por arquivos JSON e Cloudflare R2
Uma grade geográfica comum, JSON estático no Cloudflare R2 e um cache que distingue dados ausentes de um mapa vazio.
Atualizado em
Neste artigo
Cada usuário do OdoKeep que procurava preços de combustível nas proximidades fazia uma pergunta diferente aos mesmos dados.
Mudava a localização, mudava o raio, mudava o combustível. A lista de postos era compartilhada por todos e reconstruída diariamente.
Isso bastou para reconsiderar onde a consulta devia acontecer.
O OdoKeep é um diário de bordo em React Native. A funcionalidade de preços de combustível consome fontes nacionais de Portugal, Espanha, França, Itália e Áustria. O módulo de células geográficas documenta um conjunto de trabalho de cerca de 46 000 postos, embora o número varie com as fontes. Originalmente, o app pedia ao backend os postos próximos e a contagem de marcas por país.
O percurso atual lê arquivos JSON do Cloudflare R2 através de um domínio público próprio. O Supabase continua cuidando dos dados privados da garagem. O processo de publicação também lê os metadados de atribuição das fontes e comunica o seu estado. O que desapareceu foi a necessidade de o banco de dados responder a cada pesquisa de preços feita por um celular.
Poderia ter publicado um arquivo por país e deixado o app filtrar. Seria simples, mas procurar nas proximidades não devia obrigar a baixar todos os postos de um país. Os dados precisavam de uma divisão ajustada à utilização da tela.
Uma grade compartilhada pelo publicador e pelo celular
A divisão usa uma grade de meio grau. lib/fuel-prices/tiles.ts contém a aritmética usada tanto pelo publicador como pelo cliente:
const TILE_DEGREES = 0.5;
function tileKey(lat: number, lon: number): string {
return `${Math.floor(lat / TILE_DEGREES)}_${Math.floor(lon / TILE_DEGREES)}`;
}
Lisboa, em 38.72, -9.14, fica na célula 77_-19. A coordenada negativa torna Math.floor importante: truncar colocaria pontos a oeste de Greenwich na célula errada.
A estrutura publicada é suficientemente pequena para ser descrita sem uma especificação de API:
fuel/
index.json
sources.json
brands/
PT.json
ES.json
...
tiles/
77_-19.json
77_-18.json
...
Uma célula contém fatos sobre os postos: coordenadas, identificadores, endereços, preços, horários quando disponíveis e datas de atualização das fontes. Não contém a distância ao usuário. A distância pertence à pesquisa, não ao posto.
Obter um retângulo e depois filtrar por distância
Para uma pesquisa, o cliente calcula primeiro as células que cobrem o retângulo envolvente do círculo. A amplitude aproximada da latitude resulta da divisão do raio pelos quilômetros por grau. A amplitude da longitude aumenta em função do cosseno da latitude, porque os graus de longitude ficam mais estreitos para norte.
Pedir um retângulo é deliberadamente um pouco impreciso. Pode ser necessária uma célula de canto mesmo que nenhum dos seus postos fique dentro do círculo. Depois da leitura, o app calcula a distância pela fórmula de haversine, exclui os postos fora do raio e ordena os restantes. Mantém o raio da Terra usado pela consulta que substituiu.
É no número de arquivos que a dimensão da grade se justifica. Uma célula de meio grau tem cerca de 56 km de altura e uma largura inferior nas latitudes cobertas. O raio padrão de 10 km corresponde a 20 km de diâmetro. Os testes amostram a região documentada e verificam um máximo de quatro células para esse raio e de nove para o limite de 30 km.
É fácil esquecer o diâmetro nesta estimativa. “Uma pesquisa de 30 km cabe em uma célula de 56 km” parece razoável até se desenhar o círculo.
Uma grade mais larga reduziria o número máximo de pedidos, mas aumentaria os arquivos da pesquisa habitual. A grade escolhida aceita mais pedidos nos raios maiores para permitir parcelas menores nos raios pequenos.
O índice torna baratas as zonas vazias. Enumera apenas as células com postos e inclui a dimensão da grade e a data de construção. Uma pesquisa junto à costa pode excluir células conhecidas como vazias antes de as pedir. O cliente rejeita um índice construído com outra grade, em vez de usar chaves com um significado diferente.
As contagens por marca também são construídas uma vez por país. Mudar o filtro de marca deixa de exigir uma agregação nacional no banco de dados.
Depois de ler os arquivos, mudar o combustível, a marca ou o raio pode reutilizar os postos já disponíveis. A rede deixa de participar em cada ajuste da vista.
Vazio e falha são resultados diferentes
O cache das células considera os dados válidos durante seis horas. Conserva dados processados em memória, persiste cópias para inicializações posteriores e mantém uma única promise em curso por chave. Dois consumidores que pedem a mesma célula ao mesmo tempo compartilham o pedido. Se a rede falhar e existir uma cópia anterior utilizável, essa cópia ainda pode servir a pesquisa.
A alteração mais importante do cache foi distinguir três resultados:
type TileOutcome =
| { status: "ok"; stations: TileStation[] }
| { status: "empty" }
| { status: "failed" };
Uma célula vazia e uma célula que não foi possível obter são provas diferentes.
Imagine uma pesquisa que atravessa duas células com postos. Uma responde normalmente; a outra devolve 503, sem cópia em cache utilizável. Devolver apenas os postos da primeira reduziria silenciosamente a área pesquisada. Essa lista poderia depois ser ordenada e salva como se respondesse à pergunta completa.
stationsAround devolve uma falha se alguma célula necessária falhar. A camada superior pode recorrer ao cache da pesquisa anterior ou explicar que não conseguiu obter os dados. Não transforma uma parcela ausente em um resultado novo e completo.
O tratamento atual de 404 é mais limitado: conta como vazio e fica memorizado durante cinco minutos. Esse prazo curto importa durante a publicação, quando uma célula pode estar temporariamente indisponível. O índice evita muitos pedidos desse tipo, mas um 404 não oferece a mesma certeza de um conjunto de dados versionado e internamente consistente.
Publicar conjuntos completos, com as células antes do índice
A publicação tem uma regra correspondente de completude. O construtor em TypeScript exige todos os países configurados e recusa publicar se algum adaptador falhar. Caso contrário, uma indisponibilidade da fonte de um país se transformaria em uma nova lista da qual esse país teria desaparecido. É preferível conservar a versão completa anterior a publicar essa omissão como dados recentes.
A GitHub Action agendada executa o construtor com Bun. Este percurso usa módulos nativos e arquivos do projeto, por isso não instala a árvore de dependências do app móvel. Produz uma pasta; o envio é outro passo do workflow.
Os arquivos JSON são comprimidos com gzip e servidos com a codificação correspondente. gzip -n retira a data do cabeçalho comprimido, evitando que os mesmos dados produzam bytes diferentes apenas por serem comprimidos em outro dia. Os objetos indicam um cache público de uma hora.
As células são enviadas antes do índice. Publicar primeiro o índice anunciaria chaves novas cujos arquivos uma transferência falhada poderia nunca disponibilizar.
Esta ordem não torna o deploy atômico. Os arquivos são substituídos no mesmo local, e o cliente pode observar uma mistura de versões. A limpeza também pode remover uma célula antiga enquanto o cliente ainda conserva o índice anterior. Para a consulta atual de preços, a implementação aceita essa consistência limitada e usa um prazo curto para células ausentes. Um conjunto que exigisse um snapshot consistente precisaria de caminhos por geração e de um ponteiro atualizado apenas depois de todos os arquivos estarem disponíveis.
Quando esta arquitetura faz sentido
O R2 é útil aqui porque o seu modelo de preços publicado não cobra largura de banda de saída para a Internet. O armazenamento e as operações continuam a fazer parte do tabela de preços do R2, e a configuração do cache importa. Um domínio público próprio pode usar o cache Cloudflare, como explica a documentação de buckets públicos. Não tenho uma fatura de antes e depois para apresentar. A alteração concreta é que as pesquisas de preços deixaram de consumir leituras e tráfego de saída do banco de dados.
Publicar diariamente também não torna todos os preços atuais ao minuto. A data de atualização da fonte é preservada separadamente, e a atribuição acompanha os dados. As fontes nacionais têm ritmos de atualização e condições de reutilização diferentes. Servir um arquivo em vez de uma linha não altera nenhum desses fatos.
Esta solução funciona porque os dados são públicos, compartilhados, geograficamente limitados e atualizados muito menos vezes do que são lidos. Escolheria outra arquitetura para dados privados, autorizações por usuário ou preços em tempo real cuja atualidade dependesse de cada transação.
Neste caso, o celular já conhecia a localização, o raio e os filtros. Receber uma parcela local reutilizável permitiu que ele pudesse terminar a pesquisa sem pedir repetidamente ao servidor que reencontrasse os mesmos postos.
O resultado pode ser visto no navegador de preços do OdoKeep, juntamente com o diário de bordo que serve. O app iOS permite experimentar esse recurso perto dos seus trajetos.