Notre site conserve l’intégralité de l’historique des discussions, qui s’accumule depuis longtemps. Récemment, j’ai rencontré un problème où les utilisateurs se plaignaient de la lenteur de chargement du bouton de discussion, voire de son absence totale ! Le comportement semblait également incohérent d’un utilisateur à l’autre.
Je pense avoir identifié le problème avec l’aide de Claude Code, et cela pourrait affecter tous les sites. Voici un rapport compilé sur ce problème.
En résumé : il semble que les réponses dans la discussion génèrent des événements de suivi qui s’alourdissent avec le temps (même lorsque le fil de discussion est désactivé).
Voici le rapport généré par l’IA, avec quelques légères modifications de ma part.
Le problème
Des utilisateurs actifs signalaient que la discussion restait en chargement indéfiniment sans jamais s’afficher. Dans les journaux d’administration, nous observions des dépassements de temps d’exécution (timeouts) du worker Pitchfork accompagnés de traces d’appels (backtraces) comme :
Le worker Pitchfork est sur le point de dépasser le temps d'attente, vidage de la pile d'appels du thread principal
...
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...
Le worker web était tué car une requête SQL unique (ThreadUnreadsQuery) ne retournait pas dans le délai imparti au worker. La même trace d’appel se répétait des centaines de fois par jour.
Côté client, l’échec se manifeste par une erreur 500 sur /chat/api/me/channels :
/chat/api/me/channels Échec du chargement de la ressource : le serveur a répondu avec un statut 500
C’est la face visible côté navigateur du même dépassement de temps Pitchfork — le point de terminaison qui déclenche ThreadUnreadsQuery ne se termine jamais, le worker est recyclé, et l’interface utilisateur ne reçoit jamais la liste des canaux nécessaire pour afficher la discussion.
La cause identifiée, en langage simple
À chaque fois qu’un utilisateur clique sur « Répondre » à un message de discussion spécifique, Discourse crée (ou réutilise) un fil de discussion pour contenir cette réponse — même lorsque le canal a la fonctionnalité de fils de discussion désactivée. Dans les messages directs (DM) de groupe, il ajoute également automatiquement tous les participants du DM à l’appartenance du nouveau fil. Avec le temps, chaque utilisateur actif accumule des milliers de lignes d’appartenance dans user_chat_thread_memberships sans jamais avoir intentionnellement choisi de suivre quoi que ce soit.
Lorsque l’un de ces utilisateurs ouvre la discussion, la requête de suivi principale parcourt tous leurs fils de discussion suivis et exécute trois sous-requêtes corrélées par fil. Un utilisateur ayant quelques milliers d’appartenances pousse la requête au-delà de ce que Postgres + Pitchfork sont prêts à attendre, et le worker est tué avant que la discussion ne finisse de charger.
Il n’existe aucun mécanisme de nettoyage : les appartenances ne sont jamais élaguées, et rien dans le code actuel du plugin ne définit jamais notification_level = muted/normal sur les lignes créées automatiquement.
Données
Depuis notre base de données de production au moment de l’enquête :
- Appartenances de suivi obsolètes (
notification_level = 2) sur des fils inactifs depuis 180 jours ou plus : 29 309 - Même règle appliquée à 15 jours : 41 139
- Utilisateurs avec > 1 500 appartenances de fils : 11
- Utilisateur individuel le plus lourd : 3 738 appartenances — au-dessus de la limite
MAX_THREADS = 3000dansThreadUnreadsQuery - Parmi ces 11 utilisateurs lourds, 100 % des appartenances étaient
notification_level = 2(suivi automatique) — aucune n’était un choix « suivi » (niveau 3) de l’utilisateur
Appartenances de suivi pour les principaux utilisateurs
Quels canaux ont été affectés
Deux chemins distincts ont produit le même résultat :
-
DM de groupe (
chatable_type = DirectMessage,threading_enabled = false)
À chaque réponse dans un DM de groupe, un fil est créé et tous les participants du DM sont automatiquement ajoutés. Cela est explicite danscreate_message.rb(voir le code ci-dessous). -
Canaux de catégorie/publiques avec
threading_enabled = false
Nous avons trouvé des milliers de fils sur un canal de catégorie dont le drapeauthreading_enabledest actuellementfalseet l’est depuis plus d’un an — pourtant des fils étaient toujours créés aujourd’hui (nouveaux IDs de fil dans les minutes suivant l’exécution de la requête). Le cheminfetch_threaddanscreate_message.rbconstruit silencieusement un fil à chaque réponse sans vérifier le paramètre de fils du canal.
Ainsi, « désactiver les fils de discussion » n’est pas efficace : le paramètre masque l’interface dédiée aux fils, mais n’empêche pas la création de fils en coulisses lorsque les utilisateurs utilisent « Répondre au message ».
Code pertinent
Chemin de réponse qui crée silencieusement des fils — 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
Aucune vérification contre channel.threading_enabled?. Dans les canaux de catégorie non-DM, un fil est construit de toute façon.
Inscription automatique de tous les participants dans les DM — même fichier, ligne ~203
if channel.direct_message_channel? && !channel.threading_enabled
# Ajouter tous les participants du DM aux fils afin qu'ils aient des appartenances
# pour le suivi des non-lus et la fonctionnalité de marquage comme lu
channel.chatable.users.each { |user| thread.add(user) }
thread.membership_for(guardian.user).update!(last_read_message: message_instance)
Chaque réponse dans un DM de groupe augmente l’appartenance de la taille du DM, et ces lignes ne sont jamais élaguées.
Requête goulot d’étranglement — plugins/chat/app/queries/chat/thread_unreads_query.rb:14-54
class ThreadUnreadsQuery
MAX_THREADS = 3000
...
# trois sous-requêtes corrélées par ligne de 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
Pour N appartenances, cela produit 3 × N exécutions de sous-requêtes corrélées, chacune joignant 5 tables ou plus. Dès qu’un utilisateur dépasse quelques milliers d’appartenances, la requête dépasse systématiquement le délai d’attente Pitchfork. MAX_THREADS = 3000 est déjà une reconnaissance du problème d’évolutivité, mais les utilisateurs peuvent et le dépassent.
Des index sur user_chat_thread_memberships (user_id, thread_id) UNIQUE et (thread_id, user_id) sont présents, donc ce n’est pas un problème d’index manquant.
Solution temporaire
En tant que mesure d’atténuation immédiate, nous avons supprimé les appartenances de niveau de suivi où le fil sous-jacent n’avait aucune activité au cours des 15 derniers jours :
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'
)
Après l’élagage, cet utilisateur principal est passé de 3 738 à 77 appartenances et la discussion s’est chargée instantanément.
Ceci est sûr : aucune de ces lignes ne correspond à un choix explicite de l’utilisateur (ce sont toutes des suivis automatiques, niveau 2), et si un utilisateur interagit à nouveau avec un fil élagué, une nouvelle appartenance est automatiquement créée lors de la prochaine réponse ou lecture.
Les utilisateurs lourds sont passés de 1 500–3 700 appartenances à moins de 150, et la discussion se charge à nouveau normalement. Nous prévoyons de planifier cela comme une tâche périodique jusqu’à ce que le comportement sous-jacent soit modifié en amont.
Directions suggérées pour une correction
- Limiter ou élaguer les appartenances de suivi automatique à un horizon raisonnable (par exemple, supprimer les lignes
notification_level = 2pour les fils inactifs depuis > N jours via une tâche planifiée). - Envisager si le comportement « ajouter automatiquement chaque participant du DM à chaque fil » dans les DM de groupe peut utiliser un mécanisme de comptabilité moins coûteux qu’une ligne complète
user_chat_thread_membershipspar participant par fil.

