Nuestro sitio conserva el historial completo de chat, que se ha estado acumulando durante mucho tiempo. Recientemente, tuve un problema en el que los usuarios se quejaban de cuánto tardaba en cargar el botón de chat o incluso de si aparecía en absoluto. Además, parecía ser inconsistente entre usuarios.
Creo que encontré el problema con la ayuda de Claude Code y podría afectar a todos los sitios. A continuación se presenta un informe compilado sobre el problema.
En resumen: parece que las respuestas en el chat generan eventos de seguimiento que comienzan a inflarse con el tiempo (incluso cuando la función de hilos está desactivada).
A continuación, el informe generado por la IA con ligeras ediciones mías.
El problema
Los usuarios activos reportaban que el chat giraba indefinidamente y nunca llegaba a cargar. En los registros del administrador, vimos que los workers de Pitchfork estaban agotando el tiempo de espera y generando backtraces como:
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...
El worker web estaba siendo eliminado porque una sola consulta SQL (ThreadUnreadsQuery) no devolvía resultados dentro del tiempo de espera del worker. El mismo backtrace se repetía cientos de veces en un día.
En el lado del cliente, el fallo se manifiesta como un error 500 en /chat/api/me/channels:
/chat/api/me/channels Failed to load resource: the server responded with a status of 500
Esa es la cara del navegador del mismo tiempo de espera de Pitchfork: el punto de entrada que activa ThreadUnreadsQuery nunca termina, el worker se recicla y la interfaz de usuario nunca recibe la lista de canales necesaria para renderizar el chat.
La causa identificada, en lenguaje sencillo
Cada vez que un usuario hace clic en “Responder” en un mensaje de chat específico, Discourse crea (o reutiliza) un hilo para contener esa respuesta, incluso cuando el canal tiene la función de hilos desactivada. En los mensajes directos grupales, también agrega automáticamente a todos los participantes del DM a la membresía del nuevo hilo. Con el tiempo, cada usuario activo acumula miles de filas de membresía en user_chat_thread_memberships sin haber elegido intencionalmente realizar un seguimiento de nada.
Cuando cualquiera de esos usuarios abre el chat, la consulta de seguimiento principal itera sobre todos sus hilos rastreados y ejecuta tres subconsultas correlacionadas por hilo. Un usuario con unas pocas miles de membresías empuja la consulta más allá de lo que Postgres + Pitchfork están dispuestos a esperar, y el worker es eliminado antes de que el chat termine de cargar.
No existe ningún mecanismo de limpieza: las membresías nunca se podan y nada en el código actual del plugin establece nunca notification_level = muted/normal en las filas creadas automáticamente.
Datos
De nuestra base de datos de producción en el momento de la investigación:
- Membresías de seguimiento obsoletas (
notification_level = 2) en hilos inactivos durante 180+ días: 29,309 - Misma regla a los 15 días: 41,139
- Usuarios con >1,500 membresías de hilo: 11
- Usuario individual con más membresías: 3,738 membresías — por encima del límite
MAX_THREADS = 3000enThreadUnreadsQuery - Entre esos 11 usuarios intensivos, efectivamente el 100% de las membresías eran
notification_level = 2(seguimiento automático); ninguna era una opción de “vigilancia” elegida por el usuario (nivel 3)
Membresías de seguimiento para los principales usuarios
Qué canales se vieron afectados
Dos rutas distintas produjeron el mismo resultado:
-
DMs grupales (
chatable_type = DirectMessage,threading_enabled = false)
En cada respuesta en un DM grupal, se crea un hilo y todos los participantes del DM se agregan automáticamente. Esto es explícito encreate_message.rb(ver código a continuación). -
Canales de categoría/públicos con
threading_enabled = false
Encontramos miles de hilos en un canal de categoría cuya banderathreading_enabledactualmente esfalsey lo ha sido durante más de un año, sin embargo, aún se estaban creando hilos hoy (nuevos IDs de hilo en minutos de ejecutar la consulta). La rutafetch_threadencreate_message.rbconstruye silenciosamente un hilo en cualquier respuesta sin verificar la configuración de hilos del canal.
Por lo tanto, “desactivar los hilos” no es efectivo: la configuración oculta la interfaz de usuario dedicada a los hilos, pero no impide que se creen hilos en el fondo cuando los usuarios utilizan “Responder al mensaje”.
Código relevante
Ruta de respuesta que crea hilos silenciosamente — 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
No hay verificación contra channel.threading_enabled?. En los canales de categoría que no son DM, se construye un hilo independientemente.
Inscripción automática de todos los participantes en DM — mismo archivo, ~línea 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)
Cada respuesta en un DM grupal expande la membresía por el tamaño del DM, y esas filas nunca se podan.
Consulta cuello de botella — 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
Para N membresías, esto produce 3 × N ejecuciones de subconsulta correlacionada, cada una uniéndose a 5+ tablas. Una vez que un usuario supera unas pocas miles de membresías, la consulta supera consistentemente el tiempo de espera de Pitchfork. MAX_THREADS = 3000 ya es un reconocimiento del problema de escalabilidad, pero los usuarios pueden y lo hacen excederlo.
Los índices en user_chat_thread_memberships (user_id, thread_id) UNIQUE y (thread_id, user_id) están presentes, por lo que este no es un problema de índice faltante.
Solución temporal
Como mitigación inmediata, eliminamos las membresías de nivel de seguimiento donde el hilo subyacente no tenía actividad en los últimos 15 días:
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'
)
Después de la poda, ese usuario principal bajó de 3,738 a 77 membresías y el chat cargó instantáneamente.
Esto es seguro: ninguna de esas filas corresponde a una elección explícita del usuario (todas son de seguimiento automático, nivel 2), y si un usuario interactúa con un hilo podado nuevamente, se crea una nueva membresía automáticamente en la siguiente respuesta/lectura.
Los usuarios intensivos bajaron de 1,500–3,700 membresías a menos de 150, y el chat vuelve a cargar con normalidad. Planeamos programar esto como un trabajo periódico hasta que cambie el comportamiento subyacente en la versión principal.
Direcciones sugeridas para una solución
- Limitar o podar las membresías de seguimiento automático en un horizonte razonable (por ejemplo, eliminar filas con
notification_level = 2para hilos inactivos > N días mediante un trabajo programado). - Considerar si el comportamiento de “agregar automáticamente a todos los participantes del DM a cada hilo” en los DM grupales puede utilizar un mecanismo de contabilidad más barato que una fila completa de
user_chat_thread_membershipspor participante por hilo.

