

1/1/1970
Jak migrovat z Firestore na Postgres za 2 dny
Firebase. To jediné slovo vás může klidně stát 10 až 20krát víc, než kolik stojí levnější databáze, a může váš databázový design jak omezit, tak zkomplikovat.
Takže si dokážete představit, jak se mi ulevilo, když jsem dokončil migraci z Firestore za pouhé 2 dny. No, 3 dny, pokud počítáte čas, který jsem strávil stavbou prvotního „PostgresSynchronizeru". Nebo 3,5 dne, pokud počítáte čas strávený migrací workshopového systému. Tak jako tak, bylo to rychlé. Opravdu rychlé.
Můj původní odhad pro tento projekt byl celý měsíc práce! Ale díky pár skvělým LLM promptům a kamarádovi, který mě naučil o CDC, jsem to zvládl výrazně rychleji.
Pokud hledáte pub/sub, Firebase nebo Supabase mohou znít velmi lákavě. Koneckonců, není mnoho databází, které podporují pub/sub out of the box. Ale tato jednoduchá architektura níže je vše, co potřebujete pro většinu případů použití malých firem (a měla by se pěkně škálovat až k nějakým 10 až 100 tisícům operací za sekundu).
Než se ale dostaneme k detailům, jak pub/sub funguje, projdu kroky, které jsem postupně udělal.
Nejprve jsem si v Cursoru udělal prompt na sestavení PostgresSynchronizeru. Tato třída má funkci handleWrite, která přijme Firestore cestu, typ operace (create, update, delete) a data. Pak data zaupsertuje do Postgresu. Následně jsem obalil svou instanci Firestore store tak, aby jakékoliv změny ve Firestore zavolaly i handleWrite.
Tohle samozřejmě není 100% perfektní. Pokud se server odpojí nebo spadne, je možné, že několik drobných změn se neuloží. Ale pro případ Foony je to dostatečné a Postgres schéma nám dává lepší záruky integrity dat (např. foreign key constraints) než Firestore.
Dále jsem vytvořil a spustil backfill, který iteruje přes všechny Firestore kolekce a volá handleWrite pro každý dokument. Tím zajistíme, že dostaneme všechna historická data z Firestore. Kvůli rychlosti jsem to omezil pouze na registrované uživatele (promiňte, hosti, měli jste se zaregistrovat). Tohle funguje dobře a je bezpečné to spustit vícekrát.
Teď, když jsem měl způsob, jak naplnit data v Postgresu a udržet je (většinou) v synchronizaci s Firestore, jsem mohl řešit ten velký, strašidelný problém: pub/sub.
Jak se sakra dělá pub/sub s Postgresem? Nebo s jakoukoliv jinou SQL databází?
Postgres Change Data Capture (CDC) na záchranu!
CDC je honosné slovo, které jen znamená „čti změny provedené v databázi a publikuj je do pub/sub systému". Ve velké firmě byste možná použili něco jako Debezium + Kafka. Ale Kafka je opruz na nasazení, my už máme Redis a naše databáze dostává jen asi 30 operací za sekundu. I kdybychom 100x zvýšili náš rozsah na ~100 000 souběžných uživatelů, pravděpodobně to stále zvládneme v jediné instanci Redis. Takže přesně to jsem udělal.
(Pub/sub na Postgres si můžete přilepit i pomocí LISTEN/NOTIFY, ale to nepřežije reconnect a sesype se, jakmile skutečně potřebujete fanout. CDC je nudná, odolná odpověď.)
Můj kamarád Eric mě naučil o CDC a otevřel zdrojový kód svého CDC kódu, který čte Postgres WAL (Write-Ahead Log, místo, kam Postgres zapisuje každou změnu, kterou v databázi udělá) přes logical replication slot a uchovává ho do sinku (např. Redis).
S jednoduchým, detailním promptem v Cursoru se mi povedlo většinou na jednou napsat realtime CDC a gateway kód (s drobnými úpravami a důkladným code review). Tohle zahrnuje dvě velmi jednoduché služby:
- singleton CDC službu, která čte WAL a publikuje ho do Redis Pub/Sub (lze získat větší odolnost pomocí Streams, ale za cenu složitosti)
- horizontálně autoscalovanou gateway službu, která zpracovává JWT autorizaci a websocket připojení od webových klientů.
Obě služby jsou v Go a obzvlášť mám rád tu autoscalovanou gateway část. Webový klient se přihlašuje k odběru stejně jako dřív s Firestore a dostává data zpět ve stejném formátu. Klient vidí „usersPublic/" a „usersPrivate/" jako dvě samostatné kolekce se samostatnými oprávněními. Gateway je zodpovědná za překlad tohoto požadavku na základní Postgres tabulky (v tomto případě users) a za ověření, že klient má oprávnění k těmto datům.
Funguje to šokujícně dobře. Celý pub/sub systém nás stojí jen asi 0,50 USD za měsíc v compute + egress nákladech a nemá moc pohyblivých částí, které by se mohly rozbít (CDC, gateway a klientský kód jsou všechny jednoduché bez závislostí třetích stran mimo Redis / Postgres).
Z pohledu DevEx je nový systém pravděpodobně i jednodušší: vývojáři nemusí znát modelování dat ve Firestore ani Firestore security rules. Pokud rozumějí SQL, mohou rychle udělat úpravu v našem schema.sql, přidat nebo upravit route v all.go a jsou hotovi. Dokumentace je také dost dobrá na to, aby ji LLM snadno následovaly a dělaly změny. Z bezpečnostního hlediska je to také výhra, protože už nejsme vystaveni nebezpečnému denial-of-wallet útoku (Wallet DoS), protože naše nová infrastruktura má fixní cenu a je šíleně levnější.
Celkově, včetně Postgres instance a denních S3 záloh, se nám podařilo přejít z 550 USD měsíčně na pouhých 40 USD měsíčně v nákladech na databázovou infrastrukturu. Také jsme snížili velikost klientského bundle o ~100 kB, což je hezké.
Teď, když jsem měl nový systém, jsem testoval lokálně, aby vše fungovalo dle očekávání. Pak jsem ho nasadil naživo. Kdyby byly nějaké problémy, je snadné klienta vrátit zpět, protože Firestore je v tomto okamžiku stále zdrojem pravdy.
Jdeme naživo
Přepnutí serveru bylo trochu strašidelné (migrace databází jsou vždy). Až do tohoto bodu byl Firestore stále zdrojem pravdy. CDC pub/sub systém jen četl z Postgres mirroru, který synchronizer udržoval aktuální. Nasazení nových herních serverů by ve skutečnosti přepnulo Postgres na autoritativní pro zápisy.
Takhle jsem to rozjel:
- Aktualizoval jsem partnery jako první. Foony je integrované s partnery jako FRVR, takže jsme nasadili nový klientský build pár dní předem, aby používal naši novou CDC gateway.
- Varoval jsem hráče. Asi deset minut před přepnutím jsme zveřejnili upozornění na (snad) krátkou údržbu.
- Vzal jsem čerstvou zálohu databáze. Tohle je vždy dobrý nápad u velkých migrací databází. LLM tomu říkají „belt and suspenders", což v podstatě znamená být extra opatrný a mít záložní plán.
- Nasadil jsem oba clustery současně. Žádný blue/green tentokrát. Chtěl jsem se ujistit, že všechny clustery zapisují do stejné databáze, abych se vyhnul potenciálním nekonzistencím.
Měl jsem také připravený rollback plán, který byl velmi jednoduchý: pokud by se cokoliv pokazilo, znovu bych nasadil předchozího klienta (který stále četl z Firestore), znovu nasadil servery, restartoval backfill a zkusil to později. Tohle by způsobilo asi 5 minut výpadku, dokud by se servery znovu nenasadily, a asi půl dne, než by se Postgres databáze dohnala zpět k Firestore.
Samotné přepnutí trvalo jen asi 1 minutu výpadku. Jediný vážný bug, který se objevil, byl při aktualizaci zkušeností: Postgres LOWER() na sloupci bigint implicitně přetypovával na text. Sigh. Tohle byla dostatečně snadná oprava jednoduchým přetypováním zpět na bigint, takže jsem migraci pokračoval vydáním dalšího deploye serveru.
Byl jsem v šoku, že vše probíhalo hladce. Bylo velmi málo lidí, kteří hlásili problémy, a ty byly všechny velmi drobné (kromě toho s expírijí). Co mě obzvlášť šokovalo, je, jak hladce tato migrace proběhla, vzhledem k tomu, že byla většinově napsaná stylem vibe-codingu. Docela jiné než strašidelné příběhy, které byste mohli číst ve zprávách.
Přepis Workshopu
Když byl Postgres autoritativní pro zápisy, zůstal v kódu jen jeden velký kus zapletený ve Firestore: systém workshopu. Hráči ho používají ke sdílení vlastních map v Dino-Might Bomber Online, seznamů slov v Draw & Guess a tak dále. Něco jako Steam Workshop, ale pro naše hry. Byla to nejvíce s Firestore propletená funkce, která zbývala, jak na klientovi, tak na serveru, a měla nepříjemnou strukturu kvůli omezením datového modelu Firestore, kterou jsem musel zjednodušit.
Začal jsem tím, že jsem dal Cursoru tento prompt (s použitím Opus 4.7 high v 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`, ...).
S tím, co jsem viděl ze schopností Opus 4.7 v rámci Cursoru, jsem očekával, že tato migrace skončí přes noc.
Velká věc, kterou jsem v mém původním promptu vynechal a kterou si agent vyžádal upřesnit, bylo, jak bychom měli efektivně řešit řazení „Poslední den", „Poslední týden", „Poslední měsíc" a „Vše". Agent tlačil na přístup s timestamp bucketingem (nebo úplné vynechání řazení podle času), ale spoluzakladatel trval na zachování časového řazení navzdory jeho přidané složitosti.
Po nějakém přemýšlení jsem přišel s jednoduchým řešením zahrnujícím decay faktor. Každá položka workshopu má sloupce played_count_day, played_count_week, played_count_month a played_count_all a hodinová cron úloha násobí rolovací sloupce hodnotami 23/24, 167/168 a (720-1)/720. V kombinaci s parciálními indexy (WHERE private = false AND played_count_day >= 0.368) na každé řadicí ose nám to dává levné jako lůj dotazy „nejoblíbenější za časové období" bez jakékoliv extra infrastruktury. Tohle je něco, co bych s Firestore neudělal kvůli jeho přemrštěným nákladům na čtení a zápisy, ale v Postgresu je to v podstatě zadarmo.
S tím jsem šel spát. Probudil jsem se a nadšeně zkontroloval jeho práci! Agent přidal nové tabulky workshop_items, workshop_item_votes a user_subscriptions, propojil klienta na čtení jednotlivých položek přes CDC gateway (realtime.use('workshopItems/{id}')) a přepsal všech šest workshopových akcí na backendu, aby mluvily s Postgresem přímo. Bylo pár volných konců, které jsem musel uklidit (jeden dotaz do Firestore na serveru, chyba s backfillem způsobená chybějícími daty ve Firestore atd.), ale celkově byl kód téměř perfektní.
Po spuštění backfillu a otestování, že vše funguje lokálně, jsem nasadil změnu naživo. Tím se kódová báze konečně osvobodila od Firestore. Krása.
Budoucí práce
Do budoucna bych také rád podporoval patche v gateway. Právě teď gateway pošle zpět celý dokument jako JSON pro každý update. Je to trochu plýtvavé, ale náš egress je díky Hetzneru prakticky neomezený. Implementoval bych to teď, ale zatím nedokážu ospravedlnit přidanou složitost.