background blurbackground mobile blur

1/1/1970

Cara Migrasi dari Firestore ke Postgres dalam 2 Hari

Firebase. Satu kata itu bisa dengan mudah membuatmu mengeluarkan biaya 10-20x lipat dibanding database yang lebih murah, sekaligus membatasi dan mempersulit desain database-mu.

Jadi bisa kamu bayangkan betapa leganya aku setelah berhasil menyelesaikan migrasi dari Firestore hanya dalam 2 hari. Yah, 3 hari kalau kamu hitung waktu yang aku habiskan untuk membangun "PostgresSynchronizer" awal. Atau 3,5 hari kalau kamu hitung waktu untuk migrasi sistem workshop. Apa pun itu, prosesnya cepat. Sangat cepat.

Estimasi awalku untuk proyek ini adalah sebulan penuh! Tapi, berkat beberapa prompt LLM yang keren, dan seorang teman yang mengajariku tentang CDC, aku bisa menyelesaikannya jauh lebih cepat.

Kalau kamu sedang mencari pub/sub, Firebase atau Supabase mungkin terdengar sangat menggoda. Bagaimanapun, tidak banyak database yang mendukung pub/sub secara bawaan. Tapi arsitektur sederhana di bawah ini sudah cukup untuk sebagian besar kebutuhan bisnis kecil (dan harusnya bisa di-scale dengan baik sampai sekitar 10rb-100rb ops/detik).

Arsitektur pub/sub Foony Postgres berjalan di server Hetzner-nya sendiri. Layanan CDC dan Gateway berjalan di dalam cluster k3s Hetzner. CDC membaca Write-Ahead Log Postgres dan menerbitkan perubahan ke Redis Pub/Sub (server Hetzner terpisah). Gateway berlangganan ke Redis dan menyebarkan pembaruan ke klien WebSocket.

{/* Host Postgres (kiri). Server Hetzner mandiri. */} Server Postgres (Hetzner) Postgres WAL

{/* Cluster k3s (tengah). Berisi layanan CDC dan Gateway sebagai pod. */} Cluster k3s (Hetzner)

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

{/* Layanan Gateway. Dua rect berlapis menggambarkan beberapa replika "autoscaling horizontal". */} Gateway (autoscaling)

{/* Host Redis (di bawah k3s). Server Hetzner mandiri. */} Server Redis (Hetzner) Redis Pub/Sub

{/* Alur data: Postgres WAL -> CDC. /} {/ CDC -> Redis (turun melewati bagian bawah bingkai k3s). /} {/ Redis -> Gateway (naik kembali ke bingkai k3s). */}

{/* Klien WebSocket (di luar semuanya). Tiga kartu bertumpuk menggambarkan fan-out. */} Klien WebSocket

Klien WebSocket Klien WebSocket

{/* Gateway -> klien WebSocket (fan-out melalui internet publik). */}

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

Sebelum kita masuk ke detail bagaimana pub/sub-nya bekerja, aku akan menjelaskan langkah-langkah yang aku ambil secara berurutan.

Pertama, aku membuat sebuah prompt di Cursor untuk membangun PostgresSynchronizer. Class ini memiliki sebuah fungsi, handleWrite, yang menerima path Firestore, jenis operasi (create, update, delete), dan datanya. Lalu fungsi itu akan meng-upsert data ke Postgres. Setelah itu, aku membungkus instance store Firestore-ku supaya setiap perubahan pada Firestore juga memanggil handleWrite.

Ini memang belum 100% sempurna. Kalau server terputus atau crash, ada kemungkinan beberapa perubahan kecil tidak tersimpan. Tapi sudah cukup baik untuk kebutuhan Foony, dan skema Postgres memberi kami jaminan integritas data yang lebih baik (misalnya foreign key constraints) dibanding Firestore.

Selanjutnya, aku membuat dan menjalankan backfill yang iterasi melalui semua koleksi Firestore dan memanggil handleWrite untuk setiap dokumen. Ini memastikan kami mendapatkan semua data historis dari Firestore. Demi kecepatan, aku membatasinya hanya untuk pengguna terdaftar (maaf untuk tamu, harusnya kalian daftar dulu). Cara ini bekerja dengan baik, dan aman untuk dijalankan berkali-kali.

Sekarang setelah aku punya cara untuk mengisi data di Postgres, dan menjaganya (kurang lebih) tetap sinkron dengan Firestore, aku bisa menangani masalah besar yang menakutkan: pub/sub.

Bagaimana caranya melakukan pub/sub dengan Postgres? Atau database SQL mana pun?

Postgres Change Data Capture (CDC) sebagai penyelamat!

CDC adalah istilah keren yang artinya cuma "baca perubahan yang dibuat ke database dan terbitkan ke sistem pub/sub". Di perusahaan besar, kamu mungkin akan pakai sesuatu seperti Debezium + Kafka. Tapi Kafka itu merepotkan untuk di-setup, kami sudah punya Redis di tempat, dan database kami hanya menerima sekitar 30 ops/detik. Bahkan kalau kami melakukan 100x skala kami menjadi ~100.000 pengguna bersamaan, kemungkinan besar kami masih bisa menampungnya dalam satu instance Redis. Jadi itulah yang aku lakukan.

(Kamu juga bisa menempelkan pub/sub ke Postgres dengan LISTEN/NOTIFY, tapi cara ini tidak bertahan saat reconnect dan akan ambruk begitu kamu benar-benar butuh fanout. CDC adalah jawaban yang membosankan tapi tahan banting.)

Temanku, Eric, mengajariku tentang CDC dan membuat kode CDC-nya menjadi open source. Kode itu membaca WAL Postgres (Write-Ahead Log, tempat Postgres menulis setiap perubahan yang dilakukan ke database) melalui logical replication slot, dan menyimpannya ke sink (misalnya Redis).

Dengan prompt yang sederhana dan detail di Cursor, aku berhasil mendapatkan kode CDC realtime dan gateway dalam satu kali percobaan (dengan beberapa penyesuaian kecil dan code review yang teliti). Ini mencakup dua layanan yang sangat sederhana:

  • sebuah layanan CDC singleton yang membaca WAL dan menerbitkannya ke Redis Pub/Sub (bisa mendapatkan daya tahan lebih dengan Streams, tapi dengan biaya kompleksitas)
  • sebuah layanan gateway yang autoscaling horizontal yang menangani autentikasi JWT dan koneksi websocket dari klien web.

Kedua layanan ini ditulis dalam golang, dan aku sangat suka bagian gateway autoscaling-nya. Klien web berlangganan dengan cara yang sama seperti dulu dengan Firestore, dan menerima data dengan format yang sama. Klien melihat "usersPublic/" dan "usersPrivate/" sebagai dua koleksi terpisah dengan izin yang berbeda. Gateway bertanggung jawab menerjemahkan permintaan itu ke tabel Postgres yang mendasarinya (dalam hal ini users), dan memvalidasi bahwa klien punya izin untuk data tersebut.

Cara ini bekerja sangat menakjubkan. Seluruh sistem pub/sub hanya menelan biaya sekitar $0,50/bulan untuk komputasi + biaya egress, dan tidak punya banyak bagian bergerak yang bisa rusak (kode CDC, gateway, dan klien semuanya sederhana tanpa dependensi pihak ketiga di luar Redis/Postgres).

Dari sisi DevEx, sistem baru ini bisa dibilang juga lebih sederhana: developer tidak perlu tahu cara memodelkan data di Firestore atau aturan keamanan Firestore. Selama mereka memahami SQL, mereka bisa cepat melakukan edit pada schema.sql kami, menambah atau memodifikasi route di all.go, dan selesai. Dokumentasinya juga cukup kuat sehingga LLM mudah mengikutinya dan melakukan perubahan. Dari sisi keamanan, ini juga merupakan kemenangan karena kami tidak lagi terpapar serangan denial-of-wallet (Wallet DoS) yang berbahaya, sebab infrastruktur baru kami berbiaya tetap dan jauh lebih murah.

Secara keseluruhan, termasuk instance Postgres dan backup harian ke S3, kami berhasil mengurangi biaya infrastruktur database dari $550/bulan menjadi hanya $40/bulan. Kami juga mengurangi ukuran bundle klien sebesar ~100KB, yang lumayan.

Sekarang setelah aku punya sistem baru, aku menguji secara lokal untuk memastikan semuanya bekerja sesuai harapan. Lalu, aku deploy ke production. Kalau ada masalah, mudah untuk melakukan rollback klien karena Firestore masih menjadi sumber kebenaran pada titik ini.

Go Live

Cutover server-nya cukup menegangkan (migrasi database memang selalu begitu). Sampai titik ini, Firestore masih menjadi sumber kebenaran. Sistem CDC pub/sub hanya membaca dari mirror Postgres yang dijaga tetap up-to-date oleh synchronizer. Men-deploy server game baru itulah yang akan benar-benar membuat Postgres menjadi otoritatif untuk operasi tulis.

Begini cara aku me-roll out-nya:

  1. Memperbarui partner terlebih dahulu. Foony terintegrasi dengan partner seperti FRVR, jadi kami men-deploy build klien baru beberapa hari lebih awal yang akan menggunakan gateway CDC baru kami.
  2. Memberi tahu para pemain. Sekitar sepuluh menit sebelum cutover, kami memposting pemberitahuan tentang jendela maintenance singkat (semoga begitu).
  3. Membuat backup database baru. Ini selalu ide yang bagus saat melakukan migrasi database besar. LLM menyebutnya "belt and suspenders", yang pada dasarnya berarti ekstra hati-hati dan punya rencana cadangan.
  4. Men-deploy kedua cluster secara bersamaan. Tidak ada blue/green kali ini. Aku ingin memastikan semua cluster menulis ke database yang sama untuk menghindari potensi ketidakkonsistenan.

Aku juga punya rencana rollback yang sangat sederhana: kalau ada yang melenceng, aku akan men-deploy ulang klien sebelumnya (yang masih membaca ke Firestore), men-deploy ulang server, memulai ulang backfill, dan coba lagi nanti. Ini akan menyebabkan sekitar 5 menit downtime saat server di-deploy ulang, dan sekitar setengah hari untuk menyusulkan database Postgres ke Firestore lagi.

Cutover yang sebenarnya hanya memakan waktu sekitar 1 menit downtime. Satu-satunya bug serius yang muncul adalah saat memperbarui experience: LOWER() Postgres pada kolom bigint secara implisit melakukan cast ke text. Hadeuh. Ini cukup mudah diperbaiki dengan cast sederhana kembali ke bigint, jadi aku melanjutkan migrasi dengan melakukan deploy server lagi.

Aku terkejut bahwa semuanya tampak berjalan mulus. Sangat sedikit orang yang melaporkan masalah, dan semuanya sangat minor (selain masalah experience tadi). Yang sangat mengejutkan bagiku adalah betapa mulusnya migrasi ini berjalan padahal sebagian besar dikerjakan secara vibe-coding. Cukup berbeda dengan cerita menakutkan yang mungkin kamu baca di berita.

Penulisan Ulang Workshop

Dengan Postgres yang sekarang otoritatif untuk operasi tulis, hanya tersisa satu bagian besar dari codebase yang masih berbelit dengan Firestore: sistem workshop. Pemain menggunakannya untuk berbagi peta kustom di Dino-Might Bomber Online, daftar kata di Draw & Guess, dan sebagainya. Mirip dengan Steam Workshop, tapi untuk game kami. Ini adalah fitur yang paling terikat dengan Firestore yang tersisa, baik di klien maupun server, dan memiliki struktur yang canggung karena keterbatasan model data Firestore yang harus aku sederhanakan.

Untuk memulai, aku memberikan prompt ini ke Cursor (menggunakan Opus 4.7 high di 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`, ...).

Berdasarkan apa yang aku lihat dari kemampuan Opus 4.7 dengan harness Cursor, aku berharap migrasi ini bisa selesai semalaman.

Hal besar yang aku lewatkan dalam prompt awal, yang membuat agen meminta klarifikasi, adalah bagaimana kami harus secara efisien menangani sortir "Hari Terakhir", "Minggu Terakhir", "Bulan Terakhir", dan "Sepanjang Masa". Si agen mendorong pendekatan timestamp-bucketed (atau menghilangkan sortir berbasis timestamp sama sekali), tapi co-founder bersikeras mempertahankan sortir berbasis waktu meskipun kompleksitasnya bertambah.

Setelah berpikir sejenak, aku menemukan solusi sederhana yang melibatkan faktor peluruhan (decay factor). Setiap item workshop punya kolom played_count_day, played_count_week, played_count_month, dan played_count_all, dan cron job per jam mengalikan kolom rolling tersebut dengan 23/24, 167/168, dan (720-1)/720 masing-masing. Dikombinasikan dengan partial index (WHERE private = false AND played_count_day >= 0.368) di setiap sumbu sortir, ini memberi kami query "paling populer berdasarkan periode waktu" yang super murah tanpa infrastruktur tambahan apa pun. Ini adalah sesuatu yang tidak akan aku lakukan dengan Firestore karena biaya baca dan tulisnya yang selangit, tapi pada dasarnya gratis di Postgres.

Dengan itu, aku pun tidur. Aku terbangun, dan dengan bersemangat memeriksa hasilnya! Si agen menambahkan tabel baru workshop_items, workshop_item_votes, dan user_subscriptions, menghubungkan klien untuk membaca item individu melalui gateway CDC (realtime.use('workshopItems/{id}')), dan menulis ulang keenam action workshop di backend untuk berbicara langsung dengan Postgres. Ada beberapa ujung lepas yang harus aku rapikan (satu query ke Firestore di server, error dengan backfill karena data yang hilang di Firestore, dll), tapi secara keseluruhan kodenya hampir sempurna.

Setelah menjalankan backfill, dan menguji untuk memastikan semuanya bekerja secara lokal, aku men-deploy perubahan ke production. Dengan itu, codebase akhirnya bebas dari Firestore. Indah.

Pekerjaan ke Depan

Ke depannya, aku juga ingin mendukung patch di gateway. Saat ini, gateway mengirim kembali seluruh dokumen sebagai JSON untuk setiap pembaruan. Memang agak boros, tapi egress kami praktis tidak terbatas berkat Hetzner. Aku akan mengimplementasikan ini sekarang, tapi belum bisa membenarkan kompleksitas tambahannya untuk saat ini.

8 Ball Pool online multiplayer billiards icon