Dados geográficos
Substituir consultas geoespaciais por ficheiros JSON e Cloudflare R2
Uma grelha geográfica comum, JSON estático no Cloudflare R2 e uma cache que distingue dados em falta de um mapa vazio.
Atualizado em
Neste artigo
Cada utilizador 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 partilhada 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, a app pedia ao backend os postos próximos e a contagem de marcas por país.
O percurso atual lê ficheiros JSON do Cloudflare R2 através de um domínio público próprio. O Supabase continua a tratar 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 a base de dados responder a cada pesquisa de preços feita por um telemóvel.
Poderia ter publicado um ficheiro por país e deixado a app filtrar. Seria simples, mas procurar nas proximidades não devia obrigar a descarregar todos os postos de um país. Os dados precisavam de uma divisão ajustada à utilização do ecrã.
Uma grelha partilhada pelo publicador e pelo telemóvel
A divisão usa uma grelha 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 factos sobre os postos: coordenadas, identificadores, moradas, preços, horários quando disponíveis e datas de atualização das fontes. Não contém a distância ao utilizador. 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, a 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 ficheiros que a dimensão da grelha se justifica. Uma célula de meio grau tem cerca de 56 km de altura e uma largura inferior nas latitudes cobertas. O raio predefinido 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 numa célula de 56 km» parece razoável até se desenhar o círculo.
Uma grelha mais larga reduziria o número máximo de pedidos, mas aumentaria os ficheiros da pesquisa habitual. A grelha 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 grelha 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 grelha, 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 na base de dados.
Depois de ler os ficheiros, 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
A cache das células considera os dados válidos durante seis horas. Conserva dados processados em memória, persiste cópias para arranques posteriores e mantém uma única promise em curso por chave. Dois consumidores que pedem a mesma célula em simultâneo partilham 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 da 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-se 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 guardada como se respondesse à pergunta completa.
stationsAround devolve uma falha se alguma célula necessária falhar. A camada superior pode recorrer à cache da pesquisa anterior ou explicar que não conseguiu obter os dados. Não transforma uma parcela em falta num 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 transformar-se-ia numa 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 ficheiros do projeto, pelo que não instala a árvore de dependências da app móvel. Produz uma pasta; o envio é outro passo do workflow.
Os ficheiros 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 noutro dia. Os objetos indicam uma cache pública de uma hora.
As células são enviadas antes do índice. Publicar primeiro o índice anunciaria chaves novas cujos ficheiros uma transferência falhada poderia nunca disponibilizar.
Esta ordem não torna o deploy atómico. Os ficheiros 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 em falta. Um conjunto que exigisse um snapshot consistente precisaria de caminhos por geração e de um apontador atualizado apenas depois de todos os ficheiros 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 preçário do R2, e a configuração da cache importa. Um domínio público próprio pode usar a 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 da base 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 ficheiro em vez de uma linha não altera nenhum desses factos.
Esta solução funciona porque os dados são públicos, partilhados, geograficamente limitados e atualizados muito menos vezes do que são lidos. Escolheria outra arquitetura para dados privados, autorizações por utilizador ou preços em tempo real cuja atualidade dependesse de cada transação.
Neste caso, o telemóvel já conhecia a localização, o raio e os filtros. Receber uma parcela local reutilizável permitiu-lhe 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. A app iOS permite experimentá-lo perto dos seus percursos.