يحتفظ موقعنا بسجل محادثة كامل، وقد تراكم لفترة طويلة الآن. مؤخرًا، واجهتُ مشكلة اشتكى فيها المستخدمون من الوقت الطويل الذي يستغرقه زر المحادثة للتحميل، أو حتى عدم ظهوره من الأساس! كما بدا الأمر غير متسق بين المستخدمين.
أعتقد أنني وجدت المشكلة بمساعدة Claude Code، وقد تؤثر على جميع المواقع. فيما يلي تقرير مُعدّ حول هذه المشكلة.
الخلاصة هي أن الردود في المحادثة تبدو وكأنها تُنشئ أحداث تتبع تتضخم مع مرور الوقت (حتى عند تعطيل الخيوط).
فيما يلي التقرير الذي أوجده الذكاء الاصطناعي مع بعض التعديلات البسيطة مني
المشكلة
أبلغ المستخدمون النشطون عن أن المحادثة تدور إلى الأبد ولا يتم تحميلها أبدًا. في سجلات المسؤول، رأينا انتهاء مهلة عمال Pitchfork مع إرجاع أكواد تتبع مثل:
Pitchfork worker is about to timeout, dumping backtrace for main thread
...
lib/mini_sql/postgres/connection.rb:... MiniSql::Postgres::Connection#query
plugins/chat/app/queries/chat/thread_unreads_query.rb:132 Chat::ThreadUnreadsQuery.call
plugins/chat/app/queries/chat/tracking_state_report_query.rb:71 Chat::TrackingStateReportQuery.call
plugins/chat/app/services/chat/list_user_channels...
تم قتل عميل الويب لأن استعلام SQL واحد (ThreadUnreadsQuery) لم يُرجع النتائج ضمن مهلة العامل. تكرر نفس تتبع الأكواد مئات المرات في اليوم.
على جانب العميل، يظهر الفشل كخطأ 500 في /chat/api/me/channels:
/chat/api/me/channels Failed to load resource: the server responded with a status of 500
هذا هو الوجه المتصفح لنفس انتهاء مهلة Pitchfork — النقطة الطرفية التي تُفعّل ThreadUnreadsQuery لا تكتمل أبدًا، ويتم إعادة تدوير العامل، ولا تحصل واجهة المستخدم على قائمة القنوات التي تحتاجها لعرض المحادثة.
السبب المُحدّد، بلغة بسيطة
في كل مرة ينقر فيها مستخدم على “رد” على رسالة محادثة معينة، ينشئ Discourse (أو يعيد استخدام) خيطًا لاستيعاب هذا الرد — حتى عندما يكون الخيط معطلاً في القناة. في الرسائل المباشرة الجماعية، يضيف أيضًا تلقائيًا كل مشارك في الرسالة المباشرة إلى عضوية الخيط الجديد. مع مرور الوقت، يتراكم لدى كل مستخدم نشط آلاف صفوف العضوية في user_chat_thread_memberships دون أن يختار عمدًا تتبع أي شيء.
عندما يفتح أي من هؤلاء المستخدمين المحادثة، يستعرض استعلام التتبع الأساسي جميع خيوطهم المتتبعة ويشغل ثلاثة استعلامات فرعية مترابطة لكل خيط. يدفع المستخدم الذي لديه بضعة آلاف من العضويات الاستعلام إلى ما يتجاوز ما سيتحمله Postgres + Pitchfork، ويتم قتل العامل قبل اكتمال تحميل المحادثة.
لا توجد آلية تنظيف: لا يتم تقليم العضويات أبدًا، ولا يقوم أي شيء في كود الإضافة الحالي بتعيين notification_level = muted/normal على الصفوف التي تم إنشاؤها تلقائيًا.
البيانات
من قاعدة بيانات الإنتاج لدينا في لحظة التحقيق:
- عضويات تتبع قديمة (
notification_level = 2) على خيوط خاملة لأكثر من 180 يومًا: 29,309 - نفس القاعدة عند 15 يومًا: 41,139
- مستخدمون لديهم >1,500 عضوية خيط: 11
- أعلى مستخدم فردي: 3,738 عضوية — أعلى من حد
MAX_THREADS = 3000فيThreadUnreadsQuery - من بين هؤلاء المستخدمين الـ 11 الثقيلين، كانت 100% تقريبًا من العضويات
notification_level = 2(تتبع تلقائي) — لم تكن أي منها “مراقبة” مختارة من قبل المستخدم (المستوى 3)
عضويات التتبع لأعلى المستخدمين
القنوات المتأثرة
أنتج المساران المتميزان نفس النتيجة:
-
الرسائل المباشرة الجماعية (
chatable_type = DirectMessage,threading_enabled = false)
في كل رد في رسالة مباشرة جماعية، يتم إنشاء خيط ويتم إضافة جميع المشاركين في الرسالة المباشرة تلقائيًا. هذا صريح فيcreate_message.rb(انظر الكود أدناه). -
قنوات التصنيف/العامة مع
threading_enabled = false
وجدنا آلاف الخيوط على قناة تصنيف حيث تكون علامةthreading_enabledحاليًاfalseوهي كذلك منذ أكثر من عام — ومع ذلك، لا تزال الخيوط تُنشأ اليوم (معرفات خيوط جديدة خلال دقائق من تشغيل الاستعلام). مسارfetch_threadفيcreate_message.rbيبني خيطًا بصمت على أي رد دون التحقق من إعداد الخيط في القناة.
لذا فإن “إيقاف الخيوط” ليس فعالًا: الإعداد يخفي واجهة الخيط المخصصة لكنه لا يمنع إنشاء الخيوط في الخلفية عندما يستخدم المستخدمون “الرد على الرسالة”.
الكود ذو الصلة
مسار الرد الذي ينشئ خيوطًا بصمت — plugins/chat/app/services/chat/create_message.rb:131
def fetch_thread(params:, reply:, channel:, options:)
return Chat::Thread.find_by(id: params.thread_id) if params.thread_id.present?
reply.thread ||
reply.build_thread(
...
force: options.force_thread,
...
)
end
لا يوجد تحقق من channel.threading_enabled?. في قنوات التصنيف غير المباشرة، يتم بناء خيط بغض النظر.
التسجيل التلقائي لجميع المشاركين في الرسائل المباشرة — نفس الملف، ~السطر 203
if channel.direct_message_channel? && !channel.threading_enabled
# Add all DM participants to threads so they have memberships
# for unread tracking and mark-as-read functionality
channel.chatable.users.each { |user| thread.add(user) }
thread.membership_for(guardian.user).update!(last_read_message: message_instance)
كل رد في رسالة مباشرة جماعية يوسع العضوية بحجم الرسالة المباشرة، ولا يتم تقليم هذه الصفوف أبدًا.
استعلام الاختناق — plugins/chat/app/queries/chat/thread_unreads_query.rb:14-54
class ThreadUnreadsQuery
MAX_THREADS = 3000
...
# three correlated subqueries per row of user_chat_thread_memberships:
# SELECT (SELECT COUNT(*) ... unread) AS unread_count,
# (SELECT COUNT(*) ... mentions) AS mention_count,
# (SELECT COUNT(*) ... watched) AS watched_threads_unread_count,
# chat_threads.channel_id, memberships.thread_id
# FROM user_chat_thread_memberships AS memberships
# ...
# WHERE memberships.user_id = :user_id
# ...
# LIMIT :limit
لـ N عضوية، ينتج هذا 3 × N تنفيذًا للاستعلامات الفرعية المترابطة، كل منها يجمع 5+ جداول. بمجرد أن يتجاوز المستخدم بضعة آلاف من العضويات، يتجاوز الاستعلام بشكل موثوق مهلة Pitchfork. MAX_THREADS = 3000 هو بالفعل اعتراف بمشكلة التوسع، لكن المستخدمين يمكنهم ويتجاوزونها.
مؤشرات على user_chat_thread_memberships (user_id, thread_id) UNIQUE و (thread_id, user_id) موجودة، لذا هذه ليست مشكلة مؤشر مفقود.
الحل المؤقت
كإجراء تخفيف فوري، حذفنا عضويات التتبع حيث لم يكن هناك نشاط في الخيط الأساسي خلال آخر 15 يومًا:
DELETE FROM user_chat_thread_memberships uctm
USING chat_threads t
WHERE t.id = uctm.thread_id
AND uctm.notification_level = 2
AND NOT EXISTS (
SELECT 1 FROM chat_messages m
WHERE m.thread_id = t.id
AND m.created_at > NOW() - INTERVAL '15 days'
)
بعد التقليم، انخفض المستخدم الأعلى من 3,738 → 77 عضوية وتم تحميل المحادثة على الفور.
هذا آمن: لا تتوافق أي من هذه الصفوف مع اختيار صريح من المستخدم (كلها تتبع تلقائي، المستوى 2)، وإذا تفاعل المستخدم مع خيط تم تقليمه مرة أخرى، فسيتم إنشاء عضوية جديدة تلقائيًا في الرد/القراءة التالية.
انخفض المستخدمون الثقيلون من 1,500–3,700 عضوية إلى أقل من 150، وتعمل المحادثة بشكل طبيعي مرة أخرى. نخطط لجدولة هذا كعملية دورية حتى يتغير السلوك الأساسي في الأعلى.
اتجاهات مقترحة للإصلاح
- تحديد أو تقليم عضويات التتبع التلقائي عند أفق معقول (على سبيل المثال، إسقاط صفوف
notification_level = 2للخيوط الخاملة لأكثر من N يومًا عبر عملية مجدولة). - النظر في ما إذا كان سلوك “إضافة تلقائية لكل مشارك في الرسالة المباشرة إلى كل خيط” في الرسائل المباشرة الجماعية يمكن أن يستخدم آلية محاسبة أرخص من صف
user_chat_thread_membershipsكامل لكل مشارك لكل خيط.

