باگ ۱۶ ساله‌ی SQLite، و شش ماه زمان که Tailscale صرف پیدا کردنش کرد!

Tailscale چیه/کیه؟

Tailscale یک سرویس ایجاد شبکه بر پایه‌ی WireGuard است؛ یعنی شما می‌تونید با استفاده از پروتکل وایرگارد، شبکه دلخواه خودتون رو بسازین و مدیریت کنید. مثلا دستگاه‌های شما را، هرجای دنیا که باشن، توی یک شبکه‌ی مش (mesh) خصوصی به هم وصل بشن؛ بدون درگیر شدن با پیکربندی VPNهای سنتی، فورواردینگ پورت، یا تنظیمات پیچیده فایروال‌ها. ایده‌ی اصلی‌اش اینه که هر دستگاه مستقیماً به دستگاه‌های دیگه وصل بشه، نه از طریق یک سرور مرکزی که ترافیک رو رله کنه. اما برای اینکه این اتصالات نظیر-به-نظیر شکل بگیره، دستگاه‌ها باید جایی رو داشته باشن که فهرست بقیه‌ی اعضای شبکه، کلیدها، و تنظیمات دسترسی رو ازش بگیرن؛ که بهش control plane تیل‌اسکیل می‌گن.

از بیرون، این control plane یک اندپوینت عمومی واحده: controlplane.tailscale.com. اما پشت صحنه، به صورت shard شده نگهداری می‌شه؛ سرورهای هماهنگ‌کننده‌ی مستقلی که هرکدام مسئول مجموعه‌ای از تیل‌نت‌ها هستن. هر شارد یک پایگاه‌داده‌ی SQLite داره که تمام اطلاعات تیل‌نت‌های روی اون شارد رو نگهداری می‌کنه، و فقط یک پروسه‌ی Go به‌صورت انحصاری به اون پایگاه‌داده دسترسی نوشتن داره. این دقیقاً الگوی single-writer است که SQLite براش طراحی شده، و تیل‌اسکیل از سال ۲۰۲۲ ازش استفاده می‌کنه؛ نه برای اینکه SQLite جذاب یا خاصه، بلکه دقیقاً برعکس: چون خیلی پایدار و قابل‌اتکاست. با این توضیح می‌شه حدس زد چقدر حجم دیتای زیاده و چقدر پایداریش مهمه.

وقتی یه «boring technology» هم به مشکل می‌خوره!

از پاییز سال گذشته، uptime تیل‌اسکیل افت محسوسی داشته. و ماجرا از یک خطا در پایپلاین بکاپ شروع می‌شه: هر چند دقیقه یک اسنپ‌شات کامل از پایگاه‌داده گرفته می‌شده و به S3 آپلود می‌شده؛ روالی که از اوایل ۲۰۲۳ بدون مشکل کار کرده بود. یک روز، PRAGMA integrity_check روی یکی از این بکاپ‌ها خطا می‌ده و پایگاه‌داده corrupt شده بوده.

خرابی SQLite اتفاق نادریه، اما در مقیاس بزرگ حتی اتفاقات نادر هم تکرار می‌شن. توی شش ماه، این اتفاق ۱۹ بار می‌افته. نکته‌ی خوب ماجرا این بوده که control plane فقط متادیتای تیل‌نت‌ها رو نگهداری می‌کنه، و کلید رمزنگاری خصوصی یا ترافیک شبکه‌ی کسی رو هرگز اونجا ذخیره نمی‌کنن؛ پس این اتفاق ریسک نشت داده نداشته. اما نکته‌ی بد این بوده که با هر خرابی، پروسه‌ی control plane روی اون شارد باید متوقف می‌شد تا پایگاه‌داده تعمیر یا بازیابی بشه. طی این پنجره‌ی زمانی، دستگاه‌های آنلاینِ همون تیل‌نت به هم متصل می‌موندن، اما دستگاه‌های جدید نمی‌تونستن فهرست بقیه رو بگیرن و وصل بشن، و کنسول ادمین و API هم در دسترس نبوده.

شکار بدون سرنخ

چیزی که این باگ رو واقعاً سخت می‌کرده، نبودِ هیچ الگوی قابل‌تکرار بوده. تیم مهندسی تغییرات اخیر کدهاشون رو بررسی می‌کردن؛ ولی چیزی پیدا نمی‌شده؛ بخش‌هایی از کد که با SQLite کار می‌کرده سال‌ها دست‌نخورده مونده بودن. دنبال وجه مشترک بین حوادث می‌گشتن؛ ولی مشکلات نه به یک شارد خاص محدود بود، نه به یک مشتری، نه به یک ویژگی، نه به ساعت خاصی از روز. حوادث هم فاصله‌ی زمانی منظمی نداشتن؛ گاهی چند ساعت، گاهی چند هفته، و یک بار حتی شش هفته سکوت کامل قبل از بازگشت باگ درست موقع کریسمس!

بدون محرک مشخص، بازتولید مصنوعی باگ ممکن نبوده. و تنها راه، گذاشتن تله‌ی خطایابی توی محیط پروداکشن و صبر برای دفعه‌ی بعد بوده؛ دقیقاً همون کاری که هیچ تیمی دوست نداره روی یک پایگاه‌داده‌ی زنده انجام بده.

نکته‌ی جالب توجه اینه که که تیل‌اسکیل به‌جای اکتفا به تلاش داخلی، یک قرارداد پشتیبانی حرفه‌ای با توسعه‌دهندگان اصلی SQLite بست. این تصمیم بعداً معلوم شد نقطه‌ی عطف ماجراست، چون باگی که در نهایت پیدا شد، خارج از کدِ خودِ تیل‌اسکیل، بلکه توی دل SQLite بود.

WAL و checkpoint: پس‌زمینه‌ای که باگ توش زندگی می‌کرد

برای فهم خود باگ، اول باید دو مفهوم را روشن کنم.

دیتابیس SQLite از واحدهایی به اسم page تشکیل شده؛ بلوک‌های کوچیک اطلاعات. وقتی رکوردی رو آپدیت می‌کنیم، برخی از این صفحات باید با نسخه‌ی جدید جایگزین بشن (بسته به حجم رکوردهای تغییر یافته). حالت پیش‌فرض SQLite اینه که این جایگزینی مستقیماً توی فایل اصلی پایگاه‌داده انجام بشه. اما برای عملکرد و همزمانی بهتر، تیل‌اسکیل از حالت Write-Ahead Logging یا WAL استفاده می‌کنه: توی این حالت، صفحات جدید به جای فایل اصلی دیتابیس، اول توی یک فایل جداگانه به اسم WAL نوشته می‌شن. این یعنی پروسه‌هایی که می‌خوان داده‌ها رو بخونن، همزمان با نوشتن، نسخه‌ی سازگار قدیمی رو از فایل اصلی بخونن، بدون اینکه بلاک بشن. (همون چیزی که توی MySQL و PostrgreSQL و SQL Server و… داریم)

اما فایل WAL نمی‌تونه تا ابد رشد کنه؛ حتی به بیان ساده‌تر رشد زیادش هم عموما دردسرسازه. توی یه نقطه‌، محتواش باید به فایل اصلی پایگاه‌داده منتقل بشه و به این فرایند «checkpoint» می‌گیم. در حالت عادی، خودِ SQLite تصمیم می‌گیره چه زمانی checkpoint بزنه و کل ماجرا برای توسعه‌دهنده نامرئیه. اما تیل‌اسکیل، برای اینکه بتونن بکاپ‌های سریع و سازگار بگیرن، کنترل دستیِ فرایند checkpoint رو دستشون گرفته بودن؛ یعنی خودش تصمیم می‌گرفت کِی و چقدر checkpoint بزنه، و این کار رو با فرکانس بالایی انجام می‌دادن. همین انحراف از مسیر استاندارد بود که بعداً به‌عنوان مظنون اصلی مطرح شد.

سرنخی که از یه پایپلاین جانبی بیرون زد!

حالا در کنار تلاش برای پیدا کردن ریشه‌ی باگ، تیم باید سرویسش رو هم سرپا نگه می‌داشته. برای این کار یه پایپلاین transaction logging ساختن: که هر دستور SQL که پایگاه‌داده رو تغییر می‌ده، توی یه فایل لاگ جداگانه ذخیره می‌شد. چون SQLite یه پایگاه‌داده‌ی single-writer با تراکنش‌های serializable است، تاریخچه‌ی تراکنش‌ها کاملاً خطی و قطعیه؛ چیزی که توی یک پایگاه‌داده‌ی چند-نویسنده مثل Postgres یا MySQL این‌قدر ساده نیست. با پخش دوباره‌ی این تراکنش‌ها روی آخرین بکاپ سالم، می‌شد پایگاه‌داده رو به آخرین وضعیت سالمش رسوند، بدون نیاز به دست زدن به فایل خراب.

این پایپلاین کار می‌کرد، اما نکته‌ی مهم‌تر این بود که توی دو مورد، لاگ‌های تراکنش درست پخش نشدن! بررسی دقیق‌تر نشون داد که داده‌ای که توی یه تراکنش نوشته و commit شده بود، برای تراکنش‌های بعدی به‌طور غیرمنتظره‌ای نامرئی بود؛ بدون اینکه هیچ خطایی صادر بشه. یک نوشتن، بی‌هیچ ردی، ناپدید شده بود. این چیزی بود که به صورت تئوری غیرممکنه!

معیار دیگه‌ای هم که مشکوک بود: در طول حوادث خرابی، متریک‌ها نشون می‌دادن که SQLite تعداد بیشتری صفحه از WAL کپی کرده تا اونچه که واقعاً توی WAL موجود بوده! مثلا اگر ده صفحه توی WAL هست ولی بیست صفحه کپی می‌شده، یعنی یه چیزی توی منطق checkpoint درست کار نمی‌کرده.

پیدا کردن باگ: یک شیم دیباگ توی لایه‌ی فایل‌سیستم مجازی

معماری داخلی SQLite چند لایه داره: پارسر و مولد کد که SQL رو به ساختارهای داخلی تبدیل می‌کنه؛ pager که این ساختارها رو به صفحات تقسیم می‌کنه؛ و در نهایت لایه‌ی فایل‌سیستم مجازی (VFS) که واقعاً مسئول نوشتن روی دیسکه. توسعه‌دهنده‌های SQLite برای این پرونده یک شیم جدید به نام tmstmpvfs نوشتنن که دور لایه‌ی VFS می‌پیچیه و اطلاعات ردیابی و لاگ اضافه از تغییرات پایگاه‌داده رو بیرون می‌کشه. این شیم رو توی محیط زنده‌ی تیل‌اسکیل مستقر کردن و منتظر حادثه‌ی بعدی نشستن.

لاگ‌های همون شیم بود که در نهایت باگ رو لو دادن: یه data race نادر، بین یه عملیات checkpoint و یه تراکنش نوشتنِ همزمان. اگه نوشتن دقیقاً در لحظه‌ی خاصی از یک checkpoint اتفاق بیفته، فرایند checkpoint دچار سردرگمی می‌شه؛ و فکر می‌کنه بعضی صفحات از WAL به فایل اصلی کپی شدن، در حالی که نشدن. اون صفحات هم هرگز نوشته نمی‌شن، اون داده هم برای همیشه گم می‌شه، و چون صفحات دیگه‌ای (مثل ایندکس‌ها) بهش اشاره می‌کنن، فایل پایگاه‌داده ناسازگار و در نتیجه corrupt می‌شه.

توسعه‌دهنده‌های SQLite اسم این باگ رو «WAL-Reset bug» گذاشتن، و برآوردشون این بود که این باگ دست‌کم ۱۶ سال توی کد SQLite بوده؛ به‌قدری نادر که خودشون برای تست کردنش مجبور شدن کدی بنویسن که عمداً شرایط بروزش رو شبیه‌سازی کنه. دلیل اینکه تیل‌اسکیل بیشتر از اکثر کاربرهای SQLite با این باگ برخورد می‌کرده هم روشن شد: کنترل دستی و تهاجمی روی checkpoint، احتمال برخورد با یک race condition نادر رو به‌شدت بالا می‌برده.

استقرار، یک عارضه‌ی جانبی غیرمنتظره، و اثبات نهایی

نهایتاً راه‌حل به‌صورت SQLite 3.52.0 منتشر شد. تیل‌اسکیل اول روی چند شارد canary مستقرش کرد، و بعد، وقتی همه‌چیز آروم به‌نظر رسید، به کل control plane گسترشش داد. اما بلافاصله بعد از استقرار کامل، مانیتور بکاپ برای ۱۳ پایگاه‌داده‌ی مختلف هشدار قرمز داد.

این بار خودِ ماجرا هم آموزنده بود: این خرابی واقعی نبود، بلکه ناشی از یک باگ دومِ SQLite در مدیریت ایندکس‌های محاسباتی (expression index) بود. اگر روی یک مقدار محاسبه‌شده ایندکس بسازین و بعد نحوه‌ی محاسبه تغییر کنه، ایندکس با مقادیر ناسازگار می‌مونه و PRAGMA integrity_check اون رو به اشتباه خرابی گزارش می‌کنه. در مورد تیل‌اسکیل، این به یک تغییر ظریف در رفتار گِرد‌کردنِ تبدیل متن-به-عدد اعشاری برمی‌گشت که همون نسخه‌ی ۳.۵۲ برای بهینه‌سازی اضافه کرده بود؛ و چون شاردهای canary تایم‌استمپی نداشتن که این رفتار رو تحریک کنه، در فاز canary هم دیده نشده بودن.

به همین دلیل، توسعه‌دهنده‌های SQLite نسخه‌ی ۳.۵۲.۰ رو پس گرفتن و به‌جاش ۳.۵۱.۳ رو منتشر کردن که فقط شامل رفع WAL-Reset بود. تیل‌اسکیل هم دقت تایم‌استمپ‌های خودش رو به ثانیه‌ی صحیح کاهش داد تا از ابهام تبدیل متن-به-عدد خلاص بشه.

اما نبودِ حادثه به‌تنهایی اثبات کافی نبود؛ یک بار قبلاً هم شش هفته سکوت و ارامش داشتن و باگ برگشته بود. برای اطمینان واقعی، تیم درایور SQLite رو طوری پچ کرد که هر بار یک نوشتن و یک WAL-reset همزمان رخ می‌داد، یک هشدار ثبت بشه؛ بدون اینکه دیگه خرابی‌ای رخ بده. دو ماه صبر کردن تا بالاخره اون هشدار یک‌بار فعال شد، درست همون‌طور که پیش‌بینی می‌شد، و پایگاه‌داده سالم موند. این اثبات نهایی بود که فیکس واقعاً همون چیزی رو می‌گیره که باید بگیره.

نکته‌ی این ماجرا…

این داستان یک یادآوری ساده اما ارزشمند داره: استفاده از تکنولوژی boring به شیوه‌ای غیراستاندارد، خودش نوعی ریسکه. مسیرهای رایج و پیکربندی‌های استاندارد به‌شدت تست‌شده و قابل‌اتکا هستن؛ اکثر کاربرهای SQLite هرگز چنین چیزی نمی‌بینن. هرچی تیل‌اسکیل انجام می‌داد کاملاً مستند و پشتیبانی‌شده بود، اما با گرفتن کنترل دستیِ checkpoint و اجراش با ریتم تهاجمی، از مسیر پرتردد استاندارد خارج شده بودن؛ و همین کافی بود تا با شرایطی مواجه بشن که به‌قدری نادر بود که ۱۶ سال زیر رادار مونده بود.

و نکته بعدی داستان؛ اینه که مهندسی؛ و دانش و تجربه هنوز هم لازمه؛ GenAI قادر به حل این مشکل نبود؛ مهم اینه که جایی باشیم و کاری کنیم که مهندس نیاز داشته باشه نه فقط تکنسین یا اپراتور!

دیدگاهتان را بنویسید