

1/1/1970
2 दिन में Firestore से Postgres पर माइग्रेट कैसे करें
Firebase. यह एक शब्द आसानी से आपको किसी सस्ते डेटाबेस की तुलना में 10-20 गुना ज़्यादा महंगा पड़ सकता है, और आपके डेटाबेस डिज़ाइन को सीमित और जटिल दोनों बना सकता है.
तो आप समझ सकते हैं कि मुझे कितनी राहत मिली जब मैंने सिर्फ़ 2 दिनों में Firestore से माइग्रेशन पूरा किया. खैर, 3 दिन अगर मेरे शुरुआती "PostgresSynchronizer" बनाने में लगे समय को भी गिनें. या 3.5 दिन अगर वर्कशॉप सिस्टम के माइग्रेशन में लगे समय को भी जोड़ें. किसी भी हाल में, यह जल्दी हुआ. बहुत जल्दी.
इस प्रोजेक्ट के लिए मेरा शुरुआती अनुमान पूरे एक महीने के काम का था! लेकिन कुछ शानदार LLM प्रॉम्प्ट्स की बदौलत, और एक दोस्त जिसने मुझे CDC के बारे में सिखाया, मैं इसे काफ़ी तेज़ी से निपटा सका.
अगर आप pub/sub ढूंढ रहे हैं, तो Firebase या Supabase बहुत लुभावने लग सकते हैं. आखिरकार, बहुत कम डेटाबेस pub/sub को आउट-ऑफ़-द-बॉक्स सपोर्ट करते हैं. लेकिन नीचे दिया गया यह सरल आर्किटेक्चर ज़्यादातर छोटे-व्यवसाय के मामलों के लिए काफ़ी है (और लगभग 10k-100k ऑप्स / सेकंड तक अच्छी तरह स्केल होना चाहिए).
हालांकि pub/sub कैसे काम करता है, इसके विवरण में जाने से पहले, मैं उन कदमों के बारे में बताऊंगा जो मैंने क्रम से उठाए.
सबसे पहले, मैंने Cursor में एक प्रॉम्प्ट बनाया जो PostgresSynchronizer तैयार करे. इस क्लास में एक फ़ंक्शन है, handleWrite, जो एक Firestore पाथ, ऑपरेशन का प्रकार (create, update, delete), और डेटा लेता है. फिर यह डेटा को Postgres में अपसर्ट कर देता है. इसके बाद मैंने अपने Firestore स्टोर इंस्टेंस को इस तरह रैप किया कि Firestore में होने वाला कोई भी बदलाव handleWrite को भी कॉल करे.
यह 100% परफेक्ट नहीं है, बेशक. अगर सर्वर डिसकनेक्ट हो जाए या क्रैश हो जाए, तो हो सकता है कि कुछ छोटे-मोटे बदलाव सेव न हों. लेकिन Foony के मामले के लिए यह काफ़ी अच्छा है, और Postgres स्कीमा हमें Firestore से बेहतर डेटा इंटीग्रिटी गारंटी देता है (जैसे foreign key constraints).
इसके बाद, मैंने एक बैकफिल बनाया और चलाया जो सभी Firestore कलेक्शन पर इटरेट करता है और हर डॉक्यूमेंट के लिए handleWrite को कॉल करता है. यह सुनिश्चित करता है कि हमें Firestore से सारा ऐतिहासिक डेटा मिल जाए. स्पीड के लिए, मैंने इसे केवल रजिस्टर्ड यूज़र्स तक सीमित किया (माफ़ करना मेहमानों, आपको साइन अप कर लेना चाहिए था). यह अच्छी तरह काम करता है, और इसे कई बार चलाना सुरक्षित है.
अब जबकि मेरे पास Postgres में डेटा भरने का तरीका था, और इसे (ज़्यादातर) Firestore के साथ सिंक में रखने का तरीका भी, मैं उस बड़ी, डरावनी समस्या से निपटने को तैयार था: pub/sub.
आप Postgres के साथ pub/sub आखिर कैसे करेंगे? या किसी भी SQL डेटाबेस के साथ?
Postgres Change Data Capture (CDC) हमारी मदद के लिए हाज़िर!
CDC एक फ़ैंसी शब्द है जिसका मतलब बस इतना है कि "डेटाबेस में हुए बदलावों को पढ़ो और उन्हें pub/sub सिस्टम पर प्रकाशित करो". किसी बड़े बिज़नेस में, आप शायद Debezium + Kafka जैसी चीज़ इस्तेमाल करेंगे. लेकिन Kafka सेट अप करना झंझट है, हमारे पास पहले से Redis मौजूद है, और हमारा डेटाबेस लगभग 30 ऑप्स / सेकंड ही पाता है. अगर हम अपने स्केल को 100 गुना बढ़ाकर ~1,00,000 कंकरंट यूज़र्स भी कर लें, तो भी यह संभवतः एक ही Redis इंस्टेंस में फ़िट हो सकता है. तो मैंने यही किया.
(आप Postgres पर pub/sub को LISTEN/NOTIFY के ज़रिए भी जोड़ सकते हैं, लेकिन यह रीकनेक्ट के बाद नहीं चलता और जब असली फैन-आउट की ज़रूरत पड़े तो ढह जाता है. CDC उबाऊ, टिकाऊ जवाब है.)
मेरे दोस्त, Eric, ने मुझे CDC के बारे में सिखाया और अपना CDC कोड ओपन-सोर्स किया, जो Postgres के WAL (Write-Ahead Log, यानी वो जगह जहाँ Postgres डेटाबेस में किया गया हर बदलाव लिखता है) को लॉजिकल रिप्लिकेशन स्लॉट के ज़रिए पढ़ता है, और उसे एक सिंक (जैसे Redis) में सेव करता है.
Cursor में एक सरल, विस्तृत प्रॉम्प्ट से, मैं रियलटाइम CDC और गेटवे कोड को लगभग एक ही बार में तैयार कर सका (कुछ छोटे-मोटे बदलावों और गहन कोड रिव्यू के साथ). इसमें दो बहुत सरल सेवाएँ शामिल हैं:
- एक सिंगलटन CDC सेवा जो WAL पढ़ती है और इसे Redis Pub/Sub पर प्रकाशित करती है (Streams के साथ ज़्यादा टिकाऊपन मिल सकता है, लेकिन जटिलता की कीमत पर)
- एक हॉरिज़ॉन्टली ऑटोस्केलिंग गेटवे सेवा जो वेब क्लाइंट्स से JWT auth और websocket कनेक्शन हैंडल करती है.
दोनों सेवाएँ golang में हैं, और मुझे विशेष रूप से ऑटोस्केलिंग गेटवे वाला हिस्सा बहुत पसंद है. वेब क्लाइंट उसी तरह सब्सक्राइब करता है जैसे पहले Firestore के साथ करता था, और उसी फ़ॉर्मेट में डेटा वापस पाता है. क्लाइंट को "usersPublic/" और "usersPrivate/" दो अलग-अलग परमिशन वाले अलग-अलग कलेक्शन के रूप में दिखते हैं. गेटवे की ज़िम्मेदारी है कि वह उस अनुरोध को अंतर्निहित Postgres टेबल्स (इस मामले में users) पर अनुवादित करे, और यह सत्यापित करे कि क्लाइंट को उस डेटा की अनुमति है.
यह हैरतअंगेज़ तरीके से अच्छा काम करता है. पूरे pub/sub सिस्टम पर हमें कुल मिलाकर लगभग $0.50 / महीना का कंप्यूट + एग्रेस खर्च आता है, और इसमें टूटने लायक बहुत सारे चलते-फिरते हिस्से नहीं हैं (CDC, गेटवे और क्लाइंट कोड सभी सरल हैं, और Redis / Postgres के अलावा कोई थर्ड-पार्टी डिपेंडेंसी नहीं है).
DevEx के नज़रिए से, नया सिस्टम कहीं ज़्यादा सरल भी है: डेवलपर्स को Firestore में डेटा मॉडल करना या Firestore security rules जानने की ज़रूरत नहीं है. जब तक वे SQL समझते हैं, वे जल्दी से हमारे schema.sql में एक एडिट कर सकते हैं, all.go में एक रूट जोड़ या बदल सकते हैं, और बस हो गया. डॉक्यूमेंटेशन भी इतना मज़बूत है कि LLMs इसे आसानी से फ़ॉलो कर सकते हैं और बदलाव कर सकते हैं. सिक्योरिटी के नज़रिए से भी यह एक जीत है क्योंकि अब हम खतरनाक denial-of-wallet (Wallet DoS) हमले के लिए खुले नहीं हैं, चूँकि हमारा नया इंफ्रा फ़िक्स्ड-कॉस्ट और बहुत-बहुत सस्ता है.
कुल मिलाकर, Postgres इंस्टेंस और दैनिक S3 बैकअप सहित, हम डेटाबेस इंफ्रा कॉस्ट में $550 / महीना से सिर्फ़ $40 / महीना तक आ गए. हमने क्लाइंट बंडल साइज़ को भी ~100KB कम किया, जो अच्छा है.
अब जब मेरे पास नया सिस्टम था, मैंने लोकल में टेस्ट किया ताकि यह सुनिश्चित हो कि सब कुछ अपेक्षा के अनुसार काम कर रहा है. फिर, मैंने इसे लाइव डिप्लॉय किया. अगर कोई समस्या होती, तो क्लाइंट को रोलबैक करना आसान है क्योंकि इस समय तक Firestore अभी भी सत्य का स्रोत है.
लाइव होना
सर्वर कटओवर थोड़ा डरावना था (डेटाबेस माइग्रेशन हमेशा होते हैं). इस बिंदु तक, Firestore अभी भी सत्य का स्रोत था. CDC pub/sub सिस्टम बस एक Postgres मिरर से पढ़ रहा था जिसे सिंक्रोनाइज़र अप-टू-डेट रखता था. नए गेम सर्वर डिप्लॉय करना ही वह कदम होता जो वास्तव में लिखने के लिए Postgres को आधिकारिक बनाता.
मैंने इसे ऐसे रोलआउट किया:
- पहले पार्टनर्स को अपडेट किया. Foony, FRVR जैसे पार्टनर्स के साथ इंटीग्रेटेड है, तो हमने कुछ दिन पहले ही एक नया क्लाइंट बिल्ड डिप्लॉय कर दिया जो हमारे नए CDC गेटवे का उपयोग करेगा.
- खिलाड़ियों को आगाह किया. कटओवर से लगभग दस मिनट पहले, हमने एक (उम्मीद से) छोटी मेंटेनेंस विंडो के बारे में जानकारी पोस्ट की.
- एक ताज़ा डेटाबेस बैकअप लिया. बड़े डेटाबेस माइग्रेशन के साथ यह हमेशा एक अच्छा विचार है. LLMs इसे "belt and suspenders" कहते हैं, जिसका मतलब बस इतना है कि अतिरिक्त सावधान रहना और एक बैकअप प्लान रखना.
- दोनों क्लस्टर एक साथ डिप्लॉय किए. इस बार कोई blue/green नहीं. मैं सुनिश्चित करना चाहता था कि सभी क्लस्टर एक ही डेटाबेस में लिख रहे हों ताकि किसी भी संभावित असंगति से बचा जा सके.
मेरे पास एक रोलबैक प्लान भी था, जो बहुत सरल था: अगर कुछ भी गड़बड़ होती, तो मैं पिछला क्लाइंट (जो अभी भी Firestore से पढ़ता था) फिर से डिप्लॉय करता, सर्वर फिर से डिप्लॉय करता, बैकफिल फिर से शुरू करता, और बाद में फिर कोशिश करता. इससे सर्वर के फिर से डिप्लॉय होने तक लगभग 5 मिनट का डाउनटाइम होता, और Postgres डेटाबेस को Firestore के साथ वापस पकड़ने में लगभग आधा दिन लगता.
असली कटओवर में सिर्फ़ 1 मिनट का डाउनटाइम लगा. एकमात्र गंभीर बग जो सामने आया वह experience अपडेट करने में था: Postgres का LOWER() एक bigint कॉलम पर implicit रूप से text में कास्ट हो रहा था. एक आह. यह एक आसान फ़िक्स था, बस एक सरल कास्ट वापस bigint में, तो मैंने एक और सर्वर डिप्लॉय जारी करके माइग्रेशन जारी रखा.
मैं हैरान था कि सब कुछ सुचारू रूप से चल रहा था. बहुत कम लोगों ने समस्याओं की रिपोर्ट की, और वे सभी बहुत छोटी थीं (experience वाली बात को छोड़कर). जो बात मुझे विशेष रूप से चौंकाने वाली थी, वह यह कि यह माइग्रेशन कितना सुचारू रहा, खासकर इसे देखते हुए कि यह ज़्यादातर वाइब-कोडेड था. यह उन डरावनी कहानियों से बिल्कुल अलग था जो आप ख़बरों में पढ़ सकते हैं.
वर्कशॉप का रीराइट
अब जब Postgres लिखने के लिए आधिकारिक हो चुका था, कोडबेस का सिर्फ़ एक बड़ा हिस्सा अभी भी Firestore में उलझा हुआ था: वर्कशॉप सिस्टम. खिलाड़ी इसका उपयोग Dino-Might Bomber Online में कस्टम मैप शेयर करने के लिए, Draw & Guess में वर्ड लिस्ट के लिए, इत्यादि के लिए करते हैं. कुछ-कुछ Steam Workshop की तरह, लेकिन हमारे गेम्स के लिए. यह बचा हुआ सबसे Firestore-उलझा फ़ीचर था, क्लाइंट और सर्वर दोनों पर, और इसमें Firestore के डेटा मॉडल की सीमाओं की वजह से एक अजीब संरचना थी जिसे मुझे सरल करना था.
शुरुआत के लिए, मैंने Cursor को यह प्रॉम्प्ट दिया (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`, ...).
Cursor harness के साथ Opus 4.7 की क्षमताओं को जो मैंने देखा था, उसे देखते हुए, मुझे उम्मीद थी कि यह माइग्रेशन रातों-रात पूरा हो जाएगा.
मेरे मूल प्रॉम्प्ट में जो बड़ी बात मैं चूक गया, जिसे एजेंट ने मुझसे स्पष्ट करने को कहा, वह यह थी कि हम "Last Day", "Last Week", "Last Month", और "All Time" सॉर्टिंग को कुशलता से कैसे हैंडल करें. एजेंट टाइमस्टैम्प-बकेटेड एप्रोच के लिए (या टाइमस्टैम्प सॉर्टिंग को पूरी तरह छोड़ने के लिए) ज़ोर दे रहा था, लेकिन सह-संस्थापक ने अतिरिक्त जटिलता के बावजूद समय-आधारित सॉर्टिंग बनाए रखने पर ज़ोर दिया.
कुछ सोच-विचार के बाद, मैंने एक decay factor वाला सरल समाधान निकाला. हर वर्कशॉप आइटम में played_count_day, played_count_week, played_count_month, और played_count_all कॉलम होते हैं, और एक hourly cron job रोलिंग कॉलम्स को क्रमशः 23/24, 167/168, और (720-1)/720 से गुणा करता है. हर सॉर्ट एक्सिस पर पार्शियल इंडेक्स (WHERE private = false AND played_count_day >= 0.368) के साथ मिलकर, यह हमें बिना किसी अतिरिक्त इंफ्रास्ट्रक्चर के बेहद सस्ती "समय अवधि के अनुसार सबसे लोकप्रिय" क्वेरीज़ देता है. यह वो चीज़ है जो मैंने Firestore के साथ नहीं की होती क्योंकि उसके reads और writes बहुत महंगे होते हैं, लेकिन Postgres में यह लगभग मुफ़्त है.
इसके साथ, मैं सोने चला गया. मैं उठा, और उत्साह से उसका काम चेक किया! एजेंट ने नए workshop_items, workshop_item_votes, और user_subscriptions टेबल जोड़े, क्लाइंट को CDC गेटवे (realtime.use('workshopItems/{id}')) के ज़रिए व्यक्तिगत आइटम पढ़ने के लिए वायर किया, और बैकएंड पर सभी छह वर्कशॉप ऐक्शंस को Postgres से सीधे बात करने के लिए फिर से लिखा. कुछ ढीले-छोर थे जिन्हें मुझे साफ़ करना पड़ा (सर्वर पर Firestore की एक क्वेरी, Firestore में मिसिंग डेटा के कारण बैकफिल में एक एरर, आदि), लेकिन कुल मिलाकर कोड लगभग परफेक्ट था.
बैकफिल चलाने और लोकल में सब कुछ काम कर रहा है यह सुनिश्चित करने के लिए टेस्ट करने के बाद, मैंने बदलाव को लाइव डिप्लॉय किया. इसके साथ, कोडबेस आख़िरकार Firestore-मुक्त हो गया. खूबसूरत.
आगे का काम
भविष्य में, मैं गेटवे में patches को भी सपोर्ट करना चाहूंगा. अभी, गेटवे हर अपडेट के लिए पूरे डॉक्यूमेंट को JSON के रूप में वापस भेजता है. यह थोड़ा बेकार है, लेकिन Hetzner पर होने की वजह से हमारा एग्रेस प्रभावी रूप से असीमित है. मैं इसे अभी लागू करूंगा, लेकिन फ़िलहाल मैं अतिरिक्त जटिलता को सही नहीं ठहरा सकता.