Timeouts do Chat Pitchfork: respostas criam tópicos silenciosamente e o rastreamento automático infla com o tempo

Nosso site mantém todo o histórico de chat, que vem sendo acumulado há bastante tempo. Recentemente, tive um problema em que os usuários reclamavam sobre o tempo que o botão de chat leva para carregar ou se ele sequer aparece! Também parecia inconsistente entre os usuários.

Acredito que encontrei a causa do problema com a ajuda do Claude Code, e isso pode afetar todos os sites. Abaixo está um relatório compilado sobre o problema.

O resumo é que as respostas no chat parecem criar eventos de rastreamento que começam a inchar com o tempo (mesmo quando o encadeamento está desativado).

Abaixo está o relatório gerado por IA com pequenas edições da minha parte


O problema

Usuários ativos relatavam que o chat ficava girando para sempre e nunca carregava. Nos logs de administração, vimos timeouts do worker Pitchfork sendo registrados com 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...

O worker da web estava sendo encerrado porque uma única consulta SQL (ThreadUnreadsQuery) não retornava dentro do tempo limite do worker. O mesmo backtrace se repetia centenas de vezes em um dia.

No lado do cliente, a falha se manifesta como um erro 500 em /chat/api/me/channels:

/chat/api/me/channels  Failed to load resource: the server responded with a status of 500

Essa é a face do navegador do mesmo timeout do Pitchfork — o endpoint que aciona ThreadUnreadsQuery nunca termina, o worker é reciclado e a interface nunca recebe a lista de canais necessária para renderizar o chat.


A causa identificada, em linguagem simples

Toda vez que um usuário clica em “Responder” em uma mensagem específica de chat, o Discourse cria (ou reutiliza) um thread para guardar essa resposta — mesmo quando o canal tem o encadeamento desativado. Em DMs em grupo, ele também adiciona automaticamente todos os participantes da DM à associação do novo thread. Com o tempo, cada usuário ativo acumula milhares de linhas de associação em user_chat_thread_memberships sem nunca ter escolhido intencionalmente rastrear nada.

Quando qualquer um desses usuários abre o chat, a consulta de rastreamento principal itera sobre todos os seus threads rastreados e executa três subconsultas correlacionadas por thread. Um usuário com algumas milhares de associações empurra a consulta além do que o Postgres + Pitchfork esperam, e o worker é encerrado antes que o chat termine de carregar.

Não há mecanismo de limpeza: as associações nunca são podadas, e nada no código atual do plugin define notification_level = muted/normal nas linhas criadas automaticamente.


Dados

De nosso banco de dados de produção no momento da investigação:

  • Associações de nível de rastreamento obsoletas (notification_level = 2) em threads inativos há 180+ dias: 29.309
  • Mesma regra em 15 dias: 41.139
  • Usuários com >1.500 associações de thread: 11
  • Usuário individual com mais associações: 3.738 associações — acima do limite MAX_THREADS = 3000 em ThreadUnreadsQuery
  • Entre esses 11 usuários pesados, efetivamente 100% das associações eram notification_level = 2 (rastreamento automático) — nenhuma era de “observação” escolhida pelo usuário (nível 3)

Associações de rastreamento para os principais usuários


Quais canais foram afetados

Dois caminhos distintos produziram o mesmo resultado:

  1. DMs em grupo (chatable_type = DirectMessage, threading_enabled = false)
    Em cada resposta em uma DM em grupo, um thread é criado e todos os participantes da DM são adicionados automaticamente. Isso é explícito em create_message.rb (veja o código abaixo).

  2. Canais de categoria/públicos com threading_enabled = false
    Encontramos milhares de threads em um canal de categoria cujo sinalizador threading_enabled está atualmente como false e tem sido assim há mais de um ano — no entanto, threads ainda estavam sendo criados hoje (novos IDs de thread em minutos após executar a consulta). O caminho fetch_thread em create_message.rb constrói silenciosamente um thread em qualquer resposta sem verificar a configuração de encadeamento do canal.

Portanto, “desativar o encadeamento” não é eficaz: a configuração oculta a interface dedicada de threads, mas não impede que threads sejam criados nos bastidores quando os usuários usam “Responder à mensagem”.


Código relevante

Caminho de resposta que cria threads 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

Nenhuma verificação contra channel.threading_enabled?. Em canais de categoria não-DM, um thread é construído independentemente.

Inscrição automática de todos os participantes em DM — mesmo arquivo, ~linha 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 resposta em uma DM em grupo expande a associação pelo tamanho da DM, e essas linhas nunca são podadas.

Consulta gargaloplugins/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 associações, isso produz 3 × N execuções de subconsulta correlacionada, cada uma juntando 5+ tabelas. Assim que um usuário excede algumas milhares de associações, a consulta consistentemente ultrapassa o tempo limite do Pitchfork. MAX_THREADS = 3000 já é um reconhecimento do problema de escalabilidade, mas os usuários podem e excedem esse limite.

Índices em user_chat_thread_memberships (user_id, thread_id) UNIQUE e (thread_id, user_id) estão presentes, então isso não é um problema de índice ausente.


Solução temporária

Como mitigação imediata, deletamos associações de nível de rastreamento onde o thread subjacente não teve atividade nos últimos 15 dias:

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'
  )

Após a poda, esse usuário principal caiu de 3.738 → 77 associações e o chat carregou instantaneamente.

Isso é seguro: nenhuma dessas linhas corresponde a uma escolha explícita do usuário (todas são de rastreamento automático, nível 2), e se um usuário interagir com um thread podado novamente, uma nova associação será criada automaticamente na próxima resposta/leitura.

Usuários pesados caíram de 1.500–3.700 associações para menos de 150, e o chat carrega normalmente novamente. Planejamos agendar isso como um trabalho periódico até que o comportamento subjacente seja alterado upstream.


Direções sugeridas para uma correção

  1. Limitar ou podar associações de rastreamento automático em um horizonte sensato (por exemplo, remover linhas notification_level = 2 para threads inativos há > N dias via um trabalho agendado).
  2. Considerar se o comportamento de “adicionar automaticamente todos os participantes da DM a cada thread” em DMs em grupo pode usar um mecanismo de contabilidade mais barato do que uma linha completa user_chat_thread_memberships por participante por thread.
1 curtida