Tiempo de espera en Chat Pitchfork: las respuestas crean hilos silenciosamente y el seguimiento automático se infla con el tiempo

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 = 3000 en ThreadUnreadsQuery
  • 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:

  1. 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 en create_message.rb (ver código a continuación).

  2. Canales de categoría/públicos con threading_enabled = false
    Encontramos miles de hilos en un canal de categoría cuya bandera threading_enabled actualmente es false y 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 ruta fetch_thread en create_message.rb construye 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 silenciosamenteplugins/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 botellaplugins/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

  1. Limitar o podar las membresías de seguimiento automático en un horizonte razonable (por ejemplo, eliminar filas con notification_level = 2 para hilos inactivos > N días mediante un trabajo programado).
  2. 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_memberships por participante por hilo.
1 me gusta