

1/1/1970
Πώς να Μεταναστεύσετε από το Firestore στην Postgres σε 2 Μέρες
Firebase. Αυτή η μία λέξη μπορεί άνετα να σας κοστίσει 10-20x περισσότερα από μια φθηνότερη βάση δεδομένων, και να περιορίσει και να περιπλέξει τον σχεδιασμό της βάσης σας.
Φαντάζεστε λοιπόν πόσο ανακουφισμένος ένιωσα όταν τελείωσα τη μετανάστευση από το Firestore σε μόλις 2 μέρες. Μάλιστα, 3 μέρες αν μετρήσετε τον χρόνο που πέρασα φτιάχνοντας τον αρχικό μου "PostgresSynchronizer". Ή 3,5 μέρες αν μετρήσετε τον χρόνο για τη μετανάστευση του συστήματος workshop. Σε κάθε περίπτωση, ήταν γρήγορο. Πολύ γρήγορο.
Η αρχική μου εκτίμηση για αυτό το έργο ήταν ολόκληρος μήνας δουλειάς! Αλλά, χάρη σε κάποια εκπληκτικά LLM prompts και έναν φίλο που μου έμαθε για το CDC, κατάφερα να τελειώσω σημαντικά πιο γρήγορα.
Αν ψάχνετε για pub/sub, το Firebase ή το Supabase μπορεί να ακούγονται πολύ δελεαστικά. Άλλωστε, δεν υποστηρίζουν πολλές βάσεις δεδομένων pub/sub out of the box. Αλλά αυτή η απλή αρχιτεκτονική παρακάτω είναι ό,τι χρειάζεστε για τις περισσότερες περιπτώσεις μικρών επιχειρήσεων (και θα πρέπει να κλιμακώνεται όμορφα μέχρι περίπου 10k-100k ops / δευτερόλεπτο).
Πριν όμως μπούμε στις λεπτομέρειες του πώς δουλεύει το pub/sub, θα περάσω από τα βήματα που έκανα με τη σειρά.
Πρώτα, έφτιαξα ένα prompt στο Cursor για να φτιάξει έναν PostgresSynchronizer. Αυτή η κλάση έχει μια συνάρτηση, την handleWrite, που δέχεται ένα Firestore path, τον τύπο της λειτουργίας (create, update, delete) και τα δεδομένα. Στη συνέχεια κάνει upsert τα δεδομένα στην Postgres. Μετά τύλιξα την Firestore store instance μου, έτσι ώστε οποιαδήποτε αλλαγή στο Firestore να καλεί και την handleWrite.
Αυτό δεν είναι 100% τέλειο, φυσικά. Αν ο server αποσυνδεθεί ή κρασάρει, είναι πιθανό μερικές μικρές αλλαγές να μην αποθηκευτούν. Αλλά είναι αρκετά καλό για την περίπτωση της Foony, και το Postgres schema μάς δίνει καλύτερες εγγυήσεις για την ακεραιότητα των δεδομένων (π.χ. foreign key constraints) σε σχέση με το Firestore.
Στη συνέχεια, δημιούργησα και έτρεξα ένα backfill που επαναλαμβάνει όλες τις Firestore collections και καλεί την handleWrite για κάθε document. Αυτό εξασφαλίζει ότι παίρνουμε όλα τα ιστορικά δεδομένα από το Firestore. Για ταχύτητα, το περιόρισα μόνο σε εγγεγραμμένους χρήστες (συγγνώμη, επισκέπτες, έπρεπε να είχατε εγγραφεί). Λειτουργεί καλά και είναι ασφαλές να εκτελεστεί πολλές φορές.
Τώρα που είχα έναν τρόπο να γεμίζω τα δεδομένα στην Postgres και να τα κρατάω (κυρίως) συγχρονισμένα με το Firestore, μπορούσα να αντιμετωπίσω το μεγάλο, τρομακτικό πρόβλημα: το pub/sub.
Πώς στο καλό κάνεις pub/sub με την Postgres; Ή με οποιαδήποτε SQL βάση δεδομένων;
Postgres Change Data Capture (CDC) στη διάσωση!
CDC είναι μια φανταχτερή λέξη που απλά σημαίνει "διάβασε τις αλλαγές που έγιναν στη βάση δεδομένων και δημοσίευσέ τις σε ένα σύστημα pub/sub". Σε μια μεγάλη επιχείρηση, ίσως χρησιμοποιούσατε κάτι σαν Debezium + Kafka. Αλλά το Kafka είναι μπελάς να στηθεί, εμείς ήδη έχουμε Redis έτοιμο, και η βάση δεδομένων μας παίρνει μόλις περίπου 30 ops / δευτερόλεπτο. Ακόμα κι αν πολλαπλασιάζαμε επί 100 την κλίμακά μας σε ~100.000 ταυτόχρονους χρήστες, μάλλον θα μπορούσαμε να το χωρέσουμε σε μία μόνο Redis instance. Αυτό λοιπόν έκανα.
(Μπορείτε επίσης να προσθέσετε pub/sub στην Postgres με LISTEN/NOTIFY, αλλά δεν επιβιώνει σε reconnects και καταρρέει μόλις χρειαστείτε πραγματικά fanout. Το CDC είναι η βαρετή, ανθεκτική απάντηση.)
Ο φίλος μου, ο Eric, μου έμαθε για το CDC και έκανε open-source τον κώδικα CDC του, που διαβάζει το WAL της Postgres (Write-Ahead Log, εκεί που η Postgres γράφει κάθε αλλαγή που κάνει στη βάση δεδομένων) μέσω ενός logical replication slot, και το αποθηκεύει σε ένα sink (π.χ. Redis).
Με ένα απλό, λεπτομερές prompt στο Cursor, μπόρεσα να βγάλω σχεδόν με μία προσπάθεια τον realtime CDC και gateway κώδικα (με κάποιες μικρές αλλαγές και ένα ενδελεχές code review). Αυτό περιλαμβάνει δύο πολύ απλές υπηρεσίες:
- μια singleton CDC υπηρεσία που διαβάζει το WAL και το δημοσιεύει στο Redis Pub/Sub (μπορεί να έχουμε περισσότερη durability με Streams, αλλά με κόστος επιπλέον πολυπλοκότητας)
- μια οριζόντια αυτο-κλιμακούμενη gateway υπηρεσία που χειρίζεται JWT auth και websocket συνδέσεις από web clients.
Και οι δύο υπηρεσίες είναι σε golang, και ιδιαίτερα μου αρέσει το κομμάτι του autoscaling gateway. Ο web client κάνει subscribe με τον ίδιο τρόπο που έκανε με το Firestore, και παίρνει πίσω δεδομένα στην ίδια μορφή. Ο client βλέπει το "usersPublic/" και το "usersPrivate/" ως δύο ξεχωριστές collections με ξεχωριστά δικαιώματα. Το gateway είναι υπεύθυνο να μεταφράζει αυτό το request στους υποκείμενους Postgres πίνακες (σε αυτή την περίπτωση users), και να επικυρώνει ότι ο client έχει δικαίωμα για αυτά τα δεδομένα.
Αυτό δουλεύει εξωφρενικά καλά. Όλο το σύστημα pub/sub μάς κοστίζει μόνο περίπου $0,50 / μήνα σε compute + egress, και δεν έχει πολλά κινούμενα μέρη που μπορούν να σπάσουν (ο κώδικας CDC, gateway και client είναι όλα απλά χωρίς third-party εξαρτήσεις πέρα από Redis / Postgres).
Από άποψη DevEx, το νέο σύστημα είναι αναμφισβήτητα πιο απλό επίσης: οι developers δεν χρειάζεται να ξέρουν πώς να μοντελοποιήσουν δεδομένα στο Firestore ή τους Firestore security rules. Αρκεί να καταλαβαίνουν SQL, και μπορούν γρήγορα να κάνουν μια τροποποίηση στο schema.sql μας, να προσθέσουν ή να τροποποιήσουν ένα route στο all.go, και είναι έτοιμοι. Η τεκμηρίωση είναι επίσης αρκετά καλή ώστε τα LLMs να την ακολουθούν εύκολα και να κάνουν αλλαγές. Από άποψη ασφάλειας, αυτό είναι επίσης νίκη καθώς δεν είμαστε πλέον εκτεθειμένοι σε μια επικίνδυνη επίθεση denial-of-wallet (Wallet DoS), εφόσον η νέα υποδομή μας έχει σταθερό κόστος και είναι παράλογα φθηνότερη.
Συνολικά, συμπεριλαμβανομένης της Postgres instance και των ημερήσιων S3 backups, καταφέραμε να πάμε από $550 / μήνα σε μόλις $40 / μήνα σε κόστος υποδομής βάσης δεδομένων. Μειώσαμε επίσης το μέγεθος του client bundle κατά ~100KB, το οποίο είναι ωραίο.
Τώρα που είχα το νέο σύστημα, το δοκίμασα τοπικά για να βεβαιωθώ ότι όλα δούλευαν όπως αναμενόταν. Στη συνέχεια, το έβαλα live. Αν υπήρχαν προβλήματα, ήταν εύκολο να κάνουμε rollback τον client, αφού το Firestore εξακολουθούσε να είναι η πηγή της αλήθειας σε αυτό το σημείο.
Πάμε Live
Το cutover του server ήταν κάπως τρομακτικό (οι μεταναστεύσεις βάσεων δεδομένων πάντα είναι). Μέχρι εκείνο το σημείο, το Firestore ήταν ακόμα η πηγή της αλήθειας. Το CDC pub/sub σύστημα απλά διάβαζε από έναν Postgres mirror που ο synchronizer κρατούσε ενημερωμένο. Το deployment των νέων game servers ήταν αυτό που πραγματικά θα έκανε την Postgres authoritative για τα writes.
Έτσι έκανα την κυκλοφορία:
- Πρώτα ενημερώθηκαν οι συνεργάτες. Η Foony είναι ενσωματωμένη με συνεργάτες όπως το FRVR, οπότε κάναμε deploy ένα νέο client build λίγες μέρες νωρίτερα που θα χρησιμοποιούσε το νέο μας CDC gateway.
- Προειδοποιήσαμε τους παίκτες. Περίπου δέκα λεπτά πριν το cutover, δημοσιεύσαμε μια προειδοποίηση για ένα (ελπίζουμε) σύντομο παράθυρο συντήρησης.
- Πήραμε ένα φρέσκο backup της βάσης δεδομένων. Αυτή είναι πάντα μια καλή ιδέα με μεγάλες μεταναστεύσεις βάσεων δεδομένων. Τα LLMs το λένε "belt and suspenders" (ζώνη και τιράντες), που βασικά απλά σημαίνει να είσαι πολύ προσεκτικός και να έχεις εφεδρικό σχέδιο.
- Έκανα deploy και τα δύο clusters ταυτόχρονα. Όχι blue/green αυτή τη φορά. Ήθελα να βεβαιωθώ ότι όλα τα clusters έγραφαν στην ίδια βάση δεδομένων για να αποφύγω πιθανές ασυνέπειες.
Είχα επίσης ένα σχέδιο rollback έτοιμο, το οποίο ήταν πολύ απλό: αν κάτι πήγαινε στραβά, θα έκανα redeploy τον προηγούμενο client (που διάβαζε ακόμα από το Firestore), redeploy τους servers, restart το backfill, και θα δοκίμαζα ξανά αργότερα. Αυτό θα προκαλούσε περίπου 5 λεπτά downtime κατά το redeploy των servers, και περίπου μισή μέρα για να φτάσει η Postgres ξανά το Firestore.
Το πραγματικό cutover πήρε μόλις περίπου 1 λεπτό downtime. Το μόνο σοβαρό bug που εμφανίστηκε ήταν στην ενημέρωση του experience: το LOWER() της Postgres σε μια bigint στήλη έκανε implicit casting σε text. Αναστεναγμός. Αυτό ήταν αρκετά εύκολη διόρθωση με ένα απλό cast πίσω σε bigint, οπότε συνέχισα τη μετανάστευση κάνοντας ένα ακόμα server deploy.
Έμεινα έκπληκτος που όλα έμοιαζαν να πηγαίνουν ομαλά. Πολύ λίγοι άνθρωποι ανέφεραν προβλήματα, και αυτά ήταν όλα πολύ μικρά (πέρα από αυτό με το experience). Αυτό που με εξέπληξε ιδιαίτερα, όμως, είναι πόσο ομαλά πήγε αυτή η μετανάστευση, δεδομένου ότι ήταν στην πλειοψηφία της vibe-coded. Αρκετά διαφορετικό από τις τρομακτικές ιστορίες που μπορεί να διαβάσετε στις ειδήσεις.
Η Επανεγγραφή του Workshop
Με την Postgres πλέον authoritative για τα writes, μόνο ένα μεγάλο κομμάτι του codebase ήταν ακόμα μπλεγμένο με το Firestore: το σύστημα workshop. Οι παίκτες το χρησιμοποιούν για να μοιράζονται custom maps στο Dino-Might Bomber Online, λίστες λέξεων στο Draw & Guess, και ούτω καθεξής. Κάπως σαν το Steam Workshop, αλλά για τα παιχνίδια μας. Ήταν το πιο μπλεγμένο με Firestore feature που είχε απομείνει, τόσο στον client όσο και στον server, και είχε αυτή την άβολη δομή λόγω των περιορισμών του μοντέλου δεδομένων του Firestore, που έπρεπε να απλοποιήσω.
Για να ξεκινήσω, έδωσα στο Cursor αυτό το prompt (χρησιμοποιώντας Opus 4.7 high σε 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`, ...).
Με όσα είχα δει για τις δυνατότητες του Opus 4.7 με το Cursor harness, περίμενα ότι αυτή η μετανάστευση θα τελείωνε μέσα στη νύχτα.
Το μεγάλο πράγμα που μου ξέφυγε στο αρχικό μου prompt, που ο agent μου ζήτησε να διευκρινίσω, ήταν πώς θα έπρεπε να χειριστούμε αποδοτικά την ταξινόμηση "Last Day", "Last Week", "Last Month" και "All Time". Ο agent έσπρωχνε για μια προσέγγιση με timestamp-bucketed (ή να ρίξουμε εντελώς την ταξινόμηση βάσει timestamp), αλλά ο co-founder επέμενε να κρατήσει την ταξινόμηση βάσει χρόνου παρά την προστιθέμενη πολυπλοκότητα.
Μετά από κάποια σκέψη, βρήκα μια απλή λύση που περιλαμβάνει έναν decay factor. Κάθε workshop item έχει στήλες played_count_day, played_count_week, played_count_month και played_count_all, και ένα ωριαίο cron job πολλαπλασιάζει τις κυλιόμενες στήλες με 23/24, 167/168 και (720-1)/720 αντίστοιχα. Σε συνδυασμό με partial indexes (WHERE private = false AND played_count_day >= 0.368) σε κάθε άξονα ταξινόμησης, αυτό μας δίνει εξευτελιστικά φθηνά queries "most popular by time period" χωρίς επιπλέον υποδομή. Αυτό είναι κάτι που δεν θα έκανα με το Firestore λόγω του υπέρογκου κόστους του για reads και writes, αλλά είναι βασικά δωρεάν στην Postgres.
Με αυτό, πήγα για ύπνο. Ξύπνησα και ενθουσιασμένος έλεγξα τη δουλειά του! Ο agent πρόσθεσε τους νέους πίνακες workshop_items, workshop_item_votes, και user_subscriptions, σύνδεσε τον client να διαβάζει τα μεμονωμένα items μέσω του CDC gateway (realtime.use('workshopItems/{id}')), και ξαναέγραψε και τα έξι workshop actions στον backend να μιλούν απευθείας με την Postgres. Υπήρχαν μερικές χαλαρές άκρες που έπρεπε να τακτοποιήσω (ένα query στο Firestore στον server, ένα σφάλμα με το backfill λόγω δεδομένων που έλειπαν στο Firestore, κλπ.), αλλά συνολικά ο κώδικας ήταν σχεδόν τέλειος.
Αφού έτρεξα το backfill και έλεγξα ότι όλα δούλευαν τοπικά, έκανα deploy την αλλαγή live. Με αυτό, το codebase ήταν επιτέλους ελεύθερο από Firestore. Υπέροχα.
Μελλοντική Δουλειά
Στο μέλλον, θα ήθελα επίσης να υποστηρίξω patches στο gateway. Αυτή τη στιγμή, το gateway στέλνει πίσω ολόκληρο το document ως JSON για κάθε ενημέρωση. Είναι λίγο σπατάλη, αλλά το egress μας είναι ουσιαστικά απεριόριστο χάρη στο ότι είμαστε σε Hetzner. Θα το υλοποιούσα τώρα, αλλά δεν μπορώ να δικαιολογήσω την προστιθέμενη πολυπλοκότητα ακόμα.