Таймауты в Chat Pitchfork: ответы создают ветки незаметно, а автоотслеживание со временем раздувает поток

Наш сайт хранит полную историю чата, которая накапливается уже довольно давно. Недавно я столкнулся с проблемой, когда пользователи жаловались на то, как долго загружается кнопка чата или вообще не отображается! Кроме того, поведение казалось непоследовательным у разных пользователей.

Я думаю, что с помощью Claude Code мне удалось найти причину проблемы, и она может затрагивать все сайты. Ниже представлен отчёт, составленный на основе анализа этой проблемы.

Если коротко: ответы в чате создают события отслеживания, которые со временем начинают раздувать базу данных (даже когда потоковая тема отключена).

Ниже представлен сгенерированный ИИ отчёт с небольшими правками с моей стороны.


Проблема

Активные пользователи сообщали, что чат зависает в состоянии загрузки (спиннер крутится вечно) и никогда не открывается. В логах администратора мы видели таймауты воркера 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) не завершался в течение времени ожидания воркера. Аналогичная трассировка стека повторялась сотни раз в день.

На стороне клиента ошибка проявляется как ответ 500 на запрос /chat/api/me/channels:

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

Это браузерная сторона того же таймаута Pitchfork — конечная точка, запускающая ThreadUnreadsQuery, никогда не завершается, воркер перезапускается, и интерфейс не получает список каналов, необходимый для отображения чата.


Выявленная причина, простыми словами

Каждый раз, когда пользователь нажимает «Ответить» на конкретное сообщение в чате, Discourse создаёт (или переиспользует) потоковую тему (thread), чтобы хранить этот ответ — даже если в канале потоковые темы отключены. В групповых личных сообщениях (DM) ко всем участникам нового потока автоматически добавляются все участники этого DM. Со временем каждый активный пользователь накапливает тысячи записей о членстве в таблице user_chat_thread_memberships, даже никогда намеренно не выбирая отслеживание чего-либо.

Когда любой из этих пользователей открывает чат, основной запрос отслеживания перебирает все отслеживаемые им потоковые темы и выполняет три коррелированных подзапроса для каждой темы. Пользователь с несколькими тысячами записей о членстве выводит запрос за пределы того, что Postgres + Pitchfork готовы ждать, и воркер убивается до того, как чат успеет загрузиться.

Механизма очистки не существует: записи о членстве никогда не удаляются, и ни в одном из текущих плагинов кода не устанавливается notification_level = muted/normal для автоматически созданных записей.


Данные

Из нашей производственной базы данных на момент расследования:

  • Устаревшие записи о членстве с уровнем отслеживания (notification_level = 2) для потоков без активности более 180 дней: 29 309
  • То же правило для 15 дней: 41 139
  • Пользователей с >1 500 записями о членстве в потоках: 11
  • Максимальное количество записей у одного пользователя: 3 738 — выше лимита MAX_THREADS = 3000 в ThreadUnreadsQuery
  • Среди этих 11 активных пользователей практически 100% записей имели уровень notification_level = 2 (автоматическое отслеживание) — ни одна не была результатом ручного выбора «слежения» (уровень 3)

Записи о членстве в отслеживании для топ-пользователей


Какие каналы были затронуты

Два различных пути привели к одному и тому же результату:

  1. Групповые DM (chatable_type = DirectMessage, threading_enabled = false)
    При каждом ответе в групповом DM создаётся поток, и все участники DM автоматически добавляются к нему. Это явно прописано в create_message.rb (см. код ниже).

  2. Категории/публичные каналы с threading_enabled = false
    Мы обнаружили тысячи потоков в канале категории, у которого флаг threading_enabled в настоящее время установлен в false и был таковым более года — тем не менее, потоки всё ещё создаются сегодня (новые ID потоков появляются через несколько минут после запуска запроса). Путь fetch_thread в create_message.rb тихо создаёт поток при любом ответе, не проверяя настройку потоковых тем канала.

Таким образом, «отключение потоковых тем» неэффективно: настройка скрывает специальный интерфейс потоков, но не предотвращает создание потоков под капотом, когда пользователи используют функцию «Ответить на сообщение».


Соответствующий код

Путь ответа, который тихо создаёт потоки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. Ограничить или очищать записи об автоматическом отслеживании на разумном горизонте (например, удалять записи с notification_level = 2 для потоков без активности более N дней через запланированную задачу).
  2. Рассмотреть возможность использования более дешёвого механизма учёта вместо полной записи user_chat_thread_memberships для каждого участника в каждом потоке в групповых DM, чтобы реализовать поведение «автоматически добавлять всех участников DM к каждому потоку».
1 лайк