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 = 3000emThreadUnreadsQuery - 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:
-
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 emcreate_message.rb(veja o código abaixo). -
Canais de categoria/públicos com
threading_enabled = false
Encontramos milhares de threads em um canal de categoria cujo sinalizadorthreading_enabledestá atualmente comofalsee 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 caminhofetch_threademcreate_message.rbconstró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 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
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 gargalo — 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 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
- Limitar ou podar associações de rastreamento automático em um horizonte sensato (por exemplo, remover linhas
notification_level = 2para threads inativos há > N dias via um trabalho agendado). - 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_membershipspor participante por thread.

