کاربر روی یک دکمه کلیک میکند. درخواست متوقف میشود. ده ثانیه سکوت. آنها دکمه جایگزین (fallback) را کلیک میکنند. حالا دو Job برای یک قصد واحد در حال اجرا هستند. نتیجه این میشود: اثرات جانبی تکراری، شارژ مضاعف و آشفتگی در دادهها که کل بعدازظهر شما را میبلعد.
این یک باگ فرانتاند نیست. غیرفعال کردن دکمه یا استفاده از تایمر debounce در React شما را نجات نخواهد داد. درخواست اول قبلاً در جریان (in flight) بوده است. شبکه صرفاً پاسخ را بلعیده است. اگر بکاند شما با هر درخواست ورودی به عنوان یک دستور کاملاً جدید برخورد کند، تلاشهای مجدد (retries) به یک ریسک بزرگ تبدیل میشوند. شما باید این مشکل را در طراحی API و طرحواره (schema) پایگاه داده خود حل کنید.
راه حل با یک تفکیک ساختاری ساده شروع میشود.
تفکیک Jobها از Attemptها
یک Job را به عنوان سندی ماندگار از آنچه کاربر میخواهد در نظر بگیرید. Job شامل مالک، پارامترها، ارائهدهنده هدف و قصد دقیق کاربر است. یک Attempt، تلاش مشخصی برای تحقق آن قصد است.
یک چاپخانه را تصور کنید. شما یک فایل را تحویل میدهید و آنها به شما رسید شماره ۴۵ را میدهند. آن رسید همان Job است. چاپخانه از پرینتر جوهرافشان استفاده میکند. پرینتر گیر میکند. این تلاش اول (attempt one) است. آنها فایل را به پرینتر لیزری منتقل میکنند. این تلاش دوم (attempt two) است. در طول این فرآیند، رسید شماره ۴۵ هرگز تغییر نمیکند. اگر چاپخانه برای هر پرینتری که امتحان میکرد یک رسید جدید صادر میکرد، شما سه بار هزینه پرداخت میکردید و سه نسخه ناخواسته دریافت میکردید.
پایگاه داده شما باید بازتابدهنده این ساختار باشد. یک جدول برای Jobها و جدول دیگری برای Attemptها داشته باشید. ردیف Job ثابت میماند در حالی که Attemptها زیر آن انباشته میشوند.
این تفکیک به شما کنترل میدهد. همچنین مکانی برای پیوست کردن یک کلید همارزی (idempotency key) فراهم میکند که در برابر نوسانات شبکه دوام میآورد.
الزام کلید همارزی (Idempotency Key) برای هر Job
هر درخواست POST که یک Job ایجاد میکند، باید یک کلید همارزی منحصربهفرد به همراه داشته باشد. این کلید متعلق به کاربر است، نه نشست (session). شناسه مالک (owner ID) و کلید را با هم ترکیب کنید، سپس یک محدودیت منحصربهفرد (unique constraint) در پایگاه داده برای این دو ستون اعمال کنید.
چرا محدودیت پایگاه داده؟ چون بررسی وجود رکورد در کد اپلیکیشن قبل از درج (insert)، یک شرایط رقابتی (race condition) است که منتظر وقوع است. دو درخواست کاملاً مشابه میتوانند در همان شکافِ یک میکروثانیه از هم عبور کنند. اجازه دهید پایگاه داده وظیفه اجراکنندگی را بر عهده بگیرد. اگر کاربری همان شناسه مالک و کلید را دو بار ارسال کند، درخواست دوم با خطای نقض منحصربهفرد بودن مواجه میشود و شما Job موجود را برمیگردانید. هر دو درخواست یک Job ID یکسان دریافت میکنند و هیچ کار تکراری شروع نمیشود.
در مورد محدوده (scope) سختگیر باشید. اگر کسی از کلید مجدداً استفاده کند اما محتوای دادهای (payload) را تغییر دهد، یک تداخل (conflict) برگردانید. کلید همارزی باید به یک قصد دقیق متصل باشد، نه فقط به کاربر. کلید یکسان با ورودی متفاوت به این معنی است که کلاینت دچار سردرگمی شده است و سیستم شما باید به جای حدس زدن، آن را رد کند.
محافظت از تغییرات وضعیت (State Transitions)
یک Attempt، یک تغییر وضعیت است، نه یک Job جدید. API شما باید از ایجاد یک Attempt جدید در صورتی که Attempt قبلی هنوز در وضعیت شروع یا وضعیت نامشخص (unknown) معلق است، خودداری کند.
دلیل این امر، زمانهای انتظار (timeouts) هستند. وقتی درخواست ارائهدهنده منقضی میشود، کلاینت شکست را میبیند، اما فرآیند سمت سرور ممکن است همچنان زنده باشد. خوشه GPU ممکن است هنوز در حال انجام درخواست استنتاج (inference) شما باشد. کانتینر ممکن است همچنان در حال نوشتن در blob storage باشد. اگر Attempt منقضی شده را به عنوان «شکستخورده» علامتگذاری کنید و بلافاصله Attempt دوم را اجرا کنید، در واقع دارید با اثرات جانبی تکراری قمار میکنید.
با یک timeout به عنوان یک وضعیت نامشخص برخورد کنید، نه یک وضعیت شکستخورده. تا زمانی که Attempt قبلی به یک وضعیت نهایی (terminal state) نرسیده یا توسط یک فرآیند خارج از باند (out-of-band) صراحتاً لغو نشده است، از اجرای Attemptهای جدید جلوگیری کنید. این وقفه میتواند ناخوشایند باشد و کاربر را مجبور به انتظار کند، اما از هرجومرج ناشی از تغییر همزمان منابع توسط دو کارگر جلوگیری میکند.
حل رقابتها با استفاده از Compare-and-Swap
سختترین مشکلات زمانی ظاهر میشوند که چندین Attempt به پایان میرسند. شاید سیستم شما Attempt اول را برای ارائهدهنده اصلی فرستاده باشد. پس از ده ثانیه سکوت، Attempt دوم را برای ارائهدهنده جایگزین (fallback) فرستاده باشد. حالا هر دو Attempt تمام شدهاند. شما نمیتوانید اجازه دهید هر دو نتیجه خود را در یک ردیف Job بنویسند.
از منطق Compare-and-Swap استفاده کنید. یک شماره نسخه (version number) به ردیف Job اضافه کنید. وقتی یک Attempt به پایان میرسد، یک عملیات بهروزرسانی (update) با شرایط زیر اجرا میکند:
- نسخه فعلی باید با آنچه Attempt در ابتدا خوانده است مطابقت داشته باشد.
- هیچ Attempt دیگری نباید قبلاً جایگاه نتیجه را تصاحب کرده باشد.
- اگر هر دو شرط برقرار بود، نتیجه را بنویسید و نسخه را افزایش دهید.
در اصطلاحات SQL، این شبیه به یک دستور update با ساختار WHERE id = $1 AND version = $2 AND completed_by IS NULL است. اگر دستور update صفر ردیف را برگرداند، یعنی Attempt دیگری قبلاً برنده شده است. درخواست دیررس باید نادیده گرفته شود. نتیجه آن را دور بریزید. نه ادغام کنید و نه الحاق کنید. کار را رها کنید. نتیجهای که دیر میرسد و برنده قبلی را بازنویسی میکند، باعث فساد داده (data corruption) میشود و تنها حرکت امن، نادیده گرفتن آن است.
این فرآیند پایان با ترتیب معکوس را بهخوبی مدیریت میکند. تلاش A اول از همه خارج میشود اما پس از سی ثانیه بازمیگردد. تلاش B دوم خارج میشود اما پس از پنج ثانیه بازمیگردد. تلاش B در عملیات compare-and-swap پیروز میشود. بهروزرسانی تلاش A هیچ ردیفی را تغییر نمیدهد. سیستم شما این رقابت (race) را ثبت کرده، پیلود منسوخ را نادیده میگیرد و به مسیر خود ادامه میدهد.
نقاط شکست را تست کنید
شما این باگها را در تستهای مسیرهای عادی (happy-path) پیدا نخواهید کرد. مجموعه تستهای شما باید نقاط شکست را هدف قرار دهد.
- شبیهسازی یک کلیک دوگانه. دو درخواست POST همزمان با یک کلید idempotency یکسان باید شناسههای شغلی (job IDs) یکسانی را برگردانند.
- ارسال همان کلید با ورودی متفاوت. انتظار پاسخ تداخل (conflict) را داشته باشید. اگر پارامترها متفاوت باشند، سیستم نباید بدون اطلاعرسانی، شغل موجود را برگرداند.
- ایجاد وضعیت timeout. بررسی کنید که شغل در یک وضعیت نامشخص (unknown state) قرار میگیرد، نه یک وضعیت شکستخورده (failed state)، و سیستم تا زمانی که ابهام برطرف نشود، از تلاشهای بعدی جلوگیری میکند.
- وادار کردن دو تلاش به پایان رسیدن با ترتیب معکوس. تأیید کنید که دومی که بازمیگردد بازنده است، حتی اگر اولین تلاشی که شروع شده، ارائهدهنده اصلی رسمی بوده باشد.
این تستها تجملاتی برای موارد خاص (edge-case) نیستند. آنها قراردادی هستند که API شما با بقیه سیستم منعقد میکند.
پیش از Failover، قصد ارائهدهنده را اعتبارسنجی کنید
اگر از یک ساختار چند-ارائهدهنده (multi-provider) استفاده میکنید، ممکن است وسوسه شوید که با مدلهای مختلف هوش مصنوعی مانند جایگزینهای یکسان برخورد کنید. آنها مسیر کد، کلاینت HTTP و طرحواره (schema) JSON یکسانی دارند. اما این به معنای رفتار یکسان آنها نیست.
یک مدل ممکن است یک کلید سطح بالا را توهم (hallucinate) بزند. مدل دیگر ممکن است قالببندی system prompt شما را نادیده بگیرد. اعتبارسنجی طرحواره (schema validation) خطاهای سینتکسی را شناسایی میکند، اما پاسخی را که منطق تجاری (business logic) شما قادر به تفسیر آن نیست، عبور خواهد داد. یک ارائهدهنده ممکن است یک JSON معتبر برگرداند که صرفاً با قالب پرامپت (prompt template) شما رفتار اشتباهی داشته باشد.
پیش از اجازه دادن به سوئیچ خودکار مدلها، تستهای مخصوص هر ارائهدهنده را اجرا کنید. تأیید کنید که مدل fallback واقعاً به ساختار خروجی شما در دماهای (temperature) پایین احترام میگذارد. بررسی کنید که پرامپت شما از طریق tokenizer آن ارائهدهنده بهدرستی رندر میشود. کل فرآیند رفت و برگشت (round trip) را با ورودیهای واقعی تست کنید. Failover خودکار تنها زمانی ایمن است که ثابت کرده باشید مدل fallback دارای همان قرارداد عملیاتی است.
برای هر قصد (Intent)، تنها یک شغل نگه دارید
مسیرهای fallback خوب هستند، اما تکثیر کنترلنشدهی آنها یک باگ است. هر لایه از پشته (stack) شما باید ارزیابی کند که آیا قبلاً دقیقاً همین وظیفه را دیده است یا خیر. لود بالانسر (load balancer)، هندلر API، پایگاه داده و ورکر (worker) همگی باید از یک هویت واحد پیروی کنند.
سیستم خود را بهگونهای بسازید که تلاشهای مجدد (retries) و fallbackها بهعنوان تلاشهای جدید تحت یک شغل پایدار ظاهر شوند. شغل را با یک کلید idempotency که در پایگاه داده پشتیبانی میشود، قفل کنید. از گذارها (transitions) محافظت کنید. تلاشها را با هم به رقابت بگذارید. اجازه دهید دقیقاً یکی پیروز شود. اینگونه است که از تبدیل شدن یک کلیک ساده کاربر به یک آخر هفتهی تمامعیار برای پاکسازی دادهها جلوگیری میکنید.
