কোর ডাম্প এপিডেমিওলজি: ১৮ বছরের বাগ ঠিক করা
আমাদের ডেটা অবকাঠামোর জটিল crash debug করতে population-level analysis ব্যবহার.
OpenAI-এর মডেল ও এজেন্টগুলো অনুমান চলাকালে, অর্থাৎ মডেলগুলো যখন আপনার প্রশ্ন নিয়ে ভাবছে, প্রাসঙ্গিক ডেটা খুঁজতে ক্রমেই স্কেলযোগ্য ডেটা অবকাঠামোর ওপর নির্ভর করছে. এই সেবাগুলোর কিছু C++-এ লেখা, যার সিস্টেমের ওপর নিম্ন-স্তরের নিয়ন্ত্রণ আমাদের পারফরম্যান্স বাড়াতে এবং মেমোরি ব্যবহার কমাতে সাহায্য করে. স্কেল বাড়ার সঙ্গে এই দক্ষতার সুবিধাগুলো গুরুত্বপূর্ণ, কিন্তু C++-এ মেমোরি সুরক্ষা না থাকায় বাগ ভুল বা অস্তিত্বহীন মেমোরি ঠিকানায় লিখে ক্র্যাশ ঘটাতে পারে.
কয়েক মাস আগে আমরা Rockset সেবার ভেতর থেকে কিছু ক্র্যাশ লক্ষ্য করি; এটি আমাদের ChatGPT ডেটা অবকাঠামোর একটি বিশেষায়িত অংশ, যা অনেক ডেটা প্লাগইন ও কথোপকথনে অনুসন্ধানের জন্য গুরুত্বপূর্ণ. এই প্রতিটি ক্র্যাশে, একটি সাধারণ C++ ফাংশন শেষ হয়ে যেন একটি ভুয়া ঠিকানায় ফিরে যাচ্ছিল, ফলে নির্দেশ পয়েন্টার আর কোডের দিকে না থাকায় kernel প্রোগ্রামটি থামিয়ে দিচ্ছিল. কখনও স্ট্যাক ফ্রেমের রিটার্ন-ঠিকানা স্লটটি NULL ছিল. কখনও stack pointer CPU register-টিই ৮ বাইট সরে গেছে বলে মনে হচ্ছিল, যেন স্বাভাবিক নির্বাহের মাঝখানে কোনোভাবে %rsp decrement করা হয়েছে. দুই ক্ষেত্রেই রিটার্নের সময় ক্র্যাশটি ঘটেছিল.
অ্যাপ্লিকেশন কোডের জন্য এগুলো স্বাভাবিক ব্যর্থতার ধরন নয়. কোনো stray write শুধু সংরক্ষিত return address-এ পড়া সম্ভব, কিন্তু অত্যন্ত অসম্ভব. inline assembly, setcontext বা longjmp ছাড়া %rsp-কে ৮ বাইট misalign করে এমন বাগ আরও অদ্ভুত, কারণ compiled code ওই register সরাসরি শুধু function prologue ও epilogue-এ সমন্বয় করে, আর এগুলোর কোনোটি আমরা ব্যবহার করি না. আমরা বা ChatGPT যত hypothesis ভাবতে পেরেছিলাম, প্রতিটির বিরুদ্ধেই শক্ত প্রমাণ ছিল, তাই বাগটি অসম্ভব মনে হচ্ছিল.
যেটিকে আমরা একটি সমস্যা ভেবেছিলাম, শেষে দেখা গেল সেটি একই সময়ে কাকতালীয়ভাবে ধরা পড়া দুটি সম্পর্কহীন বাগ. প্রথমটি, একটি Azure host-এ নীরব hardware corruption, যেখানে CPU ঠিকভাবে গণনা করছিল না. দ্বিতীয়টি, GNU libunwind-এ ১৮ বছরের পুরোনো একটি race condition, বহুল ব্যবহৃত open source library-তে অদেখা একটি বাগ.
এই পোস্টটি হলো কীভাবে আমরা epidemiologist-এর মতো ভেবে এবং পুরো crash population নিয়ে উচ্চমানের data set তৈরি করে আপাতদৃষ্টিতে ব্যাখ্যাতীত ক্র্যাশ শনাক্ত ও ঠিক করেছি, সেই গল্প.
প্রথমে Rockset নিয়ে আরও গভীরে যাই. এটি search ও real-time analytics-এর জন্য cloud-native data system, যা OpenAI-তে আমরা অনেক internal use case-এ ব্যবহার করি, যেমন sync connector; Rockset ২০২৪ সালে OpenAI অধিগ্রহণ করে. ওয়ার্কস্পেসের knowledge base-এর হালনাগাদ index বজায় রাখতে streaming update ব্যবহার করা হয়, যাতে ChatGPT প্রশ্নের উত্তর দেওয়া বা action নেওয়ার সময় প্রাসঙ্গিক তথ্য খুঁজতে পারে.
Rockset-এর execution layer C++-এ লেখা. C++ ভাষা CPU-তে low-level access দেয়, যা performance ও efficiency-এর জন্য ভালো, কিন্তু এর অর্থ application bug invalid memory access ও segfault ঘটাতে পারে. এগুলো খুঁজে বের করতে আমরা crash ঘটলে stack trace log করার জন্য folly-এর fatal signal handler ব্যবহার করি, এবং পরে বিশ্লেষণের জন্য সংশ্লিষ্ট core dump, অর্থাৎ crash-এর মুহূর্তে program state-এর snapshot, Azure blob storage-এ upload করি. Rockset-এর সব query processing leaf replicate করা থাকে, ফলে crash-এর client impact কমে. তবে প্রতিটি segfault এমন একটি bug নির্দেশ করে, যা আমাদের reliability ও quality goal পূরণে ঠিক করা দরকার.
আমাদের প্রাথমিক পদ্ধতি ছিল core-গুলোকে প্রচলিত debugging problem হিসেবে দেখা: কয়েকটি core dump খুব খুঁটিয়ে দেখা, hypothesis তৈরি করা, এবং একে একে বাতিল করা.
বেশির ভাগ crash DocumentTree::updateDocument নামের একটি method-এ ঘটেছিল. এই crash-গুলোতে মনে হচ্ছিল updateDocument কোনো অজানা function X call করেছে, X সক্রিয় থাকা অবস্থায় stack corrupt হয়েছে, তারপর X এমন address-এ return করেছে যা executable code নয়. কিছু ক্ষেত্রে X-এর সদ্য popped frame-টি valid দেখাচ্ছিল, শুধু তার saved return address ছিল NULL. অন্য ক্ষেত্রে stack pointer নিজেই ভুল দেখাচ্ছিল, কিন্তু পরের valid frame তখনও updateDocument বলে মনে হচ্ছিল.
stack কখন corrupt হচ্ছিল তা আমরা জানতাম না, ফলে অনুসন্ধানের ক্ষেত্র বিশাল হয়ে যায়. updateDocument বড় একটি method এবং এতে অনেক inlining হয়, তাই X-এর candidate সংখ্যা সামাল দেওয়ার মতো ছিল না.
এটি কি আমাদের C++ code-এর bug ছিল? compiler বা linkage issue? আমাদের runtime library-গুলোর কোনো একটিতে সমস্যা? signal delivery বা context switching ঘিরে Linux kernel bug? আরও বিরল কিছু? এটি যদি stray write হয়, তবে আমাদের ASAN staging environment সেটি ধরল না কেন?
সমস্যাটির সব occurrence শনাক্ত করতে আমরা application-level log ব্যবহার করার চেষ্টা করি, কিন্তু stack-corruption bug শুধু log থেকে classify করা কঠিন, কারণ logged stack trace নিজেই corrupt বা অনুপস্থিত থাকে. আমরা এমন কোনো log query তৈরি করতে পারিনি যাতে false positive ও false negative দুটোই না থাকে. আমরা হাতে আরও core পরীক্ষা করে কিছু অতিরিক্ত example পাই, কিন্তু সেই প্রক্রিয়া বিশ্বাসযোগ্য data set দেওয়ার মতো নয়, অতিরিক্ত শ্রমসাধ্য ছিল.
তদন্তের এই পর্যায়ে আমরা ভুলভাবে hardware bug বাতিল করি, কারণ একাধিক region ও hardware type জুড়ে crash দেখেছিলাম, তাই তখনও শুধু software cause খুঁজছিলাম. কয়েক দিন আমরা একটি মাত্র misaligned-%rsp crash নিয়ে খুব গভীরে যাই, stack ও register content ব্যবহার করে crash-এর আগের history পুনর্গঠন করি. এতে কিছু সম্ভাব্য clue মিলেছিল, কিন্তু সব bug-এর কারণ একই—এই প্রাথমিক সিদ্ধান্ত ছাড়তে না পারায় আমরা অচলাবস্থা কাটাতে পারিনি.
আমাদের তদন্তের মোড়ে পৌঁছানোর আগে core file থেকে আমরা কী ধরনের তথ্য বের করছিলাম তা ব্যাখ্যা করা গুরুত্বপূর্ণ.
Rockset -fno-omit-frame-pointer দিয়ে compile করা, তাই active stack frame সব সময় %rbp দিয়ে পৌঁছানো যায়, আর caller-গুলো frame pointer-এর linked list তৈরি করে.
Linux x86_64-এ AMD64 System V ABI %rsp-এর নিচের ১২৮ বাইট red zone হিসেবেও reserve করে. ওই অঞ্চল userspace code-এর জন্য available এবং গুরুত্বপূর্ণভাবে, ABI contract-এর অংশ হিসেবে kernel প্রতিশ্রুতি দেয় যে signal deliver করার সময় এটি clobber করবে না.
return-এর পরের crash debug করতে red zone ছিল কেন্দ্রীয়, কারণ এটি return-এর আগের কিছু তথ্য ধরে রাখে. SIGSEGV trigger হলে folly-এর fatal signal handler crashing thread-এর stack-এ চলে. যে stack frame আর active নয়, কারণ তাদের function return করেছে, শেষ ১২৮ বাইট বাদে সেগুলো signal handler দ্বারা clobber হবে. তাই আমরা বলতে পারি, “X-এর সদ্য popped stack frame valid দেখাচ্ছিল, শুধু return address ছিল NULL.” red zone inactive frame-এর কিছু অংশ ধরে রাখে, কখনও বা শুধু একটি inactive frame-এর tail.
আমরা একটি misaligned-stack crash পেয়েছিলাম যেখানে সংশ্লিষ্ট সব function ছিল খুব ছোট. এতে আমরা দেখতে পাই যে তুলনামূলক সহজ একটি function চলার সময় %rsp misaligned হয়েছে, এবং এরপরও আরও call সফল হয়েছে. active function শেষ পর্যন্ত return করার চেষ্টা করলেই শুধু program crash করেছিল. ওই code path-গুলোর কোনোটিই exception, inline assembly, setcontext বা longjmp ব্যবহার করেনি, তাই core যেভাবে ইঙ্গিত করছিল stack pointer সত্যিই সেভাবে বদলে থাকলে userspace code-এর কোনো সম্ভাব্য bug তা ব্যাখ্যা করত না.
এতে আমরা kernel-এর দিকে ঝুঁকলাম.
Rockset অধিকাংশ program-এর তুলনায় signal বেশি আক্রমণাত্মকভাবে ব্যবহার করে. Query execution-কে data exchange করা বহু lightweight task-এ ভাগ করা হয়. উচ্চ-QPS workload দক্ষভাবে সামলাতে এটি গুরুত্বপূর্ণ, কিন্তু বহু query-র কাজ একই thread pool-এ multiplex হওয়ায় per-query CPU accounting অস্বস্তিকর হয়ে যায়.
আমাদের সমাধান হলো coarse_thread_cputime_clock নামে একটি ব্যবস্থা, যা clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...)-এর যথেষ্ট সস্তা approximation দেয়, যাতে প্রতিটি task boundary-তে sample নেওয়া যায়. timer_create API ব্যবহার করে সময় পেরোনোর বিভিন্ন ধারণা, CPU time জমা হওয়া সহ, ভিত্তি করে periodic signal delivery schedule করা যায়. আমরা প্রতি কয়েক millisecond CPU time পরপর একটি signal (SIGUSR2) deliver করার schedule করি; তখন signal handler একটি thread-local value update করে. অনেক task execute করার সময় coarse clock এগোতে না দেখলেও, সব delta যোগ করলে একটি query-র প্রকৃত CPU time-এর পক্ষপাতহীন estimate পাওয়া যায়.
আমরা এত ঘনঘন signal deliver করি বলে context switching বা signal delivery ঘিরে বিরল kernel bug যুক্তিযুক্ত মনে হচ্ছিল. আমরা bug report, kernel source code, এবং Azure-specific kernel patch পড়ে সময় কাটাই. আমরা stress test চালিয়েছিলাম. সম্পর্কিত মনে হয় এমন কিছুই খুঁজে পাইনি.
তখন আমরা এক ধাপ পিছিয়ে অন্য পদ্ধতি নেওয়ার সিদ্ধান্ত নিই.
এ ধরনের সমস্যা debug করার দুইটি বিস্তৃত উপায় আছে.
একটি হলো একরকম ডাক্তারের মতো কাজ করা: একজন রোগীর দিকে মনোযোগ দেওয়া, অনেক test চালানো, এবং বিস্তারিত evidence থেকে একটি case diagnose করার চেষ্টা করা.
অন্যটি হলো epidemiologist-এর মতো কাজ করা: পুরো population দেখা এবং এমন pattern আছে কি না জিজ্ঞাসা করা যা একক case প্রকাশ করতে পারে না. বাগটি কি নির্দিষ্ট কোনো release-এ শুরু হয়েছিল? এটি কি কোনো একটি hardware SKU, অর্থাৎ নির্দিষ্ট CPU ও server model, কোনো region, বা কোনো kernel version-এর সঙ্গে সম্পর্কিত? একটি syndrome মনে হওয়ার ভেতরে কি একাধিক পৃথক cluster লুকিয়ে আছে?
আমরা বেশির ভাগ সময় doctor mode-এই ছিলাম. মূল পরিবর্তনটি ছিল উচ্চমানের population data সংগ্রহ করা দরকার বলে সিদ্ধান্ত নেওয়া.
সমস্যার সব instance স্বয়ংক্রিয়ভাবে খুঁজে বের করার আগের চেষ্টা ব্যর্থ হয়েছিল, কারণ আমরা log-এর ওপর text search ব্যবহার করছিলাম. core dump-গুলোতে অনেক বেশি তথ্য থাকে, কিন্তু হাতে দেখে তা scale করা যায়নি. আমরা core dump স্বয়ংক্রিয়ভাবে বিশ্লেষণ করতে পারে এমন pipeline তৈরিতে শ্রম দেওয়ার সিদ্ধান্ত নিই.
আমরা ChatGPT দিয়ে এমন একটি script লিখিয়েছিলাম যা প্রতিটি core file-এর prefix download করে, register বের করে, log ব্যবহার করে known false positive filter করে, এবং crash-কে return-to-null, misaligned-stack, বা other হিসেবে label করে. এরপর আমরা আগের বছরের প্রতিটি production Rockset core dump-এর ওপর parallel-এ সেই script চালাই.
এটাই ছিল মোড় ঘোরানোর মুহূর্ত.
পরিষ্কার data set হাতে আসতেই correlation সঙ্গে সঙ্গে দেখা গেল. যেটিকে আমরা একটি অদ্ভুত bug ভাবছিলাম, সেটি আসলে দুটি আলাদা crash population.
return-to-null core-গুলো বহু cluster ও geographic region জুড়ে ছড়িয়ে ছিল. সাম্প্রতিক সময়ে তাদের frequency বেড়েছিল, কিন্তু স্পষ্ট start date বা পরিষ্কার infrastructure boundary ছিল না.
misaligned-stack crash-গুলো সম্পূর্ণ ভিন্ন দেখাচ্ছিল. সবগুলোই একটি region থেকে এসেছে, clear start date ছিল, এবং দীর্ঘ সময় ধরে চলা নোডে কখনও ঘটেনি. যদিও এতে একাধিক Azure VM, অর্থাৎ cloud-hosted virtual machine, জড়িত ছিল, pattern দেখে মনে হচ্ছিল খারাপ hardware থাকা একটি physical machine যে VM-ই তাতে পড়ছে তার জন্য সমস্যা তৈরি করছে.
সেই মুহূর্তেই বুঝলাম আমরা মনে মনে দুটি bug গুলিয়ে ফেলেছিলাম. দুটি bug-এর counterexample মিশিয়ে ফেলায় আমরা একক সুসংগত explanation খুঁজে পাচ্ছিলাম না.
Kubernetes নোড ও timestamp-এর পরিষ্কার list হাতে থাকায় আমরা misaligned-stack crashগুলোকে একটি মাত্র physical host পর্যন্ত trace করতে পেরেছিলাম, যেটি denylist করা সহজ ছিল.
কয়েক সপ্তাহ stress testing করার পরও controlled environment-এ ওই host-এ register corruption পুনরুৎপাদন করতে পারিনি. তবে সমস্যাযুক্ত host service থেকে সরিয়ে দেওয়ার পর misaligned-stack crashগুলো মিলিয়ে যায়.
খারাপ host সরানো স্থায়ী সমাধান নয়, কারণ এটি একই সমস্যার নতুন occurrence ঠেকায় না. তবে আমরা software বদলাতে পারি, যাতে একই ধরনের issue আবার ঘটলে তা সহজে detect ও handle করা যায়. আমরা fatal signal handler উন্নত করে register state যোগ করেছি, যাতে শুধু log থেকেই recurrence detect করা যায়, core dump দরকার না হয়. আমরা control plane বদলেছি, যাতে VM সাধারণত recycle না করে reuse করা হয়; এতে infrastructure stack-এর আমাদের স্তরে bad-node detection অনেক সহজ হয়. আমরা আমাদের runbook এবং team-এর mental model-ও update করেছি, যাতে এই সম্ভাবনাটি অন্তর্ভুক্ত থাকে.
bad-host crash আলাদা করার পর বাকি return-to-null coreগুলো নিয়ে যুক্তি করা অনেক সহজ হলো. আগে আমরা exception unwinding বাতিল করেছিলাম, কারণ ভেবেছিলাম আমাদের counterexample আছে: এমন code path-এ crash যেখানে exception নিশ্চিতভাবেই ব্যবহার হয়নি. কিন্তু সেই counterexample-গুলো সবই hardware-corruption cluster থেকে এসেছিল.
এটি মাথায় রেখে বাকি coreগুলো আবার দেখার পর দেখি আমাদের সিদ্ধান্ত ছিল একেবারে উল্টো: সব crash-ই exception unwinding-এর সময় ঘটছিল.
C++ exception throw করলে runtime-কে বের করতে হয় কোন catch block সেটি পাবে এবং পথে কোন destructor বা cleanup handler চলবে. compiler এই metadata emit করে, কিন্তু আসল matching runtime-এ dynamically ঘটে.
Exception unwinding আসলে throw invoke করা function করে না; resulting compiled code যে helper function call করে, সেগুলো করে. ওই runtime routine-গুলো stack পরীক্ষা করে, stack-এ পাওয়া function সম্পর্কে metadata আনে, dynamically cleanup handler ও catch block খোঁজে, তারপর ওই location-গুলোর একটিতে control transfer করে. control transfer-এর মধ্যে মাঝের সব stack frame unwinding থাকে, helper function-এর frame-সহ.
কার্যগতভাবে, এটি স্বাভাবিক call ও return-এর চেয়ে longjmp বা fiber switch-এর অনেক কাছাকাছি. Callee save register এবং stack frame register %rbp ও %rsp restore করতে হয়.
আমাদের binary দুটি library-র সঙ্গে link করে, যেগুলোতে C++ exception unwinding করা function-এর implementation আছে: libgcc এবং GNU libunwind. dynamic linker GNU libunwind-এর definition-গুলোই বেছে নিয়েছিল. এতে আমরা অবাক হয়েছিলাম; symbol versioning rule-এর কারণে libgcc implementation জিতবে আশা করেছিলাম; কিন্তু running binary inspect করে দেখা গেল তা নয়.
এই পর্যায়ে আমাদের working hypothesis বদলে গেল, কারণ একটাই bug আছে ভাবার সময় করা আরেকটি assumption আমরা শিথিল করলাম.
হয়তো আমরা কোনো ordinary function-কে NULL-এ return করতে দেখছিলাম না. হয়তো আমরা unwind transfer দেখছিলাম, কার্যত setcontext-ধাঁচের register restore, যেখানে control transfer হওয়ার আগে destination instruction pointer NULL হয়ে গিয়েছিল. অন্যভাবে বললে, stack-এর incorrect return address slot নয়, unwind library থেকে আসা incorrect data.
এতে সমস্যার পরিসর নাটকীয়ভাবে সংকুচিত হলো. হয় GNU libunwind wrong destination state compute করছিল, নয়তো right state compute করছিল এবং apply করার আগে কিছু সেটি corrupt করছিল.
আমরা GNU libunwind source পড়ে দেখি এটি stack-এ একটি ucontext_t synthesize করে, cleanup handler-এর frame-এর জন্য desired register state পূরণ করে, তারপর ওই struct-এর pointer একটি internal assembly routine-কে দেয়: _Ux86_64_setcontext.
এই পর্যায়ে আমাদের হাতে সব অংশ ছিল.
synthesized ucontext_t stack frame-গুলোর একটিতে থাকে, যা _Ux86_64_setcontext ওই function চলার সময় unwind করে. _Ux86_64_setcontext কি %rsp বদলানোর পর struct থেকে পড়ছিল, যখন struct আর active stack-এর অংশ ছিল না? তাহলে আমাদের ঘনঘন SIGUSR2-এর মতো signal delivery-তে এটি clobber হওয়ার ঝুঁকিতে পড়ত.
উত্তর ছিল হ্যাঁ.
আমরা যে GNU libunwind version ব্যবহার করছিলাম, তাতে _Ux86_64_setcontext-এর শেষ ছয়টি instruction এখানে; এগুলো mostly mov instruction, যা memory থেকে destination register-এ load করে:
(%rdi stack-allocated ucontext_t-এর দিকে point করে, এবং UC_MCONTEXT_* macro শুধু সেই fixed offset-এ expand হয় যেখানে একটি নির্দিষ্ট register stored থাকে.)
প্রথম instruction-টিই race window-এর শুরু. এটি %rsp update করে active stack-এর নতুন bottom-এর দিকে point করায়. এটি ঘটার সঙ্গে সঙ্গে %rdi যে struct-এর দিকে point করে তা আর active stack বা red zone-এর অংশ থাকে না, এবং kernel-এর জন্য আর off-limits থাকে না.
সাধারণত এতে সমস্যা হয় না, কিন্তু ঠিক সঠিক, অর্থাৎ ভুল, মুহূর্তে signal এলে kernel %rsp-128-এ signal frame তৈরি করবে. এটি %rdi নির্দেশিত memory overwrite করতে পারে.
পরের instruction UC_MCONTEXT_GREGS_RIP(%rdi) পড়ার আগে তা ঘটলে restored instruction pointer corrupt হতে পারে. আমাদের crash-গুলোতে এটি NULL হয়ে গিয়েছিল.
এটাই bug.
এই assembly আমাদের বিভ্রান্ত করা একটি পর্যবেক্ষণও ব্যাখ্যা করে: কেন function X-এর আগের stack frame-এর return address slot-এ NULL ছিল.
setcontext সব register, %rdi সহ, restore করার জন্য লেখা হয়েছিল, তাই control transfer-এর শেষ মুহূর্তে UC_MCONTEXT_GREGS_RIP(%rdi) পড়তে সেটি ওই register ব্যবহার করতে পারে না. তার বদলে এটি আগে value পড়ে, stack-এ save করে, আরও কয়েকটি register restore করে, তারপর saved value পড়ে control transfer করতে retq ব্যবহার করে.
core-গুলোতে যা “একটি function NULL-এ return করেছে” বলে মনে হচ্ছিল, তা আসলে ছিল “unwinder stack-এ একটি target return address synthesized করেছিল, কিন্তু transfer শেষ হওয়ার আগে সেই target corrupt হয়ে গিয়েছিল.” আমরা ধরে নিয়েছিলাম return address slot-এর corruption অবশ্যই in-place ঘটে, কারণ আমরা এমন কোনো জায়গা জানতাম না যেখানে corruptible data ইচ্ছাকৃতভাবে return address slot-এ লেখা হয়.
এই bug-টিকে অবিশ্বাস্য মনে হওয়ার কারণ হলো race window-টি কত সংকীর্ণ. এই ধরনের race condition-এ external event, অর্থাৎ signal, অন্য thread নেওয়া দুই ধাপের মাঝখানে ঘটতে হয়. ধাপগুলো যত কাছাকাছি, race condition ঘটার সম্ভাবনা তত কম.
এই ক্ষেত্রে vulnerable window আক্ষরিক অর্থেই এক instruction চওড়া! %rsp বদলানোর পরে, কিন্তু পরের instruction %rip load করার আগে signal deliver হতে হবে. আধুনিক super-scalar out-of-order CPU-তে এমন কয়েকটি simple instruction প্রতি cycle-এ চালানো যায়, তাই race window প্রায় একশ picosecond.
এই race খুঁজে পাওয়ার পর আমাদের প্রথম প্রতিক্রিয়া ছিল, observed crash rate ব্যাখ্যা করার জন্য এটি নিশ্চয়ই অতিরিক্ত বিরল. fleet জুড়ে আমরা দিনে এক ডজনের বেশি return-to-null crash দেখছিলাম. exception cleanup-এর সময় এক-instruction race কি সত্যিই এর কারণ হতে পারে?
আমরা Fermat estimation-এর দিকে ফিরলাম. যদি vulnerable window প্রায় second হয় এবং প্রতি second CPU time-এ SIGUSR2 আসে, তাহলে প্রতিটি exception cleanup handler বা catch block-এর race হারার সম্ভাবনা আনুমানিক .
Rockset তার internal ingest backpressure mechanism-এর অংশ হিসেবে exception ব্যবহার করে. একটি overloaded host প্রতি second-এ প্রায় exception throw করতে পারে. এর মানে backpressure ব্যবহারকারী একটি host-এর mean time between failures second, অর্থাৎ কয়েক ঘণ্টায় একটি crash. fleet scale-এ observed crash frequency ব্যাখ্যা করতে এটি যথেষ্টেরও বেশি.
GNU libunwind bugটি পুরোনো, ১৮ বছরেরও বেশি পুরোনো; C++ exception unwinding সমর্থন করা প্রথম x86_64 version-এই এটি ছিল.
তাহলে এখন এটি দেখা দিল কেন?
crash rate আনুমানিকভাবে কত exception throw হচ্ছে এবং কত signal deliver হচ্ছে তার সমানুপাতিক. signal handler কত stack ব্যবহার করে তার ওপরও এটি নির্ভর করে.
এই তিন অক্ষেই Rockset অস্বাভাবিক. স্বাভাবিক overload control-এর অংশ হিসেবে আমরা high rate-এ exception throw করি; coarse_thread_cputime_clock-এর কারণে অস্বাভাবিক ঘনঘন SIGUSR2 deliver করি; এবং এ বছরের শুরুতে merged signal হিসাব করতে timer_getoverrun call যোগ করে SIGUSR2 handler-কে বেশি stack ব্যবহার করিয়েছি.
শেষ পরিবর্তনটি গুরুত্বপূর্ণ ছিল বলে মনে হয়. handler যথেষ্ট কম stack ব্যবহার করলে সেটি stale ucontext_t memory-তে পৌঁছে overwrite নাও করতে পারে. ওই পরিবর্তনের আগে আমরা এই crash একেবারেই দেখি না. পরিবর্তনের পর rate কমই ছিল, যতক্ষণ না আমরা কিছু use case-এর load বাড়াই যা backpressure mechanism-কে চাপ দেয়.
অন্যভাবে বললে, libunwind bug সব সময়ই ছিল, কিন্তু আমাদের exception rate, signal rate, এবং handler stack usage-এর গুণফল মাত্র সম্প্রতি সেই threshold পেরিয়েছে যেখানে এটি operationally visible হয়েছে.
এই mechanism এটিও ব্যাখ্যা করে কেন hardware bug এবং libunwind bug দুটোই বেশির ভাগ সময় DocumentTree::updateDocument-এর ভেতরে crash করেছে. libunwind-এর crashগুলো এই method-এর দিকে প্রবলভাবে biased ছিল, কারণ ingest backpressure প্রয়োগ করতে exception throw করার মুহূর্তে এটি সব সময় active থাকে. %rsp-misalignment crashগুলোর ক্ষেত্রেও এটি বেশি নির্বাচিত হয়েছিল, কারণ bad hardware নোডটি এমন SKU-এর ছিল যা আমরা bulk ingest-এর জন্য ব্যবহার করি, এবং সেটি তার CPU time-এর বেশির ভাগ এই method-এ ব্যয় করে.
আমাদের তাৎক্ষণিক mitigation ছিল GNU libunwind থেকে libgcc-এর unwinder-এ switch করা. এটি নিজে থেকেই ভালো tradeoff ছিল: libgcc-এর implementation lock contention কমাতে অনেক কাজের সুফল পেয়েছে, যা large VM-এ scale করার সময় গুরুত্বপূর্ণ.
আমরা GNU libunwind-এ self-contained reproducer এবং একটি fix(একটি নতুন উইন্ডোতে খোলে) upstream করেছি, এবং যাচাই করেছি যে অন্য unwinder-গুলোতে একই ধরনের issue নেই.
এই debugging journey আমাদের dynamic linking, DWARF unwind metadata, Linux signal delivery, System V ABI, এবং C++ exception machinery-র নির্দিষ্ট detail সম্পর্কে অনেক কিছু শিখিয়েছে. কিন্তু মূল শিক্ষা ছিল তার চেয়েও সহজ.
সবচেয়ে গুরুত্বপূর্ণ ধাপটি clever assembly reading বা detail-এর গভীর জ্ঞান ছিল না. তা ছিল একটি উচ্চমানের data set তৈরি করা. এই data set না থাকলে আমরা দুটি পৃথক phenomenon-কে এক গল্পে মিশিয়ে বিভ্রান্তি থেকে যুক্তি দিয়ে বের হওয়ার চেষ্টা করছিলাম. accurate ও complete population data হাতে আসার পর সমস্যার structure স্পষ্ট হয়ে গেল: একটি crash population খারাপ host-এর, আর অন্যটি libunwind-এর একটি race-এর. data ভালো হতেই debugging সহজ হলো.
Rockset-এর মতো infrastructure system-এর জন্য এটি খুব গুরুত্বপূর্ণ. এই তদন্ত deep instrumentation, automated investigation, এবং আমাদের operational tooling-এর ধারাবাহিক উন্নতির প্রতি আমাদের commitment আরও দৃঢ় করেছে. Reliability শুধু bug ঘটার পর তা ঠিক করা নয়; এটি এমন data, workflow, ও skill তৈরি করা, যা অসম্ভব সমস্যাকে diagnose ও solve করা যায় এমন সমস্যায় পরিণত করে.
লেখকরা
By Nathan Bronson, Member of Technical Staff


