background blurbackground mobile blur

1/1/1970

ফায়ারস্টোর থেকে পোস্টগ্রেসে ২ দিনে মাইগ্রেট করার উপায়

ফায়ারবেস। এই একটি শব্দই সহজেই আপনাকে সস্তা ডাটাবেসের তুলনায় ১০-২০ গুণ বেশি খরচ করাতে পারে, এবং আপনার ডাটাবেস ডিজাইনকে সীমাবদ্ধ ও জটিল উভয়ই করে তুলতে পারে।

তাই কল্পনা করুন কতটা স্বস্তি পেয়েছিলাম যখন মাত্র ২ দিনেই ফায়ারস্টোর থেকে মাইগ্রেশন শেষ করতে পেরেছিলাম। ভালো কথা, ৩ দিন যদি প্রাথমিক "PostgresSynchronizer" বানাতে ব্যয় করা সময়টা ধরেন। অথবা সাড়ে ৩ দিন যদি ওয়ার্কশপ সিস্টেম মাইগ্রেট করার সময়টাও ধরেন। যাইহোক, এটা দ্রুত ছিল। সত্যিই দ্রুত।

এই প্রজেক্টের জন্য আমার প্রাথমিক অনুমান ছিল পুরো এক মাস কাজ! কিন্তু কিছু দুর্দান্ত LLM প্রম্পট, এবং এক বন্ধু যিনি আমাকে CDC সম্পর্কে শিখিয়েছিলেন, তাদের কল্যাণে আমি অনেক দ্রুততর সময়ে এটা শেষ করতে পেরেছিলাম।

আপনি যদি pub/sub খুঁজছেন, তাহলে ফায়ারবেস বা সুপাবেস বেশ লোভনীয় শোনাতে পারে। আসলে, খুব বেশি ডাটাবেস out of the box pub/sub সাপোর্ট করে না। কিন্তু নিচের এই সাধারণ আর্কিটেকচারই বেশিরভাগ ছোট-ব্যবসার ক্ষেত্রে যথেষ্ট (এবং প্রায় ১০ হাজার থেকে ১ লাখ ops/সেকেন্ড পর্যন্ত ভালোভাবে স্কেল করার কথা)।

Foony এর pub/sub আর্কিটেকচার পোস্টগ্রেস তার নিজস্ব Hetzner সার্ভারে চলে। CDC এবং Gateway সার্ভিসগুলো একটি Hetzner k3s ক্লাস্টারের ভেতরে চলে। CDC পোস্টগ্রেসের Write-Ahead Log পড়ে এবং পরিবর্তনগুলো Redis Pub/Sub-এ (একটি আলাদা Hetzner সার্ভার) প্রকাশ করে। Gateway Redis-এ সাবস্ক্রাইব করে এবং WebSocket ক্লায়েন্টদের কাছে আপডেট ফ্যান-আউট করে।

{/* Postgres host (left). Standalone Hetzner server. */} Postgres সার্ভার (Hetzner) Postgres WAL

{/* k3s cluster (middle). Contains the CDC and Gateway services as pods. */} k3s ক্লাস্টার (Hetzner)

{/* CDC service (singleton). */} CDC (সিঙ্গেলটন)

{/* Gateway service. Two layered rects convey "horizontally autoscaling" multiple replicas. */} Gateway (অটোস্কেলিং)

{/* Redis host (below k3s). Standalone Hetzner server. */} Redis সার্ভার (Hetzner) Redis Pub/Sub

{/* Data flow: Postgres WAL -> CDC. /} {/ CDC -> Redis (down through the bottom of the k3s frame). /} {/ Redis -> Gateway (back up into the k3s frame). */}

{/* WebSocket clients (outside everything). Three stacked cards convey fan-out. */} WebSocket ক্লায়েন্ট

WebSocket ক্লায়েন্ট WebSocket ক্লায়েন্ট

{/* Gateway -> WebSocket clients (fan-out across the public internet). */}

Postgres WAL (Hetzner) → CDC (k3s) → Redis Pub/Sub (Hetzner) → Gateway (k3s) → WebSocket ক্লায়েন্ট

তবে pub/sub কীভাবে কাজ করে সেই বিস্তারিতে যাওয়ার আগে, আমি কী কী ধাপ নিয়েছিলাম সেগুলো ধারাবাহিকভাবে বলব।

প্রথমে, Cursor-এ একটি প্রম্পট দিয়ে একটি PostgresSynchronizer বানালাম। এই ক্লাসের একটি ফাংশন আছে, handleWrite, যেটা একটি Firestore পাথ, অপারেশনের ধরন (create, update, delete), এবং ডেটা গ্রহণ করে। তারপর এটি ডেটাটি পোস্টগ্রেসে আপসার্ট করে। তারপর আমি আমার Firestore স্টোর ইনস্ট্যান্সকে এমনভাবে র‍্যাপ করলাম যাতে Firestore-এ কোনো পরিবর্তন হলে handleWrite-ও কল হয়।

এটা অবশ্যই ১০০% নিখুঁত নয়, যদি সার্ভার ডিসকানেক্ট হয় বা ক্র্যাশ করে, তাহলে কিছু ছোটখাটো পরিবর্তন হয়তো পার্সিস্ট নাও হতে পারে। কিন্তু Foony-এর ব্যবহারের জন্য এটা যথেষ্ট ভালো, এবং পোস্টগ্রেস স্কিমা আমাদের Firestore-এর তুলনায় ভালো ডেটা ইন্টিগ্রিটির গ্যারান্টি দেয় (যেমন foreign key constraints)।

এরপর, একটি ব্যাকফিল তৈরি করে চালালাম যেটা সমস্ত Firestore কালেকশনের ওপর iterate করে এবং প্রতিটি ডকুমেন্টের জন্য handleWrite কল করে। এটা নিশ্চিত করে আমরা Firestore থেকে সব ঐতিহাসিক ডেটা পাচ্ছি। গতির জন্য, আমি এটা শুধুমাত্র রেজিস্টার্ড ইউজারদের মধ্যে সীমিত রেখেছিলাম (দুঃখিত অতিথিরা, আপনাদের সাইন আপ করা উচিত ছিল)। এটা ভালোভাবে কাজ করে, এবং একাধিকবার চালানো নিরাপদ।

এখন যেহেতু আমার কাছে পোস্টগ্রেসে ডেটা পপুলেট করার এবং Firestore-এর সাথে (মোটামুটি) সিঙ্ক রাখার একটি উপায় ছিল, তাই আমি বড়, ভীতিকর সমস্যাটার মুখোমুখি হতে পারলাম: pub/sub।

পোস্টগ্রেসের সাথে pub/sub কীভাবে করবেন? অথবা যেকোনো SQL ডাটাবেসের সাথে?

পোস্টগ্রেস Change Data Capture (CDC) উদ্ধারে!

CDC একটা ফ্যান্সি শব্দ যার অর্থ হলো "ডাটাবেসে করা পরিবর্তনগুলো পড়ো এবং একটি pub/sub সিস্টেমে প্রকাশ করো"। বড় ব্যবসায়, আপনি হয়তো Debezium + Kafka-এর মতো কিছু ব্যবহার করবেন। কিন্তু Kafka সেট আপ করা একটা ঝামেলা, আমাদের কাছে আগে থেকেই Redis আছে, এবং আমাদের ডাটাবেস মাত্র প্রায় ৩০ ops/সেকেন্ড পায়। এমনকি যদি আমরা ১০০ গুণ স্কেল করে প্রায় ১,০০,০০০ একই সাথে ব্যবহারকারী-তে পৌঁছাই, তবুও সম্ভবত আমরা এটা একটি Redis ইনস্ট্যান্সেই ফিট করাতে পারব। তাই আমি সেটাই করলাম।

(আপনি LISTEN/NOTIFY দিয়েও পোস্টগ্রেসে pub/sub বোল্ট করতে পারেন, কিন্তু এটা reconnect-এ টিকে থাকে না এবং যখন সত্যিকার অর্থে fanout দরকার পড়ে তখন ভেঙে পড়ে। CDC হলো বোরিং, টেকসই উত্তর।)

আমার বন্ধু, Eric, আমাকে CDC সম্পর্কে শিখিয়েছিলেন এবং তার CDC কোড ওপেন-সোর্স করেছিলেন, যেটা একটি logical replication slot-এর মাধ্যমে পোস্টগ্রেসের WAL (Write-Ahead Log, যেখানে পোস্টগ্রেস ডাটাবেসে করা প্রতিটি পরিবর্তন লেখে) পড়ে, এবং একটি sink-এ (যেমন Redis) পার্সিস্ট করে।

Cursor-এ একটি সাধারণ, বিস্তারিত প্রম্পট দিয়ে, আমি বেশিরভাগ ক্ষেত্রে রিয়েলটাইম CDC এবং gateway কোড one-shot করতে পেরেছিলাম (কিছু সামান্য টুইক এবং একটি পুঙ্খানুপুঙ্খ কোড রিভিউ সহ)। এতে দুটি খুব সাধারণ সার্ভিস অন্তর্ভুক্ত:

  • একটি সিঙ্গেলটন CDC সার্ভিস যেটা WAL পড়ে এবং Redis Pub/Sub-এ প্রকাশ করে (Streams দিয়ে আরও বেশি স্থায়িত্ব পাওয়া যায়, কিন্তু জটিলতার মূল্যে)
  • একটি horizontally autoscaling gateway সার্ভিস যেটা JWT auth এবং ওয়েব ক্লায়েন্ট থেকে websocket কানেকশন হ্যান্ডল করে।

দুটি সার্ভিসই golang-এ, এবং আমি বিশেষ করে autoscaling gateway অংশটাকে ভালোবাসি। ওয়েব ক্লায়েন্ট ঠিক একইভাবে সাবস্ক্রাইব করে যেমনটা Firestore-এর সাথে করত, এবং ঠিক একই ফরম্যাটে ডেটা ফিরে পায়। ক্লায়েন্ট "usersPublic/" এবং "usersPrivate/"-কে দুটি আলাদা পারমিশনযুক্ত আলাদা কালেকশন হিসেবে দেখে। Gateway দায়িত্বে থাকে সেই অনুরোধটিকে অন্তর্নিহিত পোস্টগ্রেস টেবিলে (এই ক্ষেত্রে users) অনুবাদ করার, এবং যাচাই করার যে ক্লায়েন্টের সেই ডেটার জন্য পারমিশন আছে।

এটা আশ্চর্যজনকভাবে ভালো কাজ করে। পুরো pub/sub সিস্টেম আমাদের শুধু কম্পিউট + এগ্রেস খরচে প্রায় $0.50/মাস খরচ করে, এবং এমন বেশি কিছু moving parts নেই যা ভেঙে পড়তে পারে (CDC, gateway, এবং ক্লায়েন্ট কোড সবই সাধারণ এবং Redis / Postgres ছাড়া কোনো third-party নির্ভরতা নেই)।

DevEx দৃষ্টিকোণ থেকে, নতুন সিস্টেম তর্কসাপেক্ষে আরও সহজ: ডেভেলপারদের Firestore-এ ডেটা মডেল করতে বা Firestore security rules জানতে হবে না। যতক্ষণ তারা SQL বোঝেন, ততক্ষণ তারা দ্রুত আমাদের schema.sql-এ একটি এডিট করতে পারেন, all.go-তে একটি route যোগ বা পরিবর্তন করতে পারেন, এবং তারা প্রস্তুত। ডকুমেন্টেশনও যথেষ্ট শক্তিশালী যে LLM-গুলো সহজেই সেটা অনুসরণ করতে এবং পরিবর্তন করতে পারে। সিকিউরিটির দৃষ্টিকোণ থেকেও এটা একটি জয়, কারণ আমরা আর বিপজ্জনক denial-of-wallet (Wallet DoS) আক্রমণের সম্মুখীন নই যেহেতু আমাদের নতুন infra fixed-cost এবং অবিশ্বাস্যভাবে সস্তা।

মোট মিলিয়ে, পোস্টগ্রেস ইনস্ট্যান্স এবং দৈনিক S3 ব্যাকআপসহ, আমরা ডাটাবেস infra খরচ $550/মাস থেকে মাত্র $40/মাস-এ নামাতে পেরেছি। আমরা ক্লায়েন্ট বান্ডেল সাইজও প্রায় 100KB কমিয়েছি, যেটা চমৎকার।

এখন যেহেতু নতুন সিস্টেম পেয়েছি, লোকালি টেস্ট করে নিশ্চিত হলাম সবকিছু প্রত্যাশিতভাবে কাজ করছে। তারপর, লাইভ ডিপ্লয় করলাম। যদি কোনো সমস্যা হয়, তাহলে ক্লায়েন্ট রোলব্যাক করা সহজ যেহেতু এই পর্যায়ে Firestore এখনও সত্যের উৎস।

লাইভে যাওয়া

সার্ভার কাটওভার বেশ ভীতিকর ছিল (ডাটাবেস মাইগ্রেশন সবসময়ই হয়)। এই পর্যন্ত, Firestore এখনও সত্যের উৎস ছিল, CDC pub/sub সিস্টেম শুধু একটি পোস্টগ্রেস মিরর থেকে পড়ছিল যেটা synchronizer আপ-টু-ডেট রেখেছিল। নতুন গেম সার্ভার ডিপ্লয় করাটাই আসলে পোস্টগ্রেসকে রাইটের জন্য authoritative করে দিত।

আমি কীভাবে এটা রোল আউট করলাম:

  1. প্রথমে পার্টনারদের আপডেট করেছি। Foony FRVR-এর মতো পার্টনারদের সাথে ইন্টিগ্রেটেড, তাই আমরা কয়েক দিন আগেই একটি নতুন ক্লায়েন্ট বিল্ড ডিপ্লয় করেছি যেটা আমাদের নতুন CDC gateway ব্যবহার করবে।
  2. প্লেয়ারদের সতর্ক করেছি। কাটওভারের প্রায় দশ মিনিট আগে, আমরা একটি (আশা করি) ছোট maintenance window সম্পর্কে একটি heads-up পোস্ট করেছিলাম।
  3. একটি নতুন ডাটাবেস ব্যাকআপ নিয়েছি। বড় ডাটাবেস মাইগ্রেশনের সাথে এটা সবসময়ই একটা ভালো ধারণা। LLM-গুলো এটাকে "belt and suspenders" বলে, যার মূলত মানে অতিরিক্ত সতর্ক থাকা এবং একটি ব্যাকআপ পরিকল্পনা রাখা।
  4. একই সাথে উভয় ক্লাস্টার ডিপ্লয় করেছি। এবার কোনো blue/green নয়। আমি নিশ্চিত করতে চেয়েছিলাম যে সমস্ত ক্লাস্টার একই ডাটাবেসে লিখছে যাতে কোনো সম্ভাব্য inconsistency এড়ানো যায়।

আমার একটি রোলব্যাক প্ল্যানও ছিল, যেটা খুব সহজ ছিল: যদি কিছু ভুল হয়ে যায়, আমি আগের ক্লায়েন্ট রিডিপ্লয় করব (যেটা এখনও Firestore-এ পড়ছিল), সার্ভার রিডিপ্লয় করব, ব্যাকফিল রিস্টার্ট করব, এবং পরে আবার চেষ্টা করব। এটা সার্ভার রিডিপ্লয় করার সময় প্রায় ৫ মিনিটের downtime এবং পোস্টগ্রেস ডাটাবেসকে আবার Firestore-এর সাথে catch up করানোর জন্য প্রায় অর্ধেক দিন সময় লাগাত।

আসল কাটওভার নিল মাত্র প্রায় ১ মিনিটের downtime। যে একমাত্র গুরুতর বাগটি দেখা দিয়েছিল সেটা ছিল experience আপডেট করার সময়: পোস্টগ্রেসের LOWER() একটি bigint কলামে implicitly text-এ cast করছিল। আহ। এটা bigint-এ একটি সহজ cast করার মাধ্যমে যথেষ্ট সহজ ফিক্স ছিল, তাই আমি আরেকটি সার্ভার ডিপ্লয় ইস্যু করে মাইগ্রেশন চালিয়ে গেলাম।

আমি অবাক হয়ে দেখলাম যে সবকিছু সুষ্ঠুভাবে চলছে বলে মনে হচ্ছিল। খুব কম লোকজন সমস্যার রিপোর্ট করেছিল, এবং সেগুলো সবই খুব ছোটখাটো ছিল (experience-এর বিষয়টি ছাড়া)। তবে আমার কাছে বিশেষভাবে আশ্চর্যের বিষয় ছিল, এই মাইগ্রেশন কতটা সুষ্ঠুভাবে গিয়েছিল যেহেতু এটা বেশিরভাগই vibe-coded ছিল। খবরে আপনি যে ভীতিকর গল্পগুলো পড়তে পারেন তার থেকে বেশ আলাদা।

ওয়ার্কশপ পুনর্লিখন

এখন পোস্টগ্রেস রাইটের জন্য authoritative হওয়ায়, কোডবেসের শুধু একটি বড় অংশ এখনও Firestore-এ জড়িয়ে ছিল: ওয়ার্কশপ সিস্টেম। প্লেয়াররা এটা ব্যবহার করে Dino-Might Bomber Online-এ কাস্টম ম্যাপ শেয়ার করতে, Draw & Guess-এ word lists শেয়ার করতে, ইত্যাদি। অনেকটা 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-এর ক্ষমতা সম্পর্কে আমি যা দেখেছিলাম, তাতে আমি এই মাইগ্রেশন রাতের মধ্যে শেষ হবে বলে আশা করেছিলাম।

আমার প্রাথমিক প্রম্পটে যে বড় বিষয়টি আমি মিস করেছিলাম, যেটা agent আমাকে স্পষ্ট করতে বলেছিল, তা হলো আমাদের কীভাবে দক্ষতার সাথে "Last Day", "Last Week", "Last Month", এবং "All Time" সর্টিং হ্যান্ডল করা উচিত। Agent একটি timestamp-bucketed পদ্ধতির জন্য চাপ দিচ্ছিল (অথবা timestamp সর্টিং সম্পূর্ণ বাদ দিতে), কিন্তু co-founder এর যোগ করা জটিলতা সত্ত্বেও time-based সর্টিং রাখার ব্যাপারে জোর দিয়েছিলেন।

কিছুটা ভাবার পর, আমি একটি decay factor সহ একটি সরল সমাধান নিয়ে এসেছিলাম। প্রতিটি ওয়ার্কশপ আইটেমের played_count_day, played_count_week, played_count_month, এবং played_count_all কলাম আছে, এবং একটি ঘণ্টাভিত্তিক cron job rolling কলামগুলোকে যথাক্রমে 23/24, 167/168, এবং (720-1)/720 দিয়ে গুণ করে। প্রতিটি সর্ট অক্ষে partial indexes (WHERE private = false AND played_count_day >= 0.368)-এর সাথে মিলিয়ে, এটা আমাদের কোনো অতিরিক্ত infra ছাড়াই অত্যন্ত সস্তা "most popular by time period" কোয়েরি দেয়। এটা এমন কিছু যা আমি Firestore-এর সাথে করতাম না কারণ এর reads এবং writes-এর জন্য অত্যধিক খরচ, কিন্তু পোস্টগ্রেসে এটা মূলত বিনামূল্যে।

এর সাথে, আমি ঘুমাতে গেলাম। উঠে, উত্তেজনার সাথে এর কাজ চেক করলাম! Agent নতুন workshop_items, workshop_item_votes, এবং user_subscriptions টেবিল যোগ করেছিল, ক্লায়েন্টকে CDC gateway-এর মাধ্যমে individual items পড়ার জন্য তার করেছিল (realtime.use('workshopItems/{id}')), এবং পোস্টগ্রেসের সাথে সরাসরি কথা বলার জন্য ব্যাকএন্ডে সব ছয়টি workshop action পুনর্লিখন করেছিল। কিছু loose ends পরিষ্কার করতে হয়েছিল (সার্ভারে Firestore-এ একটি কোয়েরি, Firestore-এ missing data-এর কারণে ব্যাকফিলে একটি error, ইত্যাদি), কিন্তু সামগ্রিকভাবে কোডটি প্রায় নিখুঁত ছিল।

ব্যাকফিল চালানোর পর, এবং লোকালি সবকিছু কাজ করছে তা নিশ্চিত করার জন্য টেস্ট করার পর, আমি পরিবর্তনটি লাইভ ডিপ্লয় করলাম। এর সাথে, কোডবেস অবশেষে Firestore-মুক্ত হলো। সুন্দর।

ভবিষ্যৎ কাজ

ভবিষ্যতে, আমি gateway-তে patches সাপোর্ট করতেও চাই। এখন, gateway প্রতিটি আপডেটের জন্য পুরো ডকুমেন্ট JSON হিসেবে ফেরত পাঠায়। এটা একটু অপচয়মূলক, কিন্তু Hetzner-এ থাকার সুবাদে আমাদের egress কার্যত সীমাহীন। আমি এখনই এটা ইমপ্লিমেন্ট করব, কিন্তু এখনও এই যোগ করা জটিলতাকে যৌক্তিকীকরণ করতে পারছি না।

8 Ball Pool online multiplayer billiards icon