

1/1/1970
Como migrar do Firestore para o Postgres em 2 dias
Firebase. Essa palavrinha consegue facilmente custar de 10 a 20 vezes mais do que um banco de dados mais barato, e ainda pode limitar e complicar o design do seu banco de dados.
Então dá pra imaginar o alívio que senti ao terminar a migração do Firestore em apenas 2 dias. Bom, 3 dias se contarmos o tempo que passei construindo meu "PostgresSynchronizer" inicial. Ou 3,5 dias se contarmos o tempo migrando o sistema de workshop. De qualquer forma, foi rápido. Bem rápido mesmo.
Minha estimativa inicial para esse projeto era um mês inteiro de trabalho! Mas, graças a alguns prompts incríveis para LLM e a um amigo que me ensinou sobre CDC, consegui terminar tudo bem mais rápido.
Se você está procurando pub/sub, Firebase ou Supabase podem parecer bem tentadores. Afinal, poucos bancos de dados suportam pub/sub nativamente. Mas essa arquitetura simples abaixo é tudo o que você precisa para a maioria dos casos de uso de pequenas empresas (e deve escalar bem até cerca de 10 mil a 100 mil ops / segundo).
Antes de mergulharmos nos detalhes de como o pub/sub funciona, vou explicar os passos que segui em ordem.
Primeiro, criei um prompt no Cursor para construir um PostgresSynchronizer. Essa classe tem uma função, handleWrite, que recebe um caminho do Firestore, o tipo de operação (criar, atualizar, excluir) e os dados. Em seguida, faz um upsert dos dados no Postgres. Depois envolvi minha instância do store do Firestore para que qualquer alteração no Firestore também chamasse o handleWrite.
Isso não é 100% perfeito, claro. Se o servidor desconectar ou travar, é possível que algumas pequenas alterações não sejam persistidas. Mas é o suficiente para o caso de uso da Foony, e o schema do Postgres nos dá melhores garantias de integridade dos dados (por exemplo, restrições de chave estrangeira) do que o Firestore.
Em seguida, criei e executei um backfill que itera por todas as coleções do Firestore e chama handleWrite para cada documento. Isso garante que peguemos todos os dados históricos do Firestore. Por questão de velocidade, limitei isso apenas a usuários registrados (foi mal, visitantes, era pra terem se cadastrado). Funciona muito bem e é seguro executar várias vezes.
Agora que eu tinha uma forma de popular os dados no Postgres e mantê-los (em sua maior parte) sincronizados com o Firestore, pude enfrentar o grande e assustador problema: pub/sub.
Como é que você faz pub/sub com Postgres? Ou com qualquer banco de dados SQL?
Postgres Change Data Capture (CDC) ao resgate!
CDC é um termo chique que significa apenas "ler as alterações feitas no banco de dados e publicá-las em um sistema pub/sub". Em uma grande empresa, você pode usar algo como Debezium + Kafka. Mas Kafka é chato de configurar, já temos Redis em uso, e nosso banco de dados recebe apenas cerca de 30 ops / segundo. Mesmo que aumentássemos nossa escala em 100 vezes para ~100 mil usuários simultâneos, ainda caberíamos provavelmente em uma única instância de Redis. Então foi isso que eu fiz.
(Você também pode encaixar pub/sub no Postgres com LISTEN/NOTIFY, mas não sobrevive a reconexões e desmorona quando você realmente precisa de fanout. CDC é a resposta chata e durável.)
Meu amigo, Eric, me ensinou sobre CDC e abriu o código-fonte do seu CDC, que lê o WAL do Postgres (Write-Ahead Log, onde o Postgres registra cada alteração que faz no banco de dados) por meio de um slot de replicação lógica e o persiste em um sink (por exemplo, Redis).
Com um prompt simples e detalhado no Cursor, consegui acertar de primeira a maior parte do código de CDC em tempo real e do gateway (com alguns pequenos ajustes e uma revisão de código minuciosa). Isso inclui dois serviços bem simples:
- um serviço de CDC singleton que lê o WAL e publica no Redis Pub/Sub (dá pra ter mais durabilidade com Streams, mas ao custo de complexidade)
- um serviço de gateway com auto-escala horizontal que lida com autenticação JWT e conexões websocket de clientes web.
Ambos os serviços são em golang, e adoro especialmente a parte do gateway com auto-escala. O cliente web se inscreve da mesma forma que fazia antes com o Firestore, e recebe os dados no mesmo formato. O cliente vê "usersPublic/" e "usersPrivate/" como duas coleções separadas com permissões distintas. O gateway é responsável por traduzir essa requisição para as tabelas Postgres subjacentes (neste caso, users) e validar que o cliente tem permissão para aqueles dados.
Isso funciona surpreendentemente bem. O sistema pub/sub inteiro nos custa apenas cerca de US$ 0,50 / mês em computação + custos de egress, e não tem muitas partes móveis que possam quebrar (o CDC, o gateway e o código do cliente são todos simples, sem dependências de terceiros além de Redis / Postgres).
Do ponto de vista da experiência de desenvolvimento, o novo sistema é discutivelmente mais simples também: os desenvolvedores não precisam saber modelar dados no Firestore ou as regras de segurança do Firestore. Desde que entendam SQL, podem rapidamente editar nosso schema.sql, adicionar ou modificar uma rota em all.go, e estão prontos. A documentação também é robusta o suficiente para que LLMs sigam-na facilmente e façam alterações. Do ponto de vista da segurança, isso também é uma vitória, já que não estamos mais expostos a um perigoso ataque de "denial-of-wallet" (Wallet DoS), pois nossa nova infraestrutura tem custo fixo e é absurdamente mais barata.
No total, incluindo a instância do Postgres e backups diários no S3, conseguimos passar de US$ 550 / mês para apenas US$ 40 / mês em custos de infraestrutura de banco de dados. Também reduzimos o tamanho do bundle do cliente em ~100KB, o que é ótimo.
Agora que eu tinha o novo sistema, testei localmente para garantir que tudo estava funcionando como esperado. Depois, fiz o deploy ao vivo. Se houvesse qualquer problema, é fácil reverter o cliente, já que o Firestore ainda é a fonte da verdade neste ponto.
Indo ao ar
A virada do servidor foi meio assustadora (migrações de banco de dados sempre são). Até esse momento, o Firestore ainda era a fonte da verdade. O sistema de pub/sub do CDC só lia de um espelho do Postgres que o synchronizer mantinha atualizado. O deploy dos novos game servers é o que realmente faria o Postgres passar a ser autoritativo para escritas.
Veja como fiz o rollout:
- Atualizei os parceiros primeiro. A Foony é integrada com parceiros como a FRVR, então fizemos o deploy de uma nova build do cliente alguns dias antes que usaria nosso novo gateway de CDC.
- Avisei os jogadores. Cerca de dez minutos antes da virada, postamos um aviso sobre uma (esperançosamente) curta janela de manutenção.
- Fiz um backup fresco do banco de dados. Isso é sempre uma boa ideia em grandes migrações de banco de dados. As LLMs chamam isso de "cinto e suspensórios", o que basicamente significa ser extra cauteloso e ter um plano de contingência.
- Fiz o deploy dos dois clusters simultaneamente. Sem blue/green dessa vez. Eu queria garantir que todos os clusters estivessem escrevendo no mesmo banco de dados para evitar quaisquer inconsistências potenciais.
Eu também tinha um plano de rollback no lugar, que era bem simples: se alguma coisa desse errado, eu faria o redeploy do cliente anterior (que ainda lia do Firestore), redeploy dos servidores, reiniciaria o backfill e tentaria novamente mais tarde. Isso teria causado cerca de 5 minutos de downtime enquanto os servidores fazem redeploy, e cerca de meio dia para o banco Postgres voltar a estar atualizado com o Firestore.
A virada real só levou cerca de 1 minuto de downtime. O único bug sério que apareceu foi na atualização de experiência: o LOWER() do Postgres em uma coluna bigint estava fazendo cast implícito para text. Suspiro. Esse foi um ajuste fácil com um simples cast de volta para bigint, então continuei a migração disparando outro deploy do servidor.
Fiquei chocado que tudo parecia estar indo tranquilamente. Pouquíssimas pessoas relataram problemas, e todos eram bem pequenos (à parte do problema da experiência). O que foi especialmente chocante pra mim, porém, é o quanto essa migração foi tranquila considerando que foi majoritariamente vibe-coded. Bem diferente das histórias assustadoras que você pode ler nas notícias.
A reescrita do workshop
Com o Postgres agora autoritativo para escritas, só um grande pedaço da base de código ainda estava enrolado no Firestore: o sistema de workshop. Os jogadores o usam para compartilhar mapas personalizados em Dino-Might Bomber Online, listas de palavras em Draw & Guess, e por aí vai. Meio como o Steam Workshop, mas para nossos jogos. Era a funcionalidade mais amarrada ao Firestore que restava, tanto no cliente quanto no servidor, e tinha uma estrutura meio esquisita devido às limitações do modelo de dados do Firestore que eu precisava simplificar.
Para começar, dei ao Cursor esse prompt (usando o Opus 4.7 high em plan mode):
Now that we've finished most of the migration to Postgres, we have the last big part of the migration: rewriting the workshop system.
We'll need to update schema.sql to support the new workshop tables.
Think of the workshop sort of like Steam's workshop.
In other words, the workshop must:
- Support multiple games.
- Support different data formats (e.g. maps in dinomight, word lists in paintjob)
- Support favoriting
- Support like / dislike
- Support a description
Use best practices to ensure that the SQL is efficient.
After updating schema.sql, you will need to update both the backend and the frontend to use the new workshop system. You will also need to either use the `realtime.use` hook or the API for workshop info (up to you which you choose--realtime requires modifying all.go, API calls require updates to Action.ts, etc.). I'd probably go with the cdc gateway (realtime.use / all.go).
Keep your implementation simple where possible. At the end of this migration, we should no longer have `firestore.use` or any mention of firestore on the client. We should also no longer need firestore on the backend.
As part of this change, you will need to also create a backfill migration (there's already a migration backfill--just modify that to work with these firestore workshop collections) that migrates the firestore data to the postgres schema you decide on.
You shouldn't run the backfill--I will do that myself manually once you're done with your code. You can leave a call to the backfill (commented out) at the bottom of server/src/index.ts for me. The backfill should only handle migration of these remaining tables--it shouldn't do any backfilling of old tables that we've already migrated (e.g. no `userItems`, `usersPublic`, ...).
Com o que eu já tinha visto das capacidades do Opus 4.7 com o harness do Cursor, esperava que essa migração terminasse durante a noite.
A grande coisa que esqueci no meu prompt original, e que o agente me pediu para esclarecer, foi como deveríamos lidar eficientemente com a ordenação "Último Dia", "Última Semana", "Último Mês" e "Todos os Tempos". O agente estava insistindo em uma abordagem com buckets de timestamp (ou em descartar a ordenação por timestamp de vez), mas o co-fundador insistiu em manter a ordenação por tempo, apesar da complexidade adicional.
Depois de pensar um pouco, cheguei a uma solução simples envolvendo um fator de decaimento. Cada item de workshop tem colunas played_count_day, played_count_week, played_count_month e played_count_all, e um cron job de hora em hora multiplica as colunas rolantes por 23/24, 167/168 e (720-1)/720, respectivamente. Combinado com índices parciais (WHERE private = false AND played_count_day >= 0.368) em cada eixo de ordenação, isso nos dá consultas baratíssimas de "mais populares por período de tempo" sem qualquer infraestrutura extra. Isso é algo que eu não teria feito com Firestore devido a seus custos exorbitantes para leituras e escritas, mas é praticamente de graça no Postgres.
Com isso, fui dormir. Acordei e fui checar o trabalho do agente animado! Ele adicionou as novas tabelas workshop_items, workshop_item_votes e user_subscriptions, conectou o cliente para ler itens individuais através do gateway de CDC (realtime.use('workshopItems/{id}')) e reescreveu todas as seis ações do workshop no backend para falar diretamente com o Postgres. Havia algumas pontas soltas que tive que arrumar (uma consulta ao Firestore no servidor, um erro com o backfill causado por dados faltantes no Firestore, etc.), mas no geral o código estava quase perfeito.
Depois de rodar o backfill e testar para garantir que tudo funcionava localmente, fiz o deploy da mudança ao vivo. Com isso, a base de código finalmente ficou livre do Firestore. Lindo.
Trabalho futuro
No futuro, eu também gostaria de suportar patches no gateway. Agora, o gateway envia de volta o documento inteiro como JSON para cada atualização. É um pouco desperdício, mas nosso egress é efetivamente ilimitado, graças a estarmos na Hetzner. Eu implementaria isso agora, mas não consigo justificar a complexidade adicional ainda.