Laravel 13.37: إصلاح سباق onOneServer وقفل ShouldBeUnique العالق

صدر Laravel 13.37 في 16 أكتوبر بإصلاحين لقفلين كانا يوقفان مهامك بصمت: مهمة مجدولة تُتخطّى على كل الخوادم، وjob فريد لا يُدفع بعد انقطاع الـ queue.

إذا كان تطبيقك يعمل على أكثر من خادم، أو تستخدم ShouldBeUnique، فهذا الإصدار يمسّ أخطر أنواع الأعطال: التي لا تُسجَّل فيها أي رسالة خطأ. معظم ما في الإصدار إصلاحات صغيرة (33 pull request بحسب صفحة الإصدار على GitHub)، لكن هذين الإصلاحين يستحقان وقفة، لأن فهم سببهما يعلّمك شيئًا عن الأقفال في الأنظمة الموزعة عمومًا.

ما الذي صدر في Laravel 13.37؟

بحسب ملاحظات الإصدار على GitHub، يضم الإصدار 33 طلب دمج. أبرزها:

  • إصلاح race condition في المهام التي تجمع onOneServer() وwithoutOverlapping() (#62007).
  • تحرير قفل الـ job الفريد عندما يفشل دفعه إلى الـ queue (#62010)، وتطبيق الفكرة نفسها على الـ queued listeners (#62045).
  • خيار --queue في أمر queue:failed (#61971)، وقبول queue:forget لعدة معرّفات (#61901).
  • إضافة runtime إلى حدث ScheduledTaskFailed (#61970).
  • دعم صيغة JPEG XL في Image API (#62019).
  • إصلاحات متعددة في Eloquent، منها cursor() الذي كان ينشئ نسخًا زائدة من الـ models (#62001)، وتحديد اسم الجدول للعمود الافتراضي في latest() وoldest() عند وجود joins (#62024).

سأركّز على الإصلاحين الأولين لأنهما يغيّران سلوكًا في الإنتاج دون أن تفعل أنت شيئًا.

الإصلاح الأول: مهمة مجدولة تُتخطّى على كل الخوادم

الحالة التي تتأثر بها

لنفترض أن لديك خادمين ينفذان schedule:run كل دقيقة، وتريد أن تعمل المهمة مرة واحدة فقط وألا تتداخل مع تشغيلها السابق:

use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:generate')
    ->everyMinute()
    ->onOneServer()
    ->withoutOverlapping();

onOneServer() يضمن أن خادمًا واحدًا فقط يأخذ المهمة. وwithoutOverlapping() يمنع بدء نسخة جديدة والنسخة السابقة ما زالت تعمل. كلاهما يعتمد على أقفال في الـ cache.

ما الخطأ؟

وفق وصف الـ pull request #62007 فإن فحص «هل المهمة قيد التشغيل؟» في CacheEventMutex::exists() كان يتم بمحاولة أخذ القفل ثم تحريره فورًا. أثناء هذه اللحظة القصيرة يكون القفل مأخوذًا فعلًا، فإذا فحص خادم آخر في اللحظة نفسها رأى القفل محجوزًا وظنّ أن المهمة تتداخل مع نسخة سابقة، فتخطّاها. وبما أن الفحص نفسه هو من أنشأ التعارض، قد يحدث أن يتخطاها كل خادم، فلا تُنفَّذ المهمة في تلك الدقيقة إطلاقًا.

كاتب الـ PR ذكر أن هذا حدث في مشروعه بنسبة تقارب 1-3% من التشغيلات. هذا رقمه هو لحالة واحدة وليس قياسًا عامًا، لكنه يوضح الخطر: لا استثناء ولا سجل، فقط مهمة لم تعمل.

كيف عُولج؟

الإصلاح يجعل exists() يسأل عن حالة القفل دون أخذه، عبر isLocked() بدل نمط «خذ ثم حرّر». لا يُكتب شيء أثناء الفحص، فلا يرى خادم آخر قفلًا وهميًا. لم أستطع التأكد من الصياغة النهائية للكود المدموج، لذلك أصف الفكرة بالكلمات ولا أنسخ الكود.

ماذا تفعل؟

  • حدّث إلى 13.37 إن كنت تجمع الخيارين على أكثر من خادم.
  • إن لاحظت سابقًا مهامًا «تفلت» أحيانًا دون تفسير، فهذا مرشح قوي للسبب.
  • إذا كنت تستخدم cache driver مخصصًا للأقفال، تأكد من أنه يدعم فحص الحالة دون أخذ القفل.

الإصلاح الثاني: قفل Job فريد يبقى عالقًا

الحالة

ShouldBeUnique يمنع وجود أكثر من نسخة من الـ job في الـ queue:

use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;

class SyncInvoice implements ShouldQueue, ShouldBeUnique
{
    public function __construct(public int $invoiceId) {}

    public function uniqueId(): string
    {
        return (string) $this->invoiceId;
    }
}

ما الخطأ؟

بحسب وصف #62010 فإن القفل الفريد يُؤخذ قبل دفع الـ job إلى الـ queue مباشرة. فإذا فشل الدفع نفسه، كأن ينقطع Redis أو يحدث throttling في SQS، يُرمى الاستثناء ويبقى القفل مأخوذًا. وإذا كان uniqueFor هو الافتراضي (صفر) فالقفل لا ينتهي من تلقاء نفسه، فتُتجاهل كل المحاولات اللاحقة لإرسال هذا الـ job بصمت حتى بعد عودة الـ queue للعمل.

كيف عُولج؟

عند فشل الدفع يُحرَّر القفل الذي أخذه هذا الاستدعاء بالذات ثم يُعاد رمي الاستثناء نفسه، فيرى الكود المستدعي الخطأ الأصلي. وتُنظَّف معلومات سياق الـ job الفريد في كل الأحوال. كذلك أُضيف سلوك مماثل للـ queued listeners في #62045.

نصيحة عملية

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

public int $uniqueFor = 3600; // ثانية

وإن كانت عندك أقفال عالقة من أعطال سابقة، فالترقية لا تنظّفها. تحقق من مفاتيح الأقفال في الـ cache وامسح العالق منها يدويًا.

كيف تعرف أنك تأثرت؟

الأعطال الصامتة تُكتشف بالمقارنة لا بالسجلات. بعض الأفكار العملية:

  1. للمهام المجدولة: سجّل بداية كل تشغيل ونهايته (مثلًا عبر ->before() و->after() أو عبر جدول تدقيق)، ثم ابحث عن دقائق غابت فيها المهمة تمامًا رغم أن الـ scheduler عمل على كل الخوادم.
  2. للـ jobs الفريدة: إذا لاحظت أن job معيّنًا «توقف عن العمل» بعد حادثة في Redis أو SQS ولم يعد يظهر في الـ queue، فافحص مفتاح القفل الخاص به في الـ cache. قفل بلا انتهاء صلاحية بعد انتهاء الحادثة علامة واضحة.
  3. للتأكد بعد الترقية: أنشئ job فريدًا في بيئة الاختبار، وأوقف الـ queue backend عمدًا، وأرسل الـ job، ثم أعد تشغيل الـ backend وأرسله مرة ثانية. يجب أن يُقبل في المرة الثانية.

هذا النوع من الاختبارات اليدوية يبدو مبالغًا فيه، لكنه الطريقة الوحيدة لتأكيد أن بيئتك بالذات، بالـ cache driver الذي تستخدمه، لا تتأثر.

تحسينات صغيرة تستحق الانتباه

  • queue:failed صار يقبل خيار --queue للتعامل مع الـ queue المطلوبة.
  • queue:forget يقبل الآن عدة معرّفات في أمر واحد.
  • ScheduledTaskFailed صار يحمل runtime، فتعرف كم استغرقت المهمة قبل أن تفشل.
  • إصلاح cursor() يقلل إنشاء نسخ زائدة من الـ models عند معالجة جداول كبيرة.

كيف تترقّى؟

الإصدار ضمن سلسلة 13.x، فالترقية عادة بأمر واحد:

composer update laravel/framework

ثم شغّل اختباراتك، وراقب المهام المجدولة والـ jobs الفريدة في الأيام الأولى بعد النشر. لم أرَ في ملاحظات الإصدار ما يشير إلى تغييرات كاسرة، لكن راجع القائمة الكاملة قبل التحديث.

الخلاصة

الدرس الأعم من هذا الإصدار: الفحص الذي «يأخذ ثم يحرّر» قفلًا ليس فحصًا بريئًا في نظام موزع، والقفل الذي يُؤخذ قبل عملية قد تفشل يجب أن يُحرَّر عند فشلها. إن كنت تشغّل scheduler على أكثر من خادم أو تعتمد على ShouldBeUnique، فحدّث إلى 13.37 واضبط uniqueFor صراحة.

المصادر