Timeout di Chat Pitchfork: le risposte creano silenziosamente thread e il tracciamento automatico si gonfia nel tempo

Il nostro sito mantiene la cronologia completa delle chat, che si è accumulata per molto tempo. Recentemente ho avuto un problema in cui gli utenti si lamentavano del tempo necessario per caricare il pulsante della chat o del fatto che non apparisse affatto! Sembrava anche inconsistente tra i diversi utenti.

Penso di aver individuato il problema con l’aiuto di Claude Code e potrebbe interessare tutti i siti. Di seguito è riportato un rapporto compilato sul problema.

In sintesi: sembra che le risposte nella chat generino eventi di tracciamento che si gonfiano nel tempo (anche quando la funzionalità di thread è disattivata).

Di seguito è riportato il rapporto generato dall’IA con alcune piccole modifiche da parte mia.


Il problema

Gli utenti attivi segnalavano che la chat rimaneva in caricamento all’infinito senza mai aprirsi. Nei log di amministrazione abbiamo visto timeout dei worker Pitchfork con backtrace come:

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...

Il worker web veniva terminato perché una singola query SQL (ThreadUnreadsQuery) non restituiva risultati entro il timeout del worker. Lo stesso backtrace si ripeteva centinaia di volte in un giorno.

Dal lato client, l’errore si manifesta come un 500 su /chat/api/me/channels:

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

Questa è la versione lato browser dello stesso timeout Pitchfork: l’endpoint che attiva ThreadUnreadsQuery non termina mai, il worker viene riciclato e l’interfaccia utente non riceve mai l’elenco dei canali necessario per visualizzare la chat.


La causa identificata, in parole semplici

Ogni volta che un utente clicca su “Rispondi” a un messaggio specifico nella chat, Discourse crea (o riutilizza) un thread per contenere quella risposta — anche quando il canale ha i thread disabilitati. Nei messaggi diretti di gruppo, vengono aggiunti automaticamente tutti i partecipanti del DM al nuovo thread. Nel tempo, ogni utente attivo accumula migliaia di righe di appartenenza in user_chat_thread_memberships senza mai scegliere intenzionalmente di tracciare nulla.

Quando uno di questi utenti apre la chat, la query di tracciamento principale scorre tutti i suoi thread tracciati ed esegue tre sottorequisiti correlati per ogni thread. Un utente con alcune migliaia di appartenenze spinge la query oltre il limite di attesa di Postgres + Pitchfork, e il worker viene terminato prima che la chat finisca di caricarsi.

Non esiste un meccanismo di pulizia: le appartenenze non vengono mai eliminate e nulla nel codice attuale del plugin imposta mai notification_level = muted/normal sulle righe create automaticamente.


Dati

Dal nostro database di produzione al momento dell’indagine:

  • Appartenenze di tracciamento obsolete (notification_level = 2) su thread inattivi da 180+ giorni: 29.309
  • Stessa regola a 15 giorni: 41.139
  • Utenti con >1.500 appartenenze a thread: 11
  • Utente singolo con più appartenenze: 3.738 appartenenze — superiore al limite MAX_THREADS = 3000 in ThreadUnreadsQuery
  • Tra questi 11 utenti pesanti, praticamente il 100% delle appartenenze era notification_level = 2 (tracciamento automatico) — nessuna era una scelta dell’utente di “osservare” (livello 3)

Appartenenze di tracciamento per gli utenti principali


Quali canali sono stati interessati

Due percorsi distinti hanno prodotto lo stesso risultato:

  1. DM di gruppo (chatable_type = DirectMessage, threading_enabled = false)
    Ad ogni risposta in un DM di gruppo, viene creato un thread e tutti i partecipanti del DM vengono aggiunti automaticamente. Questo è esplicito in create_message.rb (vedi codice sotto).

  2. Canali di categoria/pubblici con threading_enabled = false
    Abbiamo trovato migliaia di thread su un canale di categoria il cui flag threading_enabled è attualmente false ed è stato così per oltre un anno — eppure i thread venivano ancora creati oggi (nuovi ID thread a pochi minuti dall’esecuzione della query). Il percorso fetch_thread in create_message.rb costruisce silenziosamente un thread su qualsiasi risposta senza verificare l’impostazione di threading del canale.

Quindi “disattivare i thread” non è efficace: l’impostazione nasconde l’interfaccia dedicata ai thread ma non impedisce la creazione di thread sottostante quando gli utenti usano “Rispondi al messaggio”.


Codice rilevante

Percorso di risposta che crea silenziosamente threadplugins/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

Nessun controllo su channel.threading_enabled?. Nei canali di categoria non-DM, un thread viene creato indipendentemente.

Iscrizione automatica di tutti i partecipanti nei DM — stesso file, riga ~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)

Ogni risposta in un DM di gruppo aumenta le appartenenze della dimensione del DM, e queste righe non vengono mai eliminate.

Query colli di bottigliaplugins/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

Per N appartenenze, questo produce 3 × N esecuzioni di sottorequisiti correlati, ognuna che unisce 5+ tabelle. Una volta che un utente supera alcune migliaia di appartenenze, la query supera regolarmente il timeout Pitchfork. MAX_THREADS = 3000 è già un riconoscimento del problema di scalabilità, ma gli utenti possono e lo fanno superare.

Gli indici su user_chat_thread_memberships (user_id, thread_id) UNIQUE e (thread_id, user_id) sono presenti, quindi non si tratta di un problema di indice mancante.


Soluzione temporanea

Come mitigazione immediata, abbiamo eliminato le appartenenze di livello di tracciamento in cui il thread sottostante non aveva attività negli ultimi 15 giorni:

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

Dopo la pulizia, quell’utente principale è passato da 3.738 → 77 appartenenze e la chat si è caricata istantaneamente.

Questo è sicuro: nessuna di queste righe corrisponde a una scelta esplicita dell’utente (sono tutte tracciamento automatico, livello 2), e se un utente interagisce di nuovo con un thread eliminato, una nuova appartenenza viene creata automaticamente alla prossima risposta/lettura.

Gli utenti pesanti sono passati da 1.500–3.700 appartenenze a meno di 150, e la chat si carica normalmente di nuovo. Intendiamo programmarlo come un lavoro periodico fino a quando il comportamento sottostante non cambierà upstream.


Direzioni suggerite per una soluzione

  1. Limitare o eliminare le appartenenze di tracciamento automatico a un orizzonte ragionevole (ad esempio, eliminare le righe notification_level = 2 per thread inattivi > N giorni tramite un lavoro pianificato).
  2. Valutare se il comportamento “aggiungi automaticamente ogni partecipante del DM a ogni thread” nei DM di gruppo possa utilizzare un meccanismo di contabilità più economico rispetto a una riga completa user_chat_thread_memberships per partecipante per thread.
1 Mi Piace