

1/1/1970
كيف تنتقل من Firestore إلى Postgres في يومين
Firebase. هذه الكلمة الواحدة يمكن أن تكلّفك بسهولة 10 إلى 20 ضعف ما تكلّفه قاعدة بيانات أرخص، وقد تُقيّد وتُعقّد تصميم قاعدة بياناتك في آنٍ معًا.
لذا يمكنكم تخيّل مدى ارتياحي عندما انتهيت من الانتقال من Firestore في يومين فقط. حسنًا، 3 أيام إن احتسبنا الوقت الذي قضيته في بناء "PostgresSynchronizer" الأولي. أو 3.5 يومًا إن احتسبنا الوقت الذي قضيته في نقل نظام الورشة (workshop). على أي حال، كان الأمر سريعًا. سريعًا للغاية.
كان تقديري الأولي لهذا المشروع شهرًا كاملًا من العمل! لكن، بفضل بعض المطالبات (prompts) الرائعة لنماذج LLM، وصديقٍ علّمني عن CDC، استطعت إنجاز هذا بسرعة أكبر بكثير.
إذا كنت تبحث عن pub/sub، فقد يبدو Firebase أو Supabase مغريَين جدًا. ففي النهاية، ليست هناك قواعد بيانات كثيرة تدعم pub/sub بشكل جاهز. لكن البنية البسيطة أدناه هي كل ما تحتاجه لمعظم حالات الاستخدام في الأعمال الصغيرة (ويُفترض أن تتوسّع بشكل جيد حتى نحو 10 آلاف إلى 100 ألف عملية في الثانية).
قبل أن نخوض في تفاصيل آلية عمل pub/sub، سأستعرض الخطوات التي قمت بها بالترتيب.
أولًا، أنشأت مطالبة في Cursor لبناء PostgresSynchronizer. يحتوي هذا الـ class على دالة handleWrite تأخذ مسار Firestore ونوع العملية (إنشاء، تحديث، حذف) والبيانات. ثم تقوم بعملية upsert للبيانات إلى Postgres. ثم غلّفت نسخة مخزن Firestore لديّ بحيث إن أي تغيير على Firestore سيستدعي handleWrite أيضًا.
هذا ليس مثاليًا بنسبة 100% بالطبع. فإن انقطع الخادم أو تعطّل، فمن الممكن ألا تُحفظ بعض التغييرات الطفيفة. لكنه جيد بما يكفي لحالة استخدام Foony، ويمنحنا مخطط Postgres ضمانات سلامة بيانات أفضل (مثل قيود المفاتيح الأجنبية) مقارنةً بـ Firestore.
بعد ذلك، أنشأت وشغّلت backfill يمر على جميع مجموعات Firestore ويستدعي handleWrite لكل مستند. وهذا يضمن أننا نحصل على جميع البيانات التاريخية من Firestore. ولأجل السرعة، قصرت هذا على المستخدمين المسجَّلين فقط (آسف للضيوف، كان عليكم التسجيل). يعمل هذا بشكل جيد، ومن الآمن تشغيله عدة مرات.
والآن وقد صار لديّ طريقة لملء البيانات في Postgres والحفاظ عليها (في معظمها) متزامنة مع Firestore، صار بإمكاني التصدّي للمشكلة الكبيرة المخيفة: pub/sub.
كيف بحق السماء تنفّذ pub/sub باستخدام Postgres؟ أو أي قاعدة بيانات SQL أصلًا؟
التقاط تغييرات البيانات (CDC) في Postgres للإنقاذ!
CDC هو مصطلح فاخر يعني ببساطة "اقرأ التغييرات التي حدثت على قاعدة البيانات وانشرها إلى نظام pub/sub". في الشركات الكبيرة، قد تستخدم شيئًا مثل Debezium + Kafka. لكن Kafka مزعج في الإعداد، وعندنا Redis جاهز فعلًا، وقاعدة بياناتنا تتلقى نحو 30 عملية في الثانية فقط. حتى لو قمنا بمضاعفة حجمنا 100 مرة ليصل إلى نحو 100 ألف مستخدم متزامن، فعلى الأرجح يمكننا استيعاب ذلك في نسخة Redis واحدة. ولذلك هذا ما فعلته.
(يمكنك أيضًا إلصاق pub/sub بـ Postgres عبر LISTEN/NOTIFY، لكنه لا يصمد عند إعادة الاتصال، وينهار بمجرد أن تحتاج فعلًا إلى توزيع. CDC هو الجواب الممل والمتين.)
علّمني صديقي إريك عن CDC، وفتح المصدر لـ كود الـ CDC الخاص به الذي يقرأ WAL الخاص بـ Postgres (سجل الكتابة المسبقة، وهو حيث يكتب Postgres كل تغيير يجريه على قاعدة البيانات) عبر فتحة نسخ منطقي (logical replication slot)، ويحفظه إلى وجهة (sink) مثل Redis.
بمطالبة بسيطة ومفصّلة في Cursor، استطعت تنفيذ كود CDC الفوري والـ gateway بمحاولة واحدة تقريبًا (مع بعض التعديلات الطفيفة ومراجعة كود شاملة). يتضمن ذلك خدمتين بسيطتين جدًا:
- خدمة CDC وحيدة (singleton) تقرأ من WAL وتنشر إلى Redis Pub/Sub (يمكن الحصول على متانة أعلى عبر Streams، لكن على حساب التعقيد)
- خدمة gateway ذات توسّع أفقي تلقائي تتعامل مع توثيق JWT واتصالات websocket من عملاء الويب.
كلتا الخدمتين مكتوبتان بلغة golang، وأحب بشكل خاص جزء التوسّع التلقائي للـ gateway. يشترك عميل الويب بالطريقة نفسها التي كان يستخدمها مع Firestore، ويحصل على البيانات بنفس التنسيق. يرى العميل "usersPublic/" و"usersPrivate/" كمجموعتَين منفصلتَين بصلاحيات منفصلة. والـ gateway مسؤولة عن ترجمة ذلك الطلب إلى جداول Postgres الأساسية (في هذه الحالة users)، والتحقق من أن العميل يملك صلاحية الوصول إلى تلك البيانات.
ويعمل هذا بشكل جيد بصورة مذهلة. نظام pub/sub بأكمله لا يكلّفنا سوى نحو 0.50 دولار شهريًا في تكاليف الحوسبة والـ egress، وليس فيه أجزاء متحركة كثيرة قد تتعطّل (كود CDC والـ gateway والعميل بسيط ولا يعتمد على أي مكتبات طرف ثالث خارج Redis / Postgres).
ومن منظور تجربة المطورين، النظام الجديد أبسط على الأرجح أيضًا: لا يحتاج المطورون إلى معرفة كيفية نمذجة البيانات في Firestore أو قواعد أمان Firestore. ما داموا يفهمون SQL، يمكنهم بسرعة إجراء تعديل على ملف schema.sql لدينا، أو إضافة أو تعديل مسار (route) في all.go، ويكونون جاهزين. كما أن التوثيق قوي بما يكفي ليتمكّن نماذج LLM من اتباعه بسهولة وإجراء التغييرات. ومن منظور أمني، هذا أيضًا مكسب لأننا لم نعد عرضة لهجوم خطير من نوع denial-of-wallet (Wallet DoS)، إذ صارت بنيتنا الجديدة بتكلفة ثابتة وأرخص بشكل جنوني.
في المجمل، شاملًا نسخة Postgres ونسخ S3 الاحتياطية اليومية، استطعنا الانتقال من 550 دولارًا شهريًا إلى 40 دولارًا شهريًا فقط في تكاليف البنية التحتية لقواعد البيانات. كما قلّلنا حجم حزمة العميل (client bundle) بنحو 100 كيلوبايت، وهذا أمر جميل.
والآن وقد صار لديّ النظام الجديد، اختبرته محليًا للتأكد من أن كل شيء يعمل كما هو متوقّع. ثم نشرته مباشرةً. وإن حدثت أي مشاكل، فمن السهل التراجع عن العميل لأن Firestore لا يزال هو مصدر الحقيقة في هذه المرحلة.
الانتقال للإنتاج
كان تحويل الخادم مخيفًا بعض الشيء (عمليات نقل قواعد البيانات مخيفة دومًا). حتى هذه اللحظة، كان Firestore لا يزال مصدر الحقيقة، ونظام pub/sub القائم على CDC كان مجرد قارئ من نسخة Postgres التي يبقيها الـ synchronizer محدّثة. ونشر خوادم الألعاب الجديدة هو ما سيُحوّل Postgres فعليًا ليصبح المرجع الرسمي للكتابات.
إليكم كيف طرحته:
- حدّثت الشركاء أولًا. Foony مدمج مع شركاء مثل FRVR، لذا نشرنا بناءً جديدًا للعميل قبل أيام قليلة كان سيستخدم gateway الـ CDC الجديد لدينا.
- نبّهت اللاعبين. قبل نحو عشر دقائق من التحويل، نشرنا تنبيهًا بشأن نافذة صيانة قصيرة (نأمل ذلك).
- أخذت نسخة احتياطية جديدة من قاعدة البيانات. هذه فكرة جيدة دائمًا مع عمليات النقل الكبيرة لقواعد البيانات. تسمّي نماذج LLM هذا "حزام وحمّالة بنطلون"، وهو يعني ببساطة التحلّي بالحذر الإضافي وامتلاك خطة بديلة.
- نشرت كلا العنقودين في وقت واحد. لا blue/green هذه المرة. أردت التأكد من أن جميع العناقيد تكتب إلى قاعدة البيانات نفسها لتفادي أي تعارضات محتملة.
كانت لديّ أيضًا خطة تراجع بسيطة جدًا: إذا ساءت الأمور، سأعيد نشر العميل السابق (الذي لا يزال يقرأ من Firestore)، وأعيد نشر الخوادم، وأعيد تشغيل الـ backfill، وأحاول مرة أخرى لاحقًا. كان هذا سيتسبب بنحو 5 دقائق من التوقف ريثما تُعاد الخوادم، ونحو نصف يوم لإعادة لحاق قاعدة بيانات Postgres بـ Firestore.
استغرق التحويل الفعلي دقيقة واحدة فقط من التوقف. الخلل الجدي الوحيد الذي ظهر كان في تحديث الخبرة (experience): دالة LOWER() في Postgres على عمود من نوع bigint كانت تُحوّله ضمنيًا إلى text. تنهيدة. كان هذا إصلاحًا سهلًا عبر cast بسيط للعودة إلى bigint، فأكملت عملية النقل بإصدار نشر خادم آخر.
كنت مصدومًا لأن كل شيء كان يسير بسلاسة. كان عدد قليل جدًا من الأشخاص قد أبلغوا عن مشاكل، وكانت جميعها طفيفة جدًا (باستثناء مسألة الخبرة). والأكثر إدهاشًا لي هو مدى سلاسة هذا الانتقال رغم أن أغلبه كان مكتوبًا بأسلوب vibe-coding. مختلف تمامًا عن القصص المخيفة التي قد تقرأها في الأخبار.
إعادة كتابة نظام الورشة
والآن وقد أصبح Postgres المرجع الرسمي للكتابات، بقي جزء كبير واحد فقط من قاعدة الكود متشابكًا مع Firestore: نظام الورشة (workshop). يستخدمه اللاعبون لمشاركة الخرائط المخصّصة في Dino-Might Bomber Online، وقوائم الكلمات في Draw & Guess، وهكذا. أشبه بـ Steam Workshop، لكن لألعابنا. كان أكثر ميزة متبقّية متشابكة مع Firestore، على العميل وعلى الخادم، وكان فيه بنية محرجة بسبب قيود نموذج بيانات Firestore، اضطررت إلى تبسيطها.
للبداية، أعطيت Cursor هذه المطالبة (باستخدام Opus 4.7 high في وضع 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`, ...).
بناءً على ما رأيته من قدرات Opus 4.7 مع بيئة Cursor، توقّعت أن تنتهي هذه العملية بين عشية وضحاها.
الشيء المهم الذي فاتني في مطالبتي الأصلية، والذي طلب الـ agent مني توضيحه، هو كيف ينبغي أن نتعامل بكفاءة مع الترتيب حسب "اليوم الأخير"، و"الأسبوع الأخير"، و"الشهر الأخير"، و"كل الأوقات". كان الـ agent يدفع نحو نهج مبني على شرائح زمنية (timestamp buckets)، أو التخلّي عن الترتيب الزمني تمامًا، لكنّ الشريك المؤسّس أصرّ على إبقاء الترتيب الزمني رغم تعقيده الإضافي.
بعد بعض التفكير، توصّلت إلى حل بسيط يتضمّن عامل تضاؤل (decay factor). كل عنصر ورشة لديه أعمدة played_count_day وplayed_count_week وplayed_count_month وplayed_count_all، وتقوم مهمة cron كل ساعة بضرب الأعمدة المتدحرجة بـ 23/24 و167/168 و(720-1)/720 على التوالي. وعند الدمج مع فهارس جزئية (WHERE private = false AND played_count_day >= 0.368) على كل محور ترتيب، يمنحنا هذا استعلامات "الأكثر شعبية حسب الفترة الزمنية" بتكلفة زهيدة جدًا دون أي بنية تحتية إضافية. هذا شيء ما كنت لأفعله مع Firestore بسبب تكاليفه الفاحشة على القراءات والكتابات، لكنه شبه مجاني في Postgres.
ومع ذلك، ذهبت للنوم. واستيقظت، وفحصت عمله بحماس! أضاف الـ agent الجداول الجديدة workshop_items وworkshop_item_votes وuser_subscriptions، وربط العميل ليقرأ العناصر الفردية عبر CDC gateway (realtime.use('workshopItems/{id}'))، وأعاد كتابة جميع إجراءات الورشة الستة على الخادم للتحدث مع Postgres مباشرة. كانت هناك بعض الأطراف غير المربوطة احتجت إلى تنظيفها (استعلام واحد إلى Firestore على الخادم، وخطأ في الـ backfill بسبب بيانات مفقودة في Firestore، إلخ)، لكن الكود بشكل عام كان شبه مثالي.
بعد تشغيل الـ backfill، واختبار عمل كل شيء محليًا، نشرت التغيير مباشرة. وبذلك، صارت قاعدة الكود أخيرًا خالية من Firestore. رائع.
أعمال مستقبلية
في المستقبل، أودّ أيضًا دعم patches في الـ gateway. حاليًا، يُعيد الـ gateway المستند بأكمله بصيغة JSON مع كل تحديث. هذا فيه بعض الهدر، لكن الـ egress لدينا فعليًا غير محدود بفضل وجودنا على Hetzner. كنت سأنفّذ هذا الآن، لكن لا يمكنني تبرير التعقيد الإضافي حتى الآن.