قضية المعرّف المفقود في Meilisearch

في ThriveDesk نستخدم Meilisearch لإبقاء البحث في محادثات العملاء سريعًا، إلى أن بدأت مهمة إعادة فهرسة تُسقط مستندات برسالة خطأ غامضة عن معرّف مفقود. هذه قصة كيف تتبّعنا السبب.

مخطط سير عمل الفهرسة في Meilisearch

كل شيء يعمل جيدًا إلى أن تحاول أن تكون ذكيًا في الفهرسة

في ThriveDesk ننقل عددًا كبيرًا من محادثات العملاء، ونستخدم Meilisearch لإبقاء البحث فيها سريعًا. في عصر يوم هادئ، وفي منتصف مهمة إعادة فهرسة، ظهرت لي هذه الرسالة:

Document doesn't have an `id` attribute

كان لكل مستند في الدفعة id، فلم يكن لرسالة الخطأ “document doesn't have an id attribute” أي معنى. راجعت مرتين. تبيّن أن الخطأ كان خطئي أنا بالكامل، وأن إصلاحه تطلّب التخلي عن إحدى وسائل الراحة في Laravel Scout والتحدث مع Meilisearch مباشرة. إليك ما حدث.

لماذا لا نستخدم Laravel Scout فقط؟

Scout هو الطريق السهل حين لا تريد سوى “keep this model in sync with the index”: تجعل النموذج قابلًا للبحث ثم تنساه. المشكلة أن Scout يخفي الشيء الذي يهمنا عندما يرتفع الحجم، وهو معرّف المهمة (task ID).

كل عملية غير متزامنة في Meilisearch، مثل إضافة المستندات أو حذفها، تُرجع taskUid. يمكنك الاستعلام عن هذا المعرّف دوريًا لتعرف هل نجح العمل فعلًا. لكن Scout لا يُظهره. وعندما تدفع آلاف المحادثات عبر خط معالجة، فإن “I called searchable() , so it probably worked” ليست استراتيجية مراقبة. أردنا تسجيل كل مهمة وُضعت في الطابور، وإعادة محاولة المهام الفاشلة، والتحقق من الحالة الفعلية للفهرس.

لذلك نتجاوز مساعد Scout ونستدعي بأنفسنا الدالتين addDocuments() وdeleteDocuments() في Meilisearch عبر المحرك الأساسي، ثم نأخذ taskUid ونخزّنه.

الخطأ الحقيقي: مصفوفة متفرقة

كنت أجمّع المحادثات في دفعات للفهرسة، وبعضها مكرر، فأزلت المكررات باستخدام ->unique(). وكانت النتيجة هكذا:

$filtered = collect([
    0 => ['id' => 1],
    1 => ['id' => 2],
    3 => ['id' => 4], // index 2 is gone
]);

كل عنصر ما زال يحتوي على id، فيبدو الأمر سليمًا للوهلة الأولى. لكن انظر إلى المفاتيح: 0 و1 و3. حين أزالت unique() العنصر المكرر عند الفهرس 2، أبقت المفاتيح الأصلية وتركت فجوة. صارت المصفوفة متفرقة، وهذه الفجوة هي المشكلة كلها. وسبب الخلل هو PHP وليس Meilisearch.

حين تتلقى json_encode() مصفوفة مفاتيحها أعداد صحيحة متسلسلة 0 و1 و2 و… دون فجوات، تُخرج مصفوفة JSON:

[{"id":1},{"id":2},{"id":4}]

أما إذا وُجدت فجوة في هذه المفاتيح، فلا تستطيع PHP التعامل معها كقائمة، فتعود إلى ترميزها ككائن JSON:

{"0":{"id":1},"1":{"id":2},"3":{"id":4}}

تتوقع addDocuments() مصفوفة من المستندات. وإذا أعطيتها كائنًا، فإنها تقرأ الحمولة كلها على أنها مستند واحد، حقوله العليا هي “0” و“1” و*“3”. هذا الـdocument الواحد لا يحتوي على حقل id*، فكانت رسالة الخطأ تقول الحقيقة حرفيًا. أنا من أعطيتها الشكل الخطأ.

الإصلاح

تتخلص ->values() من المفاتيح وتعيد ترقيمها من الصفر، وهذا كافٍ لاستعادة مصفوفة JSON:

$task = $engine->index($this->index)->addDocuments(
    $uniqueSearchableDocuments->filter()->values()->all(),
    (new Conversation)->getKeyName()
);

والأمر نفسه في مسار الحذف:

$task = $engine->getIndex($this->index)->deleteDocuments(
    $shouldMakeUnsearchable->values()->all()
);

الوسيط الثاني في addDocuments() هو اسم المفتاح الأساسي. نقرؤه من النموذج عبر getKeyName() بدل كتابة id ثابتًا، فيستمر العمل إن تغيّر ذلك يومًا.

أبرز ما تعلمناه

تحتفظ filter() وunique() بمفاتيح المجموعة الأصلية. هذا مقصود، وهو في الغالب ما تريده داخل Laravel. لكنه يكفّ عن أن يكون ما تريده في اللحظة التي تغادر فيها البيانات PHP إلى واجهة برمجة خارجية، فاعتد استدعاء ->values() بعد أي منهما عندما توشك على التسلسل.

وهو أيضًا تذكير جيد بأن أخطاء كهذه تعيش عند الحدود بين الأنظمة. بدا الأمر مشكلة في Meilisearch إلى أن نظرت في JSON الفعلي، فاتضح أنها مشكلة تسلسل صنعتها بنفسي.

وإذا أردت فعلًا أن تعرف هل نجحت الفهرسة، فلن تخبرك searchable() بذلك. استدعاء دوال Meilisearch مباشرة يتطلب كودًا أكثر، لكنه الطريقة الوحيدة للحصول على taskUid وبناء إعادة المحاولة فوقه.

كيف يعمل خط المعالجة في الإنتاج

يقود كل دفعة سجل BatchTask. تحمّل المهام القابلة للبحث المحادثات، وتزيل المكررات، وتعيد ضبط المفاتيح بـ values()، وتستدعي addDocuments()، وتحفظ taskUid المُرجَع. أما المهام غير القابلة للبحث فتحمّل الأهداف، وتعيد ضبط المفاتيح، وتستدعي deleteDocuments()، وتحفظ taskUid بالطريقة نفسها. يوضح المخطط أدناه المسارين.

مخطط سير عمل Meilisearch 1 مكبّر

أثمر تتبّع taskUid لكل دفعة. صرنا نعرف هل نجحت الدفعة أم فشلت بدل افتراض نجاحها. تُعاد محاولة المهام الفاشلة مع عدّاد محاولات، كما أزال إصلاح values() عددًا كبيرًا من أخطاء الإنذار الكاذب التي كانت تضيف ضجيجًا إلى سجلاتنا.

كلمة ختامية

تبدو بنية البحث التحتية بسيطة إلى أن تشغّلها على نطاق واسع، وعندها تكون الافتراضات الصغيرة هي ما يوقعك. هذه كانت مجموعة Laravel تبدو JSON صالحًا وهي ليست كذلك، في اللحظة التي عبرت فيها إلى Meilisearch.

فإن أخذت شيئًا واحدًا من هذا المقال، فليكن هذا: بعد أن تفلتر مجموعة أو تزيل مكرراتها وهي متجهة إلى نظام خارجي، استدعِ ->values(). تكلفتها قليلة، وستوفر عليك عصر يوم.


نعمل على الكثير من هذا النوع في ThriveDesk، مثل التجميع في دفعات، وتتبع المهام غير المتزامنة، والتضمينات (embeddings) للبحث الدلالي. إذا كنت تربط Laravel مع Meilisearch وتريد تبادل الخبرات، تواصل معنا.

شارك هذا المقال
Rijoanul Hasan

بقلم

Rijoanul Hasan الهندسة

مهندس في ThriveDesk، يكتب عن الأنظمة التي تقف وراء دعم سريع وموثوق.

منصة دعم بالذكاء الاصطناعي بسيطة بشكل لافت وقادرة بشكل مدهش

صندوق وارد مشترك ودردشة مباشرة ومركز مساعدة، إضافة إلى ذكاء اصطناعي يفعل أكثر من الرد: فهو يحل التذاكر وينفذ الإجراءات ويطلع العملاء على المستجدات من تلقاء نفسه.