ينقر المستخدم على زر. يتوقف الطلب. عشر ثوانٍ من الصمت. ينقر المستخدم على زر البديل (fallback). الآن يعمل أمران (jobs) مقابل نية واحدة. ينتهي بك الأمر بآثار جانبية مكررة، ورسوم مزدوجة، وفوضى في البيانات تستهلك بقية يومك.

هذه ليست مشكلة في الواجهة الأمامية (frontend). لن ينقذك زر معطل أو مؤقت debounce في React. الطلب الأول كان قيد التنفيذ بالفعل؛ الشبكة ببساطة ابتلعت الاستجابة. إذا كان نظامك الخلفي (backend) يعامل كل طلب وارد كأنه تعليمات جديدة تمامًا، فستصبح عمليات إعادة المحاولة عبئًا. تحتاج إلى إصلاح ذلك في تصميم واجهة برمجة التطبيقات (API) ومخطط قاعدة البيانات (database schema) الخاص بك.

يبدأ الحل بتقسيم هيكلي بسيط.

فصل المهام (Jobs) عن المحاولات (Attempts)

فكر في المهمة (job) كسجل دائم لما يريده المستخدم. فهي تلتقط المالك، والمعلمات (parameters)، والمزود المستهدف، والنية الدقيقة. أما المحاولة (attempt) فهي محاولة محددة لتحقيق تلك النية.

تخيل مطبعة. تسلمهم ملفًا ويعطونك تذكرة رقم 45. هذه التذكرة هي المهمة (job). تحاول المطبعة استخدام طابعة نفث الحبر (inkjet)، فتتعطل. هذه هي المحاولة الأولى. ثم ينقلون الملف إلى طابعة الليزر، وهذه هي المحاولة الثانية. طوال العملية، لا تتغير التذكرة رقم 45 أبدًا. إذا أصدرت المطبعة تذكرة جديدة لكل طابعة تجربها، فستدفع ثلاث مرات وتتلقى ثلاث نسخ غير مرغوب فيها.

يجب أن تعكس قاعدة البيانات الخاصة بك هذا الأمر. جدول واحد يحمل المهام (jobs)، وجدول آخر يحمل المحاولات (attempts). يظل صف المهمة ثابتًا بينما تتراكم المحاولات تحته.

هذا الفصل يمنحك التحكم، كما يمنحك مكانًا لإرفاق مفتاح تكرار (idempotency key) يصمد أمام انقطاعات الشبكة.

اشتراط مفتاح تكرار (Idempotency Key) لكل مهمة

يجب أن يحمل كل طلب POST ينشئ مهمة مفتاح تكرار فريدًا. هذا المفتاح يخص المستخدم، وليس الجلسة (session). ادمج معرف المالك (owner ID) مع المفتاح، ثم فرض قيدًا فريدًا (unique constraint) في قاعدة البيانات عبر هذين العمودين.

لماذا قيد قاعدة البيانات؟ لأن التحقق من الوجود في كود التطبيق قبل الإدخال هو حالة سباق (race condition) وشيكة الوقوع. يمكن لطلبين متطابقين أن يتسللا عبر فجوة ميكروثانية واحدة. اجعل قاعدة البيانات هي المنفذ. إذا أرسل المستخدم نفس معرف المالك والمفتاح مرتين، فسيتم رصد انتهاك التفرد في الطلب الثاني، وعندها تعيد المهمة الموجودة بالفعل. يحصل كلا الطلبين على نفس معرف المهمة (job ID)، ولا يبدأ أي عمل مكرر.

كن صارمًا بشأن النطاق. إذا أعاد شخص ما استخدام المفتاح ولكنه غير حمولة البيانات (payload)، فأرجع خطأ تعارض (conflict). يجب أن يرتبط مفتاح التكرار بنية دقيقة، وليس فقط بالمستخدم. استخدام نفس المفتاح مع مدخلات مختلفة يعني أن العميل مرتبك، ويجب على نظامك رفض الطلب بدلاً من التخمين.

حماية انتقالات الحالة (State Transitions)

المحاولة هي انتقال للحالة، وليست مهمة جديدة. يجب أن ترفض واجهة برمجة التطبيقات (API) الخاصة بك إنشاء محاولة جديدة إذا كانت المحاولة السابقة لا تزال عالقة في حالة "بدء" أو حالة "غير معروفة".

مهلة الانتظار (Timeouts) هي السبب. عندما تنتهي مهلة طلب المزود، يرى العميل فشلاً، ولكن العملية في جانب الخادم (server-side) قد تظل حية. قد تظل مجموعة وحدات معالجة الرسومات (GPU cluster) تعمل على طلب الاستدلال (inference request) الخاص بك. قد يظل الحاوية (container) يكتب في وحدة تخزين الكائنات (blob storage). إذا قمت بتحديد المحاولة التي انتهت مهلتها على أنها فاشلة وأطلقت فورًا محاولة ثانية، فأنت تخاطر بآثار جانبية مكررة.

تعامل مع انتهاء المهلة كحالة غير معروفة، وليس كحالة فشل. امنع المحاولات الجديدة حتى تصل المحاولة السابقة إلى حالة نهائية أو يتم إلغاؤها صراحةً بواسطة عملية خارج النطاق (out-of-band process). هذا التوقف قد يكون مزعجًا لأنه يجبر المستخدم على الانتظار، ولكنه يمنع أيضًا فوضى قيام عاملين (workers) بتعديل نفس الموارد في المراحل اللاحقة.

حل حالات السباق باستخدام "المقارنة والتبديل" (Compare-and-Swap)

تظهر أصعب المشكلات عندما تنتهي محاولات متعددة. ربما أطلق نظامك المحاولة الأولى تجاه المزود الأساسي، وبعد عشر ثوانٍ من الصمت، أطلق المحاولة الثانية تجاه البديل. الآن انتهت كلتا المحاولتين. لا يمكنك السماح لكلتيهما بكتابة نتائجهما في نفس صف المهمة.

استخدم منطق "المقارنة والتبديل" (compare-and-swap). أضف رقم إصدار (version number) إلى صف المهمة. عندما تنتهي محاولة ما، يتم تشغيل عملية تحديث بشروط:

  • يجب أن يتطابق الإصدار الحالي مع ما قرأته المحاولة في البداية.
  • يجب ألا تكون أي محاولة أخرى قد حجزت بالفعل خانة النتيجة.
  • إذا نجح كلاهما، اكتب النتيجة وزد الإصدار.

من منظور SQL، يبدو ذلك كجملة تحديث (update statement) مع WHERE id = $1 AND version = $2 AND completed_by IS NULL. إذا أعاد التحديث صفر صفوف، فهذا يعني أن محاولة أخرى قد فازت بالفعل. يجب تجاهل الوصول المتأخر؛ تخلص من نتيجته. لا تدمج، ولا تضف. تخلص من العمل تمامًا. النتيجة المتأخرة التي تمحو فائزًا سابقًا هي فساد للبيانات، والخطوة الآمنة الوحيدة هي تجاهلها.

This handles the reverse-order finish cleanly. Attempt A leaves first but returns after thirty seconds. Attempt B leaves second but returns after five seconds. Attempt B wins the compare-and-swap. Attempt A’s update touches zero rows. Your system logs the race, ignores the stale payload, and moves on.

Test the Breakpoints

You will not catch these bugs in happy-path testing. Your suite needs to target the fractures.

  • Simulate a double-click. Two simultaneous POST requests with the same idempotency key must return identical job IDs.
  • Send the same key with mismatched input. Expect a conflict response. The system must not silently return the existing job if the parameters differ.
  • Provoke a timeout. Verify the job lands in an unknown state, not a failed state, and that the system blocks further attempts until the ambiguity clears.
  • Force two attempts to finish in reverse order. Confirm that the second one to return loses, even if the first one to leave was the official primary provider.

These tests are not edge-case luxuries. They are the contract your API makes with the rest of the system.

Validate Provider Intent Before You Fail Over

If you run a multi-provider setup, you might be tempted to treat different AI models as interchangeable slots. They share the same code path, the same HTTP client, and the same JSON schema. That does not mean they behave the same.

One model might hallucinate a top-level key. Another might ignore your system prompt formatting. Schema validation catches syntax errors, but it will pass a response that your business logic cannot interpret. A provider might return valid JSON that simply does the wrong thing with your prompt template.

Run provider-specific tests before you allow automatic model switching. Confirm that the fallback model actually respects your output structure at low temperature. Verify that your prompt renders correctly through that provider’s tokenizer. Test the full round trip with real inputs. Automatic failover is only safe when you have proven that the fallback shares the same operational contract.

Keep One Job Per Intent

Fallback paths are good. Uncontrolled fallback multiplication is a bug. Every layer of your stack needs to evaluate whether it has already seen the exact task. The load balancer, the API handler, the database, and the worker must all respect the same identity.

Build your system so that retries and fallbacks surface as new attempts under one stable job. Lock the job down with a database-backed idempotency key. Guard the transitions. Race the attempts. Let exactly one win. That is how you keep a single user click from turning into a weekend of data cleanup.