একটি ভারী চ্যাট কীভাবে আমাদের API বন্ধ করে দিতে পারত
২৮ সেপ্টেম্বর, একটি release-এর প্রায় ১৭ মিনিট পর, আমাদের দুটি production API সার্ভারই একই মুহূর্তে মেমোরি ফুরিয়ে যায়, পুনরায় চালু হয়, এবং প্রায় পাঁচ মিনিট load balancer-এর কাছে ট্রাফিক পাঠানোর মতো কোনো সুস্থ সার্ভার ছিল না। আমাদের প্রথম সমাধান ছিল API-কে দ্বিগুণ মেমোরি দেওয়া। আসল কারণ সেদিনই পরে ধরা পড়ে: একটি নির্দিষ্ট চ্যাট খুললে API একটি একক অনুরোধে ৫৩ MB বার্তা লোড করছিল। চ্যাট ইতিহাস এখন পৃষ্ঠায় লোড হয়, যেখানে শুধু সংখ্যা নয়, আকারের সীমাও আছে, এবং সেই চ্যাটটি ক্র্যাশের বদলে ৫ MB-র প্রথম পৃষ্ঠা নিয়ে খোলে।
কী ঘটেছিল
আমাদের API প্রতিটি দুটি সার্ভারে একটি container হিসেবে চলে, একটি load balancer-এর পেছনে। প্রতিটি container-এর মেমোরি সীমা ছিল ৩৮৪ MiB। স্বাভাবিক সময়ে এটি ১৩০–১৪০ MiB ব্যবহার করে, তাই সীমাটি যথেষ্ট জায়গা আছে বলে মনে হয়েছিল।
দুটি container একসঙ্গে সীমায় পৌঁছায় এবং "JavaScript heap out of memory" বলে ক্র্যাশ করে। Heap হলো Node.js প্রক্রিয়া যে objects নিয়ে কাজ করে তার জন্য মেমোরি। দুটি সার্ভার একসঙ্গে ব্যর্থ হওয়ায় অনুরোধ পাঠানোর জায়গা আর ছিল না। দুটি সার্ভারের সেটআপে এটা সবচেয়ে খারাপ ধরনের আউটেজ: অতিরিক্ত সার্ভার তখনই কাজে আসে যখন ব্যর্থতা একই সময়ে ঘটে না।
প্রথম সমাধান: বেশি জায়গা
সময়টি deploy-এর দিকে ইঙ্গিত করছিল। একটি release-এর পর, deploy দ্বারা বাধাপ্রাপ্ত agent turn-গুলো আবার শুরু হয় এবং তাদের transcript আবার সংরক্ষণ করে, আর সেই কাজের হঠাৎ ঢল মেমোরি স্পাইকের স্পষ্ট উৎস বলে মনে হয়েছিল।
তাই প্রথম পরিবর্তনে API-এর সীমা ৩৮৪ MiB থেকে ৭৬৮ MiB করা হয়। দুটি সার্ভারেই একই দিনে সরাসরি প্রয়োগ করা হয়, এবং deploy configuration-এ কমিট করা হয় যাতে পরের deploy চুপিসারে ৩৮৪ ফিরিয়ে না আনে। হাতে করে একটি সার্ভারে করা সমাধান, যা পরের deploy উল্টে দেয়, একই আউটেজ ফিরিয়ে আনার একটি সাধারণ উপায়।
বেশি মেমোরি একটি যুক্তিসঙ্গত নিরাপত্তা-মার্জিন ছিল। কিন্তু এটি ব্যাখ্যা ছিল না।
আসল কারণ: একটি অনুরোধ, ৭১ MB
সেদিন পরে আমরা একটি চ্যাটের একটি কপিতে ক্র্যাশটি পুনরুৎপাদন করি, production-এর পুরোনো মেমোরি সীমা ব্যবহার করে। কপিটি কোনো deploy ছাড়াই একা API-কে ক্র্যাশ করায়।
যখন আপনি একটি প্রজেক্ট খোলেন, ওয়েব অ্যাপ চ্যাটের ইতিহাসের জন্য API-কে অনুরোধ করে। Endpoint-টি সর্বশেষ ৩০টি বার্তা পুরোপুরি ফেরত দিত। বেশিরভাগ চ্যাটের জন্য ৩০টি বার্তা ছোট। এই চ্যাটে, সর্বশেষ ৩০টি বার্তা ডেটাবেসে ৫৩ MB এবং response-এ ৭১ MB ছিল। এজেন্টের কয়েকটি উত্তর প্রতিটি ৪–১০ MB ছিল, বেশিরভাগই sub-agent-দের transcript: সহকারী এজেন্টরা, যাদের মূল এজেন্ট কাজের একটি অংশ সমান্তরালে করার জন্য শুরু করে, এবং যাদের সম্পূর্ণ কাজ উত্তরের সঙ্গে সংরক্ষিত থাকে।
সেই একটি response তৈরি করতে API-এর উপলব্ধ heap-এর চেয়ে বেশি মেমোরি লাগত। আর প্রজেক্টটি খোলা বা রিফ্রেশ করার প্রতিবার অনুরোধটি আবার পাঠানো হতো। একটি একক চ্যাট যে সার্ভারে পৌঁছাত সেটিকেই ক্র্যাশ করাতে পারত, এবং পেজ রিলোড করা যায় বলে তা দুটি সার্ভারেই পৌঁছাতে পারত।
"৩০টি বার্তা" কখনও আকারের প্রকৃত সীমা ছিল না। এটি ছিল একটি গণনার সীমা, যা স্বাভাবিক চ্যাটের জন্য ছোট হওয়ায় ঘটনাক্রমে কাজ করছিল।
সমাধান: আকার ও সংখ্যা উভয়ের সীমা সহ পৃষ্ঠা
ইতিহাসের একটি পৃষ্ঠা এখন ৩০টি বার্তা অথবা তাদের প্রায় ৪ MB, যেটি আগে আসে।
এটি দুটি ধাপে কাজ করে:
- আগে আকার জিজ্ঞেস করা। API ডেটাবেসকে সর্বশেষ বার্তাগুলোর ID ও সংরক্ষিত আকার চায়, তাদের বিষয়বস্তু ছাড়া। এই query বার্তাগুলো যত বড়ই হোক না কেন সস্তা।
- যা ফিট করে শুধু তাই লোড করা। সর্বশেষ থেকে সবচেয়ে পুরোনোর দিকে যেতে যেতে, পরের বার্তাটি যোগ করলে পৃষ্ঠা ৪ MB ছাড়ালে থামে। শুধু সেই সারিগুলো তখন পুরোপুরি লোড হয়।
দুটি নিয়ম এটি নিরাপদ রাখে:
- সর্বশেষ বার্তাটি সবসময় পৃষ্ঠায় যায়, তার আকার যা-ই হোক। যে পৃষ্ঠা কিছুই ফেরত দেয় না, তা চ্যাটটিকে আটকে দেবে, এবং পেছনে স্ক্রল করার কোনো উপায় থাকবে না।
- কিছু কাটা হয় না। কোনো বার্তা ছোট করা বা বাদ দেওয়া হয় না। বাকি চ্যাট "Scroll up for earlier messages" নিয়ন্ত্রণের মাধ্যমে লোড হয়, যা পুরোনো বার্তাগুলো আগে থেকেই ব্যবহার করত, একবারে একটি পৃষ্ঠা।
আমরা একই আকারের আরেকটি জায়গা পেয়েছি। আপনি একটি turn থামালে API শেষ কয়েকটি উত্তর পরীক্ষা করে দেখে সেগুলোর কোনোটি খালি কিনা এবং পরিষ্কার করা দরকার কিনা। এটি আগে আটটি পুরো বার্তা লোড করে এটি করত। মাল্টি-মেগাবাইট উত্তরের একটি চ্যাটে সেটি ছোট আকারে একই সমস্যা। এখন এটি ডেটাবেসকে একটি হ্যাঁ-বা-না প্রশ্ন করে: এই বার্তায় কি কোনো বিষয়বস্তু আছে? বিষয়বস্তু নিজে লোড না করেই।
কীভাবে পরীক্ষা করেছি
চ্যাটের কপিতে, পুরোনো ৩৮৪ MiB সীমায় চালিয়ে:
- প্রথম পৃষ্ঠা ৫ MB। আগে একই অনুরোধে API ক্র্যাশ করত।
- একই সময়ে চ্যাট খোলা পাঁচটি ব্রাউজার ট্যাবে মেমোরি সর্বোচ্চ ১৫৪ MB।
- পুরো চ্যাট স্ক্রল করে পেছনে গেলে সব ১৯০টি বার্তা ফেরত আসে, প্রতিটি একবার করে, সঠিক ক্রমে।
পৃষ্ঠা বাছাইয়ের যুক্তিরও unit test আছে, যার মধ্যে একটি সেই চ্যাটের সর্বশেষ বার্তাগুলোর প্রকৃত আকার দিয়ে তৈরি। প্রথম পৃষ্ঠায় একটি বার্তা আসে, জানায় আরও আছে, এবং পরের পৃষ্ঠা শুরু হয় তার পরের বার্তা থেকে।
আমরা যা শিখেছি
- গণনা আকারের সীমা নয়। যেকোনো endpoint যা অসীমে বাড়তে পারে এমন কিছুর "সর্বশেষ N" ফেরত দেয়, তার একটি byte budget-ও দরকার। ডেটা শেষ পর্যন্ত সেই ফাঁক খুঁজে বের করবেই।
- সবচেয়ে স্পষ্ট সন্দেহভাজন সবসময় কারণ নয়। Deploy অতিরিক্ত কাজের একটি আসল ট্রিগার ছিল, এবং বেশি মেমোরি মূল্যবান ছিল। কিন্তু ক্র্যাশটি deploy ছাড়াও পুনরুৎপাদিত হয়েছিল, এবং তা পুনরুৎপাদন না করলে আমরা শুধু মেমোরি বাড়িয়ে থেমে যেতাম।
- একই অনুরোধ পেলে দুটি সার্ভার একসঙ্গে ব্যর্থ হয়। Redundancy একটি সার্ভার মারা যাওয়া থেকে রক্ষা করে। যে অনুরোধ যে সার্ভারেই পৌঁছায় সেটিকেই মেরে ফেলে, তার থেকে রক্ষা করে না, বিশেষত যদি ব্যবহারকারী রিফ্রেশ করে সেটি স্বাভাবিকভাবে আবার পাঠায়।
- সমাধান config-এ দিন, শুধু বাক্সে নয়। মেমোরি বাড়ানো একই দিনে deploy configuration-এ গিয়েছিল, তাই পরের deploy তা ধরে রেখেছিল।
এটি আমাদের এজেন্টের আউটপুট নিয়ে সাম্প্রতিক দ্বিতীয় মেমোরি গল্প। প্রথমটি ছিল একটি status label যা ৬৯০ MB string সারিবদ্ধ করেছিল। Mythex-এ আপনি sub-agent ব্যবহার করলে, তারা কীভাবে কাজ করে তা ডকসে পড়তে পারেন।