عند البناء باستخدام Node.js، يبدو التعامل مع الأخطاء سهلاً للغاية في البداية. تقوم بتغليف المسار (route) داخل try-catch وترسل رمز الحالة 500، ويقرر العميل ما سيفعله بعد ذلك. هذا النموذج يعمل بشكل جيد مع HTTP، لكنه ينهار تماماً بمجرد انتقالك إلى المهام الخلفية (background jobs). في نظام الطوابير (queue system)، لا يوجد عميل ينتظر. هناك فقط عامل (worker)، وحمولة بيانات (payload)، وعداد إعادة محاولة يتزايد في مكان ما في Redis أو RabbitMQ أو SQS. إذا تعاملت مع الإخفاقات بنفس الطريقة التي تتعامل بها مع طلبات الويب الفاشلة، فلن تفقد معاملة واحدة فحسب، بل ستعطل خط الإنتاج (pipeline) بالكامل، وتستنزف موارد الحوسبة، أو تتسبب في تعطل العمال بشكل متكرر بسبب نفس الرسالة المسمومة.
عقلية HTTP تنهار في المهام الخلفية
في دورة الطلب والاستجابة، تكون حلقة التغذية الراجعة فورية. ينقر المستخدم على زر، فيقوم الخادم بإرسال خطأ، ويرى المستخدم شاشة فشل. عملية التنظيف بسيطة. أما عامل الطابور (queue worker) فيعيش في عزلة؛ يسحب مهمة، ويعمل عليها لثوانٍ أو دقائق، ثم يؤكد النجاح. إذا حدث خطأ ما في المنتصف، فلا يملك الطابور أي فكرة عن السبب، هو يعلم فقط أن التأكيد لم يصل أبداً. وبناءً على إعداداتك، سيقوم بإعادة المحاولة، وربما إلى الأبد. يمكن لحمولة بيانات واحدة مشوهة أن تتنقل بين العمال مئات المرات، مما يهدر وحدة المعالجة المركزية (CPU) ويختبئ خلف المهام المشروعة التي تحتاج فعلياً إلى معالجة.
نوعان من الإخفاقات
القاعدة الأولى للطوابير المرنة هي التوقف عن معاملة كل خطأ بنفس الطريقة. أنت بحاجة إلى تصنيف الإخفاقات إلى مجموعتين فور حدوثها.
الإخفاقات القابلة لإعادة المحاولة (Retryable failures) هي إخفاقات عابرة. فكر في مهلات الشبكة (network timeouts)، أو حدود المعدل (rate limits) من واجهة برمجة تطبيقات خارجية، أو إعادة ضبط اتصال قاعدة البيانات لأن المجمع (pool) قد استُنفد لفترة وجيزة. هذه أعراض لنظام حي تحت الضغط، وقد تنجح في المحاولة التالية بعد دقيقتين من الآن.
الإخفاقات الدائمة (Permanent failures) هي بمثابة "الرسائل المسمومة" (poison pills). وتشمل هذه الحمولة المشوهة، أو أخطاء التحقق من المخطط (schema validation)، أو فقدان حقل مطلوب لأن خدمة سابقة (upstream service) غيرت عقدها (contract). إعادة محاولة هذه الأخطاء هي مضيعة للوقت المحض، فستفشل بنفس الطريقة في المحاولة المائة.
إذا لم تتمكن كتلة catch الخاصة بك من التمييز بين هذين النوعين، فإن طابورك يعمل دون رؤية.
النمط 1: تصنيف الأخطاء عند كتلة catch
يجب أن تكون كتلة catch الخاصة بالعامل هي الكود الأكثر دقة في الملف. عندما يظهر خطأ، افحصه فوراً. هل رمز الخطأ هو ECONNRESET أو مهلة زمنية (timeout)؟ ضعه في الطابور لإعادة المحاولة. هل هو SyntaxError أو رفض من Joi validation أو قيد مفتاح خارجي مفقود؟ انقله مباشرة إلى طابور الرسائل المهملة (dead-letter queue، أو DLQ)، ولا تحسبه ضمن حد إعادة المحاولة الخاص بك.
تتيح لك معظم مكتبات طوابير Node.js، بما في ذلك BullMQ و Bee Queue، تحديد استراتيجيات تراجع مخصصة (custom backoff strategies) وخطافات أخطاء (error hooks). استخدمها. لا ينبغي أبداً للخطأ الدائم أن ينتظر ويعيد المحاولة ثلاث مرات بشكل افتراضي؛ بل يجب إخلاؤه من الطابور الرئيسي لكي تتدفق بقية مهامك بسلاسة. يحافظ الـ DLQ على الحمولة الدقيقة وسياق الخطأ، مما يتيح لك إعادة تشغيل المهمة لاحقاً بعد إصلاح الخلل أو تعديل المخطط.
النمط 2: التراجع الأسي مع التذبذب (Exponential Backoff with Jitter)
إعادة المحاولة فوراً هو تصرف هجومي. إذا كانت قاعدة البيانات التابعة (downstream database) تعاني بالفعل تحت وطأة الحمل، فإن ضربها مرة أخرى كل ثانيتين من خمسين عاملاً سيقضي عليها تماماً. أنت بحاجة إلى التراجع وإعطاء النظام مساحة للتعافي.
استخدم التراجع الأسي (exponential backoff). عند الفشل الأول، انتظر ثانية واحدة. في الثاني، انتظر ثانيتين. ثم أربعاً، ثم ثمانياً، وصولاً إلى سقف معقول مثل خمس دقائق. لكن التوقيت وحده لا يكفي. إذا استخدمت كل مهمة فاشلة نفس الفاصل الزمني تماماً، فستتصادم جميعها عند انتهاء وقت التراجع. هذه الموجة المتزامنة، التي تسمى أحياناً "قطيع الرعد" (thundering herd)، يمكن أن تغمر الخدمة التي تحاول التعافي.
أضف التذبذب (jitter). خذ التأخير المحسوب وقم بتغييره بنسبة مئوية عشوائية، ربما من عشرة إلى عشرين بالمائة. تصبح الأربع ثوانٍ 4.2 أو 4.7 ثانية. هذه العشوائية البسيطة تشتت ذروة إعادة المحاولة وتمنع بنيتك التحتية من التعرض لضربات متتالية على شكل موجات.
النمط 3: التصميم من أجل التكرارية (Idempotency)
هنا تلتقي بنية الطوابير التحتية مع منطق الأعمال (business logic). تخيل مهمة تقوم بخصم مبلغ من عميل عبر مزود خدمة دفع. ينجح العامل في إرسال عملية الخصم، ولكن ينقطع الاتصال قبل أن يتمكن من تسجيل النجاح في قاعدة بياناتك أو تأكيد المهمة. يرى الطابور فشلاً، فيقوم بإعادة المحاولة، فيتم خصم المبلغ من العميل مرتين.
في Node.js، امنع حدوث ذلك من خلال جعل كل أثر جانبي (side effect) متماثل الأثر (idempotent). قم بإنشاء مفتاح تماثل (idempotency key) من معرف المهمة (job ID) أو معرف خاص بالأعمال. قبل إنشاء عملية الدفع أو إرسال البريد الإلكتروني أو تعديل المخزون، تحقق مما إذا كان العمل قد تم بالفعل. مرر هذا المفتاح عبر مسارك وصولاً إلى قاعدة البيانات وإلى أي واجهات برمجة تطبيقات (APIs) تابعة لجهات خارجية تقبله. قم بهيكلة مهامك بحيث يؤدي تشغيل نفس الحمولة (payload) عشر مرات إلى نفس النتيجة التي يحققها تشغيلها مرة واحدة. هذه العادة الواحدة تقضي على فئة كاملة من الأخطاء المالية وأخطاء سلامة البيانات.
النمط 4: تعامل مع طابور الرسائل المهملة (Dead-Letter Queue) كلوحة تحكم
إن الـ DLQ ليس مقبرة تُرمى فيها المهام الفاشلة لتُنسى، بل هو أداة تشغيلية، ويجب أن يكون أحد أكثر الأسطح التي تراقبها بدقة.
قم بإعداد تنبيهات تعمل عند زيادة عمق الـ DLQ. فحتى وجود رسالة واحدة في الـ DLQ يعني غالباً أن منطق التحقق (validation logic) لديك معطل، أو أن مخطط البيانات (schema) في المصدر (upstream) قد تغير، أو أن خدمة تابعة (downstream) ترسل بيانات غير صالحة لم تعد تتعرف عليها. هذه هي بالضبط الإشارات التي تريد رصدها قبل أن يبدأ العملاء في الشكوى. قم ببناء لوحة تحكم تتيح لك فحص الحمولة الخام (raw payload)، وتتبع الخطأ (stack trace)، والطابع الزمني (timestamp). جهّز دليل تشغيل (runbook): افحص الفشل، أصلح الكود، ثم أعد تشغيل الرسائل بالترتيب الصحيح. إذا كان تنفيذ الطابور لديك يدعم ذلك، فقم بتفعيل التنبيه بناءً على معدل النمو بالإضافة إلى العدد الإجمالي، لأن عملية نشر (deploy) واحدة خاطئة قد تغرق الـ DLQ في غضون دقائق.
النمط 5: عند الشك، أوقف العملية (Crash the Process)
يعمل Node.js على حلقة أحداث (event loop) واحدة داخل V8 isolate. إن رفض وعد (promise rejection) غير معالج أو...
