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 = 3000inThreadUnreadsQuery - 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:
-
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 increate_message.rb(vedi codice sotto). -
Canali di categoria/pubblici con
threading_enabled = false
Abbiamo trovato migliaia di thread su un canale di categoria il cui flagthreading_enabledè attualmentefalseed è stato così per oltre un anno — eppure i thread venivano ancora creati oggi (nuovi ID thread a pochi minuti dall’esecuzione della query). Il percorsofetch_threadincreate_message.rbcostruisce 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 thread — 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
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 bottiglia — 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
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
- Limitare o eliminare le appartenenze di tracciamento automatico a un orizzonte ragionevole (ad esempio, eliminare le righe
notification_level = 2per thread inattivi > N giorni tramite un lavoro pianificato). - 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_membershipsper partecipante per thread.

