

1/1/1970
Comment migrer de Firestore vers Postgres en 2 jours
Firebase. Ce simple mot peut facilement vous coûter 10 à 20 fois plus cher qu'une base de données moins onéreuse, tout en contraignant et compliquant la conception de votre base de données.
Vous imaginez donc mon soulagement quand j'ai fini de migrer hors de Firestore en seulement 2 jours. Bon, 3 jours si on compte le temps passé à construire mon « PostgresSynchronizer » initial. Ou 3,5 jours si on compte le temps consacré à la migration du système de workshop. Dans tous les cas, ce fut rapide. Très rapide.
Mon estimation initiale pour ce projet portait sur un mois entier de travail ! Mais grâce à quelques prompts LLM géniaux, et à un ami qui m'a fait découvrir le CDC, j'ai pu boucler tout ça bien plus vite.
Si vous cherchez du pub/sub, Firebase ou Supabase peuvent sembler très tentants. Après tout, peu de bases de données prennent en charge le pub/sub nativement. Mais l'architecture simple ci-dessous suffit pour la plupart des cas d'usage des petites entreprises (et elle devrait monter en charge sans souci jusqu'à environ 10 000 à 100 000 opérations par seconde).
Avant de plonger dans les détails du fonctionnement du pub/sub, je vais cependant vous présenter les étapes que j'ai suivies, dans l'ordre.
D'abord, j'ai créé un prompt dans Cursor pour construire un PostgresSynchronizer. Cette classe possède une fonction, handleWrite, qui prend un chemin Firestore, le type d'opération (create, update, delete) et les données. Elle effectue ensuite un upsert vers Postgres. J'ai ensuite enveloppé mon instance de store Firestore pour que tout changement dans Firestore appelle aussi handleWrite.
Ce n'est évidemment pas parfait à 100 %. Si le serveur se déconnecte ou plante, il est possible que quelques changements mineurs ne soient pas persistés. Mais c'est suffisant pour le cas d'usage de Foony, et le schéma Postgres nous offre de meilleures garanties d'intégrité des données (notamment les contraintes de clé étrangère) que Firestore.
Ensuite, j'ai créé et lancé un backfill qui parcourt toutes les collections Firestore et appelle handleWrite pour chaque document. Cela garantit la récupération de toutes les données historiques de Firestore. Pour gagner en rapidité, je l'ai limité aux utilisateurs enregistrés (désolé pour les invités, vous auriez dû vous inscrire). Ça marche bien, et c'est sans danger de le relancer plusieurs fois.
Maintenant que j'avais un moyen de remplir les données dans Postgres et de les garder (en grande partie) synchronisées avec Firestore, je pouvais m'attaquer au gros problème effrayant : le pub/sub.
Comment diable faire du pub/sub avec Postgres ? Ou avec n'importe quelle base SQL d'ailleurs ?
Le Change Data Capture (CDC) de Postgres à la rescousse !
CDC est un terme savant qui signifie simplement « lire les changements effectués sur la base de données et les publier vers un système pub/sub ». Dans une grande entreprise, on utiliserait par exemple Debezium + Kafka. Mais Kafka est pénible à mettre en place, on a déjà Redis en place, et notre base ne traite qu'environ 30 opérations par seconde. Même si on multipliait notre échelle par 100 pour atteindre environ 100 000 utilisateurs concurrents, on pourrait probablement tenir avec une seule instance Redis. Donc c'est ce que j'ai fait.
(On peut aussi greffer du pub/sub sur Postgres avec LISTEN/NOTIFY, mais ça ne survit pas aux reconnexions et ça craque dès qu'on a vraiment besoin de fanout. Le CDC est la réponse ennuyeuse mais durable.)
Mon ami, Eric, m'a fait découvrir le CDC et a publié en open source son code CDC qui lit le WAL de Postgres (Write-Ahead Log, c'est là où Postgres inscrit chaque modification apportée à la base) via un slot de réplication logique, et le persiste vers un sink (Redis par exemple).
Avec un prompt simple et détaillé dans Cursor, j'ai réussi à générer en grande partie en un seul coup le code CDC temps réel et le gateway (avec quelques ajustements mineurs et une revue de code approfondie). Cela inclut deux services très simples :
- un service CDC singleton qui lit le WAL et le publie vers Redis Pub/Sub (on peut gagner en durabilité avec les Streams, mais au prix de la complexité)
- un service gateway en autoscaling horizontal qui gère l'authentification JWT et les connexions WebSocket des clients web.
Les deux services sont en Go, et j'adore particulièrement la partie gateway en autoscaling. Le client web s'abonne de la même manière qu'avec Firestore, et reçoit les données dans le même format. Le client voit « usersPublic/ » et « usersPrivate/ » comme deux collections distinctes avec des permissions séparées. Le gateway se charge de traduire cette requête vers les tables Postgres sous-jacentes (ici users) et de valider que le client a la permission d'accéder à ces données.
Ça fonctionne incroyablement bien. L'ensemble du système pub/sub ne nous coûte qu'environ 0,50 $ / mois en compute et en egress, et n'a pas beaucoup d'éléments mobiles susceptibles de casser (le code du CDC, du gateway et du client est simple, sans dépendances tierces en dehors de Redis et Postgres).
Côté DevEx, le nouveau système est sans doute plus simple aussi : les développeurs n'ont pas besoin de savoir modéliser les données dans Firestore ni de connaître les règles de sécurité Firestore. Tant qu'ils comprennent le SQL, ils peuvent rapidement modifier notre schema.sql, ajouter ou modifier une route dans all.go, et c'est fait. La documentation est également suffisamment solide pour que les LLM la suivent facilement et fassent des changements. Côté sécurité, c'est aussi une victoire, car on n'est plus exposés à une dangereuse attaque par déni de portefeuille (Wallet DoS), puisque notre nouvelle infra est à coût fixe et bien moins chère.
Au total, en incluant l'instance Postgres et les sauvegardes quotidiennes vers S3, on est passés de 550 $ / mois à seulement 40 $ / mois en coûts d'infra base de données. On a aussi réduit la taille du bundle client d'environ 100 Ko, ce qui est appréciable.
Une fois le nouveau système en place, j'ai testé en local pour m'assurer que tout fonctionnait comme prévu. Puis je l'ai déployé en production. En cas de problème, c'est facile de faire un rollback du client puisque Firestore reste la source de vérité à ce stade.
Mise en production
Le basculement du serveur était un peu effrayant (les migrations de bases de données le sont toujours). Jusque-là, Firestore restait la source de vérité, le système pub/sub CDC se contentait de lire depuis un miroir Postgres maintenu à jour par le synchronizer. C'est le déploiement des nouveaux serveurs de jeu qui allait réellement faire de Postgres la source autoritative pour les écritures.
Voici comment j'ai procédé :
- Mise à jour des partenaires en premier. Foony est intégré avec des partenaires comme FRVR. On a donc déployé une nouvelle build client quelques jours en avance, qui utiliserait notre nouveau gateway CDC.
- Avertissement aux joueurs. Environ dix minutes avant le basculement, on a publié un avis pour annoncer une (espérons-le) courte fenêtre de maintenance.
- Prise d'une sauvegarde fraîche de la base. C'est toujours une bonne idée lors de grandes migrations de bases de données. Les LLM appellent ça « ceinture et bretelles », ce qui signifie en gros être extra prudent et avoir un plan de secours.
- Déploiement simultané des deux clusters. Pas de blue/green cette fois. Je voulais m'assurer que tous les clusters écrivaient dans la même base pour éviter toute incohérence potentielle.
J'avais aussi un plan de rollback prêt, très simple : si quoi que ce soit dérapait, je redéployais le client précédent (qui lisait encore depuis Firestore), je redéployais les serveurs, je relançais le backfill et je retenterais plus tard. Cela aurait entraîné environ 5 minutes de downtime le temps que les serveurs se redéploient, et environ une demi-journée pour que la base Postgres rattrape Firestore.
Le basculement réel n'a entraîné qu'environ 1 minute de downtime. Le seul bug sérieux qui a fait surface concernait la mise à jour de l'expérience : la fonction LOWER() de Postgres sur une colonne bigint la castait implicitement en text. Soupir. C'était facile à corriger avec un simple cast vers bigint, donc j'ai poursuivi la migration en lançant un autre déploiement serveur.
J'étais sidéré que tout semble se passer sans accroc. Très peu de personnes ont signalé des problèmes, et ceux-ci étaient tous très mineurs (à part l'histoire de l'expérience). Ce qui m'a particulièrement sidéré, c'est la fluidité de cette migration alors qu'elle a été en grande partie vibe-codée. Très différent des histoires effrayantes qu'on peut lire dans les actualités.
La réécriture du workshop
Maintenant que Postgres était autoritatif pour les écritures, il ne restait qu'un seul gros morceau du code base encore empêtré dans Firestore : le système de workshop. Les joueurs s'en servent pour partager des cartes personnalisées dans Dino-Might Bomber Online, des listes de mots dans Draw & Guess, etc. Un peu comme le Steam Workshop, mais pour nos jeux. C'était la fonctionnalité la plus enchevêtrée dans Firestore qui restait, à la fois sur le client et sur le serveur, et elle avait cette structure étrange due aux limitations du modèle de données Firestore, qu'il fallait simplifier.
Pour commencer, j'ai donné à Cursor ce prompt (en utilisant Opus 4.7 high en mode plan) :
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`, ...).
Vu ce que j'avais constaté des capacités d'Opus 4.7 avec le harness Cursor, je m'attendais à ce que cette migration se termine pendant la nuit.
Ce que j'ai oublié de préciser dans mon prompt initial, et que l'agent m'a demandé de clarifier, c'était comment gérer efficacement le tri par « Dernier jour », « Dernière semaine », « Dernier mois » et « De tous les temps ». L'agent poussait pour une approche par buckets de timestamps (ou pour abandonner le tri par timestamp), mais le cofondateur insistait pour conserver le tri temporel malgré la complexité supplémentaire.
Après réflexion, j'ai trouvé une solution simple basée sur un facteur de décroissance. Chaque élément du workshop a des colonnes played_count_day, played_count_week, played_count_month et played_count_all, et un cron horaire multiplie les colonnes glissantes par 23/24, 167/168 et (720-1)/720 respectivement. Combiné à des index partiels (WHERE private = false AND played_count_day >= 0.368) sur chaque axe de tri, ça nous donne des requêtes « les plus populaires sur la période » à prix dérisoire sans infra supplémentaire. C'est quelque chose que je n'aurais pas fait avec Firestore en raison de ses coûts exorbitants en lectures et écritures, mais c'est quasiment gratuit dans Postgres.
Sur ce, je suis allé me coucher. Au réveil, j'ai consulté son travail avec impatience ! L'agent avait ajouté les nouvelles tables workshop_items, workshop_item_votes et user_subscriptions, câblé le client pour lire chaque élément via le gateway CDC (realtime.use('workshopItems/{id}')), et réécrit les six actions du workshop côté backend pour qu'elles parlent directement à Postgres. Il restait quelques détails à nettoyer (une requête à Firestore sur le serveur, une erreur dans le backfill due à des données manquantes dans Firestore, etc.), mais dans l'ensemble le code était presque parfait.
Après avoir lancé le backfill et testé en local pour m'assurer que tout fonctionnait, j'ai déployé la modification en production. Avec ça, le code base était enfin libéré de Firestore. Magnifique.
Travaux futurs
À l'avenir, j'aimerais aussi prendre en charge les patches dans le gateway. Pour l'instant, le gateway renvoie le document entier en JSON à chaque mise à jour. C'est un peu gaspilleur, mais notre egress est pratiquement illimité grâce à Hetzner. J'implémenterais bien ça maintenant, mais je ne peux pas encore justifier la complexité supplémentaire.