Chat Pitchfork タイムアウト: 返信が静かにスレッドを作成し、自動追跡が時間とともに肥大化

当社のサイトでは、長期間にわたって蓄積された完全なチャット履歴を保持しています。最近、ユーザーからチャットボタンの読み込みに時間がかかる、あるいは表示されないという不満が寄せられました。また、ユーザー間で動作が一貫していないようにも感じられました。

Claude Code の支援により問題の根源を特定でき、これはすべてのサイトに影響を及ぼす可能性があります。以下に、この問題に関する報告書を作成しました。

要約すると、チャット内の返信が追跡イベントを生成し、時間とともに膨張していく(スレッド機能がオフの場合でも)ようです。

以下は、AI が生成した報告書に、私による若干の編集を加えたものです。


問題点

アクティブなユーザーから、チャットが無限にスピニングし、決して読み込まれないという報告がありました。管理者ログでは、以下のようなバックトレースを伴う Pitchfork ワーカーのタイムアウトが多数記録されていました。

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

単一の SQL クエリ(ThreadUnreadsQuery)がワーカーのタイムアウト期限内に返答しなかったため、Web ワーカーが殺されました。この同じバックトレースが、1 日に数百回繰り返されました。

クライアント側では、この失敗は /chat/api/me/channels での 500 エラーとして表面化します。

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

これは、ブラウザ側における同じ Pitchfork タイムアウトの表れです。ThreadUnreadsQuery をトリガーするエンドポイントが完了しないため、ワーカーがリサイクルされ、UI はチャットをレンダリングするために必要なチャンネルリストを受け取ることができません。


特定された原因(平易な表現で)

ユーザーが特定のチャットメッセージで「返信」をクリックするたびに、Discourse はその返信を保持するスレッドを作成(または再利用)します。チャンネルでスレッド機能が無効になっている場合でも同様です。グループ DM の場合、新しいスレッドのメンバーシップにすべての DM 参加者が自動的に追加されます。時間の経過とともに、アクティブなユーザーは意図的に追跡を選択したわけでもないのに、user_chat_thread_memberships に何千ものメンバーシップ行を蓄積することになります。

これらのユーザーのいずれかがチャットを開くと、コアの追跡クエリがすべての追跡済みスレッドを反復し、スレッドごとに 3 つの相関サブクエリを実行します。数千のメンバーシップを持つユーザーの場合、このクエリは Postgres と Pitchfork が待機できる範囲を超え、チャットが読み込まれる前にワーカーが殺されてしまいます。

クリーンアップメカニズムはありません。メンバーシップは決して削除されず、現在のプラグインコードのどこにも、自動作成された行に対して notification_level = muted/normal を設定する処理が含まれていません。


データ

調査時点の生産環境 DB から得られたデータは以下の通りです。

  • 180 日以上活動のないスレッドに対する stale な追跡レベルのメンバーシップ(notification_level = 2):29,309
  • 15 日以上活動のないスレッドに対する同様のルール:41,139
  • 1,500 を超えるスレッドメンバーシップを持つユーザー:11
  • 最多の個人ユーザー:3,738 のメンバーシップThreadUnreadsQuery 内の MAX_THREADS = 3000 の上限を超えています
  • これらの 11 人の重負荷ユーザーのうち、実質的に**メンバーシップの 100% が notification_level = 2(自動追跡)**でした。ユーザーが選択した「ウォッチ中」(レベル 3)は一つもありませんでした。

トップユーザーの追跡メンバーシップ


影響を受けたチャンネル

2 つの異なる経路が、同じ結果を生み出しました。

  1. グループ DM(chatable_type = DirectMessage, threading_enabled = false
    グループ DM で返信するたびにスレッドが作成され、すべての DM 参加者が自動的に追加されます。これは create_message.rb に明示されています(以下のコード参照)。

  2. カテゴリ/公開チャンネル(threading_enabled = false
    現在 threading_enabled フラグが false で、1 年以上その状態であるカテゴリチャンネルで、何千ものスレッドが見つかりました。しかし、スレッドは今日も作成され続けています(クエリを実行した数分以内に新しいスレッド ID が生成されています)。create_message.rb 内の fetch_thread パスは、チャンネルのスレッド設定を確認することなく、返信時に静かにスレッドを構築します。

したがって、「スレッド機能をオフにする」設定は効果的ではありません。この設定は専用のスレッド UI を隠すだけで、ユーザーが「メッセージに返信」を使用する際に、裏側でスレッドが作成されるのを防ぎません。


関連コード

静かにスレッドを作成する返信パス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

channel.threading_enabled? に対するチェックはありません。非 DM のカテゴリチャンネルでは、スレッドが構築されます。

DM 参加者の自動登録 — 同じファイル、約 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)

グループ DM での返信ごとに、メンバーシップが DM の参加者数分だけ拡大し、これらの行は決して削除されません。

ボトルネックとなるクエリ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

N 件のメンバーシップに対して、このクエリは 3 × N 回の相関サブクエリ実行を生成し、それぞれが 5 つ以上のテーブルを結合します。ユーザーのメンバーシップが数千を超えると、このクエリは確実に Pitchfork のタイムアウトを超えます。MAX_THREADS = 3000 は、スケーリング問題の認識を示していますが、ユーザーはこれを超えてしまいます。

user_chat_thread_memberships (user_id, thread_id) UNIQUE および (thread_id, user_id) のインデックスが存在するため、これはインデックス不足の問題ではありません。


一時的な解決策

即時の緩和策として、基となるスレッドで過去 15 日間活動がなかった追跡レベルのメンバーシップを削除しました。

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

削除後、該当するトップユーザーのメンバーシップは 3,738 → 77 に減少し、チャットが即座に読み込まれるようになりました。

これは安全です。これらの行のいずれもユーザーの明示的な選択に対応するものではなく(すべて自動追跡、レベル 2)、ユーザーが削除されたスレッドと再度やり取りした場合、次の返信または読み込み時に新しいメンバーシップが自動的に作成されます。

重負荷ユーザーのメンバーシップは 1,500–3,700 から 150 未満に減少し、チャットは再び正常に読み込まれるようになりました。上流側の動作が変更されるまで、この処理を定期的なジョブとしてスケジュールする予定です。


修正に向けた提案

  1. 自動追跡メンバーシップを適切な期間で制限または削除する(例:N 日以上活動のないスレッドの notification_level = 2 の行を、定期的なジョブを通じて削除)。
  2. グループ DM における「すべての DM 参加者をすべてのスレッドに自動追加する」動作が、参加者ごとに user_chat_thread_memberships 行を作成するよりも安価な帳簿管理メカニズムを利用できないか検討する。
「いいね!」 1