background blurbackground mobile blur

1/1/1970

Come migrare da Firestore a Postgres in 2 giorni

Firebase. Quella parolina può facilmente costarti 10-20 volte di più rispetto a un database più economico, e può sia limitare sia complicare il design del tuo database.

Quindi puoi immaginare il sollievo quando ho finito di migrare da Firestore in soli 2 giorni. Beh, 3 giorni se contiamo il tempo speso a costruire il mio "PostgresSynchronizer" iniziale. O 3 giorni e mezzo se contiamo il tempo speso a migrare il sistema del workshop. In ogni caso, è stato veloce. Velocissimo.

La mia stima iniziale per questo progetto era un mese intero di lavoro! Ma, grazie ad alcuni fantastici prompt LLM, e a un amico che mi ha parlato del CDC, sono riuscito a portarlo a termine molto più rapidamente.

Se stai cercando un sistema pub/sub, Firebase o Supabase possono sembrare molto allettanti. Dopotutto, non sono molti i database che supportano il pub/sub out of the box. Ma questa semplice architettura qui sotto è tutto ciò che serve per la maggior parte dei casi d'uso delle piccole imprese (e dovrebbe scalare bene fino a circa 10k-100k operazioni al secondo).

L'architettura pub/sub di Foony Postgres gira sul proprio server Hetzner. I servizi CDC e Gateway girano all'interno di un cluster k3s su Hetzner. Il CDC legge il Write-Ahead Log di Postgres e pubblica le modifiche su Redis Pub/Sub (un server Hetzner separato). Il Gateway si iscrive a Redis e distribuisce gli aggiornamenti ai client WebSocket.

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

{/* k3s cluster (middle). Contains the CDC and Gateway services as pods. */} Cluster k3s (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. */} Server Redis (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. */} Client WebSocket

Client WebSocket Client WebSocket

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

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

Prima di entrare nei dettagli di come funziona il pub/sub, però, ripercorriamo i passi che ho fatto in ordine.

Per prima cosa, ho creato un prompt in Cursor per costruire un PostgresSynchronizer. Questa classe ha una funzione, handleWrite, che prende un path Firestore, il tipo di operazione (create, update, delete) e i dati. Poi fa un upsert dei dati su Postgres. Ho quindi avvolto la mia istanza dello store Firestore in modo che qualsiasi modifica a Firestore chiamasse anche handleWrite.

Non è perfetto al 100%, ovviamente: se il server si disconnette o va in crash, è possibile che alcune piccole modifiche non vengano persistite. Ma è abbastanza buono per il caso d'uso di Foony, e lo schema Postgres ci dà migliori garanzie di integrità dei dati (ad es. vincoli di chiave esterna) rispetto a Firestore.

Successivamente, ho creato ed eseguito un backfill che itera su tutte le collection di Firestore e chiama handleWrite per ogni documento. Questo garantisce di ottenere tutti i dati storici da Firestore. Per velocità, l'ho limitato solo agli utenti registrati (scusate ospiti, avreste dovuto registrarvi). Funziona bene, ed è sicuro eseguirlo più volte.

Ora che avevo un modo per popolare i dati in Postgres e mantenerli (per lo più) sincronizzati con Firestore, ho potuto affrontare il grosso e spaventoso problema: il pub/sub.

Come diavolo si fa pub/sub con Postgres? O con un qualsiasi database SQL?

Postgres Change Data Capture (CDC) in soccorso!

CDC è un termine raffinato che significa semplicemente "leggere le modifiche fatte al database e pubblicarle su un sistema pub/sub". In una grande azienda, potresti usare qualcosa come Debezium + Kafka. Ma Kafka è una rogna da configurare, abbiamo già Redis a disposizione, e il nostro database riceve solo circa 30 operazioni al secondo. Anche se dovessimo aumentare la nostra scala di 100 volte fino a ~100.000 utenti concorrenti, probabilmente riusciremmo comunque a starci dentro con una singola istanza Redis. Quindi è quello che ho fatto.

(Puoi anche aggiungere il pub/sub a Postgres con LISTEN/NOTIFY, ma non sopravvive alle riconnessioni e crolla quando hai davvero bisogno di fanout. CDC è la risposta noiosa e duratura.)

Il mio amico, Eric, mi ha parlato del CDC e ha reso open source il suo codice CDC che legge il WAL di Postgres (Write-Ahead Log, dove Postgres scrive ogni modifica fatta al database) tramite uno slot di replica logica, e lo persiste su un sink (ad es. Redis).

Con un prompt semplice e dettagliato in Cursor, sono riuscito a generare quasi al primo colpo il codice CDC e gateway in tempo reale (con alcune piccole modifiche e una revisione approfondita del codice). Questo include due servizi molto semplici:

  • un servizio CDC singleton che legge il WAL e lo pubblica su Redis Pub/Sub (si può ottenere maggiore durabilità con gli Streams, ma a costo di maggiore complessità)
  • un servizio gateway con autoscaling orizzontale che gestisce l'autenticazione JWT e le connessioni websocket dai client web.

Entrambi i servizi sono in golang, e amo particolarmente la parte del gateway con autoscaling. Il client web si iscrive nello stesso modo in cui faceva con Firestore, e riceve i dati nello stesso formato. Il client vede "usersPublic/" e "usersPrivate/" come due collection separate con permessi separati. Il gateway è responsabile di tradurre quella richiesta nelle tabelle Postgres sottostanti (in questo caso users), e di validare che il client abbia il permesso per quei dati.

Questo funziona incredibilmente bene. L'intero sistema pub/sub ci costa solo circa 0,50 $ al mese in costi di compute + egress, e non ha molte parti mobili che possono rompersi (il codice CDC, gateway e client sono tutti semplici e senza dipendenze di terze parti al di fuori di Redis / Postgres).

Da una prospettiva DevEx, il nuovo sistema è probabilmente anche più semplice: gli sviluppatori non devono sapere come modellare i dati in Firestore o le regole di sicurezza di Firestore. Finché capiscono SQL, possono fare velocemente una modifica al nostro schema.sql, aggiungere o modificare una route in all.go, e sono a posto. Anche la documentazione è abbastanza solida da permettere agli LLM di seguirla facilmente e fare modifiche. Da una prospettiva di sicurezza, è anche un vantaggio perché non siamo più esposti a un pericoloso attacco di denial-of-wallet (Wallet DoS), dato che la nostra nuova infrastruttura è a costo fisso e incredibilmente più economica.

Nel complesso, inclusa l'istanza Postgres e i backup giornalieri su S3, siamo riusciti a passare da 550 $ al mese a soli 40 $ al mese in costi di infrastruttura database. Abbiamo anche ridotto la dimensione del bundle client di ~100KB, che è una cosa carina.

Ora che avevo il nuovo sistema, ho testato in locale per assicurarmi che tutto funzionasse come previsto. Poi, l'ho messo in produzione. Se ci fossero stati problemi, sarebbe stato facile fare il rollback del client dato che Firestore era ancora la fonte di verità a quel punto.

Passaggio in produzione

Il cutover del server è stato un po' spaventoso (le migrazioni di database lo sono sempre). Fino a quel punto, Firestore era ancora la fonte di verità: il sistema pub/sub CDC stava solo leggendo da un mirror Postgres che il synchronizer manteneva aggiornato. Il deploy dei nuovi game server è quello che avrebbe effettivamente reso Postgres autorevole per le scritture.

Ecco come ho portato avanti il rollout:

  1. Prima aggiornamento dei partner. Foony è integrato con partner come FRVR, quindi abbiamo distribuito una nuova build del client qualche giorno prima che usasse il nostro nuovo gateway CDC.
  2. Avviso ai giocatori. Circa dieci minuti prima del cutover, abbiamo pubblicato un avviso su una (si spera) breve finestra di manutenzione.
  3. Backup fresco del database. È sempre una buona idea con migrazioni di grandi database. Gli LLM lo chiamano "cintura e bretelle", che significa essenzialmente essere extra cauti e avere un piano di backup.
  4. Deploy di entrambi i cluster simultaneamente. Niente blue/green stavolta. Volevo essere sicuro che tutti i cluster scrivessero sullo stesso database per evitare potenziali inconsistenze.

Avevo anche un piano di rollback pronto, molto semplice: se qualcosa fosse andato storto, avrei ridistribuito il client precedente (che leggeva ancora da Firestore), ridistribuito i server, riavviato il backfill, e riprovato più tardi. Questo avrebbe causato circa 5 minuti di downtime mentre i server si ridistribuivano, e circa mezza giornata per riallineare il database Postgres a Firestore.

Il cutover effettivo ha richiesto solo circa 1 minuto di downtime. L'unico bug serio che è emerso era nell'aggiornamento dell'esperienza: LOWER() di Postgres su una colonna bigint faceva un cast implicito a text. Sospiro. È stato abbastanza facile da risolvere con un semplice cast di ritorno a bigint, quindi ho continuato la migrazione facendo un altro deploy del server.

Ero scioccato che tutto sembrasse procedere senza intoppi. Pochissime persone hanno segnalato problemi, e tutti molto minori (a parte la questione dell'esperienza). Quello che però mi ha particolarmente scioccato è quanto sia stata fluida questa migrazione, considerando che era per la maggior parte vibe-coded. Piuttosto diverso dalle storie spaventose che potresti leggere nelle news.

La riscrittura del Workshop

Con Postgres ora autorevole per le scritture, solo un grosso pezzo del codebase era ancora aggrovigliato in Firestore: il sistema del workshop. I giocatori lo usano per condividere mappe personalizzate in Dino-Might Bomber Online, liste di parole in Draw & Guess, e così via. Una sorta di Steam Workshop, ma per i nostri giochi. Era la feature più intrecciata con Firestore rimasta, sia sul client che sul server, e aveva una struttura un po' goffa a causa delle limitazioni del modello dati di Firestore che dovevo semplificare.

Per iniziare, ho dato a Cursor questo prompt (usando 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`, ...).

Da quello che avevo visto delle capacità di Opus 4.7 con l'harness di Cursor, mi aspettavo che questa migrazione finisse durante la notte.

La cosa importante che mi è sfuggita nel mio prompt originale, e che l'agent mi ha chiesto di chiarire, era come gestire efficientemente l'ordinamento per "Ultimo giorno", "Ultima settimana", "Ultimo mese" e "Sempre". L'agent spingeva per un approccio a bucket di timestamp (o di abbandonare del tutto l'ordinamento per timestamp), ma il co-fondatore ha insistito per mantenere l'ordinamento basato sul tempo nonostante la complessità aggiunta.

Dopo qualche riflessione, mi è venuta in mente una soluzione semplice che coinvolge un fattore di decadimento. Ogni elemento del workshop ha le colonne played_count_day, played_count_week, played_count_month e played_count_all, e un cron job orario moltiplica le colonne a scorrimento per 23/24, 167/168, e (720-1)/720 rispettivamente. Combinato con indici parziali (WHERE private = false AND played_count_day >= 0.368) su ogni asse di ordinamento, questo ci dà query "più popolari per periodo di tempo" a costo bassissimo senza infrastruttura aggiuntiva. È qualcosa che non avrei fatto con Firestore a causa dei suoi costi esorbitanti per letture e scritture, ma è praticamente gratis in Postgres.

Con questo, sono andato a letto. Mi sono svegliato e ho controllato emozionato il suo lavoro! L'agent ha aggiunto le nuove tabelle workshop_items, workshop_item_votes e user_subscriptions, ha collegato il client per leggere i singoli elementi tramite il gateway CDC (realtime.use('workshopItems/{id}')), e ha riscritto tutte e sei le azioni del workshop sul backend per parlare direttamente con Postgres. C'erano alcuni dettagli da sistemare (una query a Firestore sul server, un errore nel backfill causato da dati mancanti in Firestore, ecc.), ma nel complesso il codice era quasi perfetto.

Dopo aver eseguito il backfill e testato che tutto funzionasse in locale, ho messo in produzione la modifica. Con questo, il codebase era finalmente libero da Firestore. Bellissimo.

Lavori futuri

In futuro, vorrei anche supportare le patch nel gateway. Al momento, il gateway rimanda indietro l'intero documento come JSON per ogni aggiornamento. È un po' uno spreco, ma il nostro egress è praticamente illimitato grazie al fatto di essere su Hetzner. Lo implementerei adesso, ma non riesco ancora a giustificare la complessità aggiuntiva.

8 Ball Pool online multiplayer billiards icon