background blurbackground mobile blur

1/1/1970

Hoe je in 2 dagen migreert van Firestore naar Postgres

Firebase. Dat ene woord kan je makkelijk 10 tot 20 keer zoveel kosten als een goedkopere database, en kan het ontwerp van je database zowel beperken als compliceren.

Je kunt je dus voorstellen hoe opgelucht ik was toen ik in slechts 2 dagen klaar was met de migratie van Firestore. Nou ja, 3 dagen als je de tijd meetelt die ik kwijt was aan het bouwen van mijn eerste "PostgresSynchronizer". Of 3,5 dagen als je de tijd voor het migreren van het workshopsysteem meerekent. Hoe dan ook, het ging snel. Echt snel.

Mijn oorspronkelijke schatting voor dit project was een hele maand werk! Maar dankzij een paar geweldige LLM-prompts en een vriend die me over CDC leerde, kreeg ik het een stuk sneller voor elkaar.

Als je op zoek bent naar pub/sub, klinken Firebase of Supabase misschien erg verleidelijk. Niet veel databases ondersteunen immers pub/sub uit de doos. Maar de eenvoudige architectuur hieronder is alles wat je nodig hebt voor de meeste use-cases bij kleine bedrijven (en schaalt prima door tot ongeveer 10k-100k ops/seconde).

Foony's pub/sub-architectuur Postgres draait op een eigen Hetzner-server. De CDC- en Gateway-services draaien binnen een Hetzner k3s-cluster. CDC leest de Write-Ahead Log van Postgres en publiceert wijzigingen naar Redis Pub/Sub (een aparte Hetzner-server). De Gateway abonneert zich op Redis en verspreidt updates naar WebSocket-clients.

{/* Postgres host (left). Standalone Hetzner server. */} Postgres-server (Hetzner) Postgres WAL

{/* k3s cluster (middle). Contains the CDC and Gateway services as pods. */} k3s-cluster (Hetzner)

{/* CDC service (singleton). */} CDC (singleton)

{/* Gateway service. Two layered rects convey "horizontally autoscaling" multiple replicas. */} Gateway (autoscaling)

{/* Redis host (below k3s). Standalone Hetzner server. */} Redis-server (Hetzner) Redis Pub/Sub

{/* Data flow: Postgres WAL -> CDC. /} {/ CDC -> Redis (down through the bottom of the k3s frame). /} {/ Redis -> Gateway (back up into the k3s frame). */}

{/* WebSocket clients (outside everything). Three stacked cards convey fan-out. */} WebSocket-client

WebSocket-client WebSocket-client

{/* Gateway -> WebSocket clients (fan-out across the public internet). */}

Postgres WAL (Hetzner) → CDC (k3s) → Redis Pub/Sub (Hetzner) → Gateway (k3s) → WebSocket-clients

Voordat we ingaan op de details van hoe de pub/sub werkt, neem ik je eerst mee door de stappen die ik op volgorde heb genomen.

Allereerst maakte ik een prompt in Cursor om een PostgresSynchronizer te bouwen. Deze klasse heeft een functie, handleWrite, die een Firestore-pad, het type bewerking (create, update, delete) en de data ontvangt. Vervolgens doet hij een upsert van de data naar Postgres. Daarna heb ik mijn Firestore-store-instantie ingepakt zodat elke wijziging in Firestore ook handleWrite aanroept.

Dit is uiteraard niet 100% perfect: als de server de verbinding verliest of crasht, is het mogelijk dat een paar kleine wijzigingen niet worden opgeslagen. Maar voor Foony's use-case is dat prima, en het Postgres-schema geeft ons betere garanties voor data-integriteit (denk aan foreign key-constraints) dan Firestore.

Daarna heb ik een backfill gemaakt en uitgevoerd die alle Firestore-collecties doorloopt en voor elk document handleWrite aanroept. Zo zorgen we dat alle historische data uit Firestore wordt overgenomen. Voor de snelheid beperkte ik dit tot alleen geregistreerde gebruikers (sorry gasten, je had je moeten registreren). Dit werkt prima, en je kunt het veilig meerdere keren draaien.

Nu ik een manier had om de data in Postgres te vullen en (grotendeels) synchroon te houden met Firestore, kon ik het grote, enge probleem aanpakken: pub/sub.

Hoe doe je in vredesnaam pub/sub met Postgres? Of welke SQL-database dan ook?

Postgres Change Data Capture (CDC) to the rescue!

CDC is een chic woord dat simpelweg betekent: "lees de wijzigingen in de database en publiceer ze naar een pub/sub-systeem". Bij een groot bedrijf zou je iets als Debezium + Kafka gebruiken. Maar Kafka is een gedoe om op te zetten, we hebben Redis al draaien, en onze database krijgt slechts zo'n 30 ops/seconde. Zelfs als we onze schaal 100x zouden vergroten tot ~100.000 gelijktijdige gebruikers, zouden we dat waarschijnlijk nog steeds in één Redis-instance kwijt kunnen. En dat is precies wat ik heb gedaan.

(Je kunt pub/sub ook aan Postgres vastplakken met LISTEN/NOTIFY, maar dat overleeft geen reconnects en valt om zodra je echt fan-out nodig hebt. CDC is het saaie, duurzame antwoord.)

Mijn vriend Eric leerde me over CDC en heeft zijn CDC-code open source gemaakt. Die leest Postgres' WAL (Write-Ahead Log, waar Postgres elke wijziging in de database in vastlegt) via een logical replication slot, en schrijft die naar een sink (zoals Redis).

Met een eenvoudige, gedetailleerde prompt in Cursor kon ik de realtime CDC- en gateway-code grotendeels in één keer goed krijgen (met wat kleine aanpassingen en een grondige code review). Dit bestaat uit twee heel eenvoudige services:

  • een singleton CDC-service die de WAL leest en publiceert naar Redis Pub/Sub (je kunt meer duurzaamheid krijgen met Streams, maar dat kost wel complexiteit)
  • een horizontaal autoscaling gateway-service die JWT-authenticatie en websocket-verbindingen vanuit webclients afhandelt.

Beide services zijn in Go geschreven, en ik ben vooral gek op het autoscaling gateway-gedeelte. De webclient abonneert zich op dezelfde manier als voorheen met Firestore, en krijgt data in hetzelfde formaat terug. De client ziet "usersPublic/" en "usersPrivate/" als twee aparte collecties met aparte rechten. De gateway is verantwoordelijk voor het vertalen van die request naar de onderliggende Postgres-tabellen (in dit geval users), en voor het valideren dat de client toestemming heeft voor die data.

Dit werkt verbluffend goed. Het hele pub/sub-systeem kost ons slechts ongeveer $0,50/maand aan compute- en egress-kosten, en heeft weinig bewegende delen die kapot kunnen gaan (de CDC-, gateway- en clientcode zijn allemaal simpel zonder externe dependencies behalve Redis en Postgres).

Vanuit DevEx-perspectief is het nieuwe systeem aantoonbaar ook eenvoudiger: ontwikkelaars hoeven niet te weten hoe ze data modelleren in Firestore of hoe Firestore security rules werken. Zolang ze SQL begrijpen, kunnen ze snel een aanpassing doen in onze schema.sql, een route toevoegen of aanpassen in all.go, en klaar. De documentatie is ook sterk genoeg dat LLMs er makkelijk doorheen komen en wijzigingen kunnen maken. Vanuit beveiligingsperspectief is dit ook winst, omdat we niet langer kwetsbaar zijn voor een gevaarlijke denial-of-wallet-aanval (Wallet DoS), aangezien onze nieuwe infra een vaste kostprijs heeft en absurd veel goedkoper is.

Inclusief de Postgres-instance en dagelijkse S3-backups zijn we erin geslaagd om van $550/maand naar slechts $40/maand aan database-infrastructuurkosten te gaan. We hebben ook de client bundle size met ~100KB verkleind, wat lekker meegenomen is.

Nu ik het nieuwe systeem had, testte ik lokaal om er zeker van te zijn dat alles werkte zoals verwacht. Daarna heb ik het live gezet. Mochten er problemen zijn, dan was het eenvoudig om de client terug te draaien, aangezien Firestore op dit moment nog de bron van waarheid was.

Live gaan

De server-cutover was een beetje eng (databasemigraties zijn dat altijd). Tot op dit punt was Firestore nog steeds de bron van waarheid: het CDC-pub/sub-systeem las alleen uit een Postgres-mirror die de synchronizer up-to-date hield. Het uitrollen van de nieuwe gameservers was wat Postgres daadwerkelijk de autoriteit voor writes zou maken.

Zo heb ik het uitgerold:

  1. Eerst de partners geüpdatet. Foony is geïntegreerd met partners zoals FRVR, dus we hebben een paar dagen eerder een nieuwe clientbuild uitgerold die onze nieuwe CDC-gateway zou gebruiken.
  2. De spelers gewaarschuwd. Ongeveer tien minuten voor de cutover plaatsten we een melding over een (hopelijk) korte onderhoudsperiode.
  3. Een verse databasebackup gemaakt. Bij grote databasemigraties is dat altijd een goed idee. LLMs noemen dit "belt and suspenders", wat eigenlijk gewoon betekent dat je extra voorzichtig bent en een plan B hebt.
  4. Beide clusters tegelijk uitgerold. Geen blue/green deze keer. Ik wilde er zeker van zijn dat alle clusters naar dezelfde database schreven om mogelijke inconsistenties te voorkomen.

Ik had ook een rollback-plan, dat heel eenvoudig was: als er iets misging, zou ik de vorige client opnieuw uitrollen (die nog steeds naar Firestore las), de servers opnieuw uitrollen, de backfill herstarten en het later opnieuw proberen. Dit zou ongeveer 5 minuten downtime hebben gekost terwijl de servers opnieuw uitrollen, en ongeveer een halve dag om de Postgres-database weer bij Firestore in te halen.

De daadwerkelijke cutover kostte slechts ongeveer 1 minuut downtime. De enige serieuze bug die opdook zat in het updaten van experience: Postgres' LOWER() op een bigint-kolom castte impliciet naar text. Zucht. Dit was makkelijk genoeg op te lossen met een simpele cast terug naar bigint, dus ik zette de migratie voort door opnieuw een serverdeploy uit te voeren.

Ik was verbaasd dat alles soepel leek te verlopen. Maar weinig mensen meldden problemen, en die waren allemaal heel klein (afgezien van het experience-dingetje). Wat me vooral verbaasde, is hoe vlekkeloos deze migratie verliep, gezien het feit dat het grotendeels vibe-coded was. Heel anders dan de enge verhalen die je in het nieuws kunt lezen.

De Workshop-herschrijving

Nu Postgres de autoriteit was voor writes, zat er nog maar één groot stuk van de codebase verstrengeld met Firestore: het workshopsysteem. Spelers gebruiken het om custom maps te delen in Dino-Might Bomber Online, woordenlijsten in Draw & Guess, enzovoort. Een beetje zoals de Steam Workshop, maar dan voor onze games. Het was de meest met Firestore verweven feature die overbleef, zowel op de client als op de server, en het had een onhandige structuur door de beperkingen van Firestore's datamodel, die ik moest vereenvoudigen.

Om te beginnen gaf ik Cursor deze prompt (met Opus 4.7 high in 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`, ...).

Met wat ik gezien had van de capaciteiten van Opus 4.7 in combinatie met Cursor, verwachtte ik dat deze migratie 's nachts klaar zou zijn.

Het grote ding dat ik in mijn oorspronkelijke prompt over het hoofd had gezien, en waar de agent om verheldering vroeg, was hoe we efficiënt moesten omgaan met sortering op "Last Day", "Last Week", "Last Month" en "All Time". De agent wilde graag een aanpak met timestamp-buckets (of helemaal stoppen met sorteren op tijd), maar de co-founder stond erop om tijdgebaseerd sorteren te behouden, ondanks de extra complexiteit.

Na wat nadenken kwam ik op een eenvoudige oplossing met een decay-factor. Elk workshop-item heeft de kolommen played_count_day, played_count_week, played_count_month en played_count_all, en een uurlijkse cronjob vermenigvuldigt de rollende kolommen respectievelijk met 23/24, 167/168 en (720-1)/720. Gecombineerd met partial indexes (WHERE private = false AND played_count_day >= 0.368) op elke sorteer-as, geeft dit ons spotgoedkope queries voor "populairste per tijdsperiode", zonder extra infrastructuur. Dit zou ik met Firestore niet hebben gedaan vanwege de absurde kosten voor reads en writes, maar in Postgres is het praktisch gratis.

Daarmee ging ik naar bed. Ik werd wakker en checkte enthousiast het werk! De agent had de nieuwe tabellen workshop_items, workshop_item_votes en user_subscriptions toegevoegd, de client aangesloten om individuele items via de CDC-gateway te lezen (realtime.use('workshopItems/{id}')), en alle zes workshop-actions op de backend herschreven zodat ze direct met Postgres praten. Er waren nog wat losse eindjes die ik moest opruimen (een query naar Firestore op de server, een fout in de backfill door ontbrekende data in Firestore, enzovoort), maar over het geheel was de code bijna perfect.

Na het draaien van de backfill en lokaal testen of alles werkte, heb ik de wijziging live gezet. Daarmee was de codebase eindelijk Firestore-vrij. Prachtig.

Toekomstig werk

In de toekomst zou ik ook patches in de gateway willen ondersteunen. Op dit moment stuurt de gateway het volledige document als JSON terug bij elke update. Dat is een beetje verspilling, maar onze egress is praktisch onbeperkt omdat we op Hetzner zitten. Ik zou dit nu implementeren, maar de extra complexiteit kan ik op dit moment nog niet rechtvaardigen.

8 Ball Pool online multiplayer billiards icon