استحالة الحجز المزدوج: قيد في القاعدة لا فحص في الكود
محرك الحجز يبدو بسيطا: اعرض المواعيد المتاحة واستقبل الحجز. الجزء الصعب يختبئ في لحظة واحدة قصيرة.
السيناريو الذي يكسر الفحص
شخصان يفتحان الصفحة في الوقت نفسه، يرى كل منهما الموعد ذاته متاحا، ثم يضغط كلاهما خلال الثانية نفسها.
الكود الساذج:
هل الموعد محجوز؟ لا
هل الموعد محجوز؟ لا (الطلب الثاني، قبل أن يكتب الأول)
احجز (الأول)
احجز (الثاني)
كلاهما فحص، وكلاهما وجده متاحا، وكلاهما حجز. والفحص نفسه أدى عمله كما ينبغي، ففي اللحظة التي جرى فيها كان الموعد متاحا حقا.
لماذا لا تحله بالمحاولة مرتين
إعادة الفحص قبل الكتابة تضيّق نافذة السباق فقط، ويبقى في وسع طلبين متزامنين أن يدخلا فيما تبقى منها مهما صغر.
الحل الوحيد الصحيح
ضع قيد UNIQUE على الموعد داخل قاعدة البيانات نفسها. مهما تقارب الإدراجان في الزمن فالقاعدة تنفّذهما بالتتابع: ينجح الأول، ويفشل الثاني بخطأ قيد.
ويلتقط الكود ذلك الخطأ ويحوّله إلى رسالة يفهمها الإنسان: «هذا الموعد حُجز للتو، اختر غيره».
التعقيد الحقيقي: الإلغاء
الموعد الملغى يجب أن يعود متاحا لمن بعده. وهنا الفخ: القيد الفريد البسيط سيمنع إعادة حجز الموعد، لأن الصف الملغى ما زال موجودا في الجدول.
والعلاج فهرس فريد جزئي (partial unique index) يغطي الحجوزات النشطة وحدها:
CREATE UNIQUE INDEX ux_slot ON bookings(slot_start) WHERE status != 'cancelled';
هكذا يتعايش حجز ملغى وحجز جديد على الموعد نفسه، ولا يتعايش حجزان نشطان أبدا.
الاختبار الذي يثبت هذا
لا تصدّق أن القيد يعمل لأنك كتبته:
book(slot);
assert.throws(() => book(slot), /UNIQUE/); // الثاني يُرفض
cancel(slot);
book(slot); // وبعد الإلغاء يُقبل
هذا الاختبار عندي يعمل على ملفات الهجرة الحقيقية (migrations)، فالقيد الذي يجتاز الاختبار هو نفسه القيد الذي يعمل في الإنتاج.