[perf][comments] POST /api/comments/resolve: p95 до 4.5 с — HTTP-ответ ждёт загрузку Y.Doc и полный store-пайплайн ради операции над меткой #399

Open
opened 2026-07-07 00:03:11 +03:00 by agent_vscode · 1 comment
Collaborator

Суть

Алерт api-p95-degradation (07.07, ~00:00) сработал по route="/api/comments/resolve": p95 ≈ 4.5 с. Данные из VM за 2 часа (12 запросов): 6 запросов в диапазоне 1–2.5 с, один 2.5–5 с — это не выброс, resolve стабильно тяжёлый. Триггер — пачка резолвов за короткое окно (11 запросов за 5 минут, типичный агентский сценарий «разобрать комментарии на странице»).

Это серверная половина боли, известной по #340 (там — клиентский ре-рендер на resolve; тут — секунды на сервере).

Семантика операций над метками НЕ меняется: resolve, как и сейчас, проставляет метке resolved:true (клиенты прячут подсветку), dismiss/delete эфемерных предложений (#329), как и сейчас, снимает якорь с текста. Меняется только одно: HTTP-ответ перестаёт ждать эту работу.

Диагноз

Цепочка resolveComment (comment.service.ts#L291-L328):

  1. Обновление строки в БД (быстро, мс) — источник истины.
  2. await collaborationGateway.handleYjsEvent('resolveCommentMark', …)collaboration.handler.ts#L58-L80withYdocConnection:
    • openDirectConnection — если документ не открыт (никто не редактирует — типично для агентских резолвов), hocuspocus загружает весь Y.Doc: чтение большого bytea из Postgres + Y.applyUpdate — O(размер документа), на больших страницах сотни мс — секунды;
    • transact — обход всего XmlFragment в поиске метки (updateYjsMarkAttribute) — O(документа);
    • ключевое: DirectConnection.transact у hocuspocus вызывает storeDocument немедленно, минуя debounce 10–45 с (задокументировано в комментарии withYdocConnection: «after the transaction (and its storeDocument hooks) resolve»). То есть HTTP-ответ ждёт весь store-пайплайн из persistence.extension.ts: fromYdoc→JSON, encodeStateAsUpdate, jsonToText, deep-equal всего контента, запись страницы, transclusion-синк (3 SELECT), два обхода на меншены, enqueue'ы.

Итого: операция над одной меткой оплачивается полной загрузкой + полной сериализацией + полным сохранением документа, синхронно в HTTP-запросе. unloadImmediately: false уже стоит (collaboration.gateway.ts#L56) — повторные резолвы переиспользуют загруженный док (поэтому не все запросы по 4.5 с), но immediate-store платится каждым вызовом.

Ирония: ошибки этого шага уже сейчас глотаются (catchlogger.warn) — то есть мы ждём секунды ради операции, провал которой и так не влияет на ответ.

Решение

1. Убрать Yjs-операцию с критического пути HTTP (единственное изменение)

Строка в БД — источник истины; операция над меткой в документе — eventual-зеркало для подключённых клиентов. Вместо await в запросе — идемпотентный джоб COMMENT_MARK_UPDATE в существующую GENERAL_QUEUE ({documentName, commentId, action: 'resolve'|'unresolve'|'delete', ts}), воркер выполняет тот же самый handleYjsEvent с той же семантикой:

  • resolve/unresolveupdateYjsMarkAttribute (флип resolved, как сейчас);
  • delete (dismiss эфемерных предложений) → removeYjsMarkByAttribute (снятие якоря, как сейчас).

Очередь, а не fire-and-forget: переживает рестарт, ретраится, failed-джобы видны в BullMQ-метриках на дашборде. Операции идемпотентны — ретраи безопасны.

Ожидание: p95 роута падает с ~4.5 с до десятков мс (остаются 2 запроса к БД + нотификация).

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

2. Смягчить сам store-пайплайн — уже в #348

transclusion short-circuit, дифф меншенов, дедуп AI-джобов — режут хвост каждого immediate-store. Не дублировать, просто зафиксировать связь.

3. (Опция, follow-up) Батч для агентов

MCP-сценарий «резолвить N комментариев страницы» мог бы делать одну сессию withYdocConnection на все метки вместо N циклов. После п. 1 станет некритично — оставить на потом.

Крайние случаи

  • Окно рассинхрона: HTTP-ответ вернётся до операции над меткой — открытый клиент увидит зеркало через collab с задержкой очереди (обычно <1 с). Клиент и так делает оптимистичное обновление (#340), UI не мигает. Приемлемо by design.
  • Порядок resolve/unresolve: в payload — timestamp (resolvedAt); воркер скипает события старше уже применённого состояния (unresolve после resolve в обратном порядке — теоретическая гонка, timestamp её закрывает).
  • Документ под активным редактированием: DirectConnection на открытый док дёшев — джоб отработает мгновенно, ничего не грузя.
  • Провал джоба после исчерпания ретраев: расхождение БД↔метка — то же, что сегодняшний проглоченный warn, но теперь видимое: failed-джобы наблюдаются метриками BullMQ на дашборде.

Тесты / проверка

  • Юнит: resolveComment не await'ит gateway (спай), джоб enqueue'ится с правильным payload; воркер вызывает handleYjsEvent с теми же аргументами и той же семантикой (resolve → флип атрибута, delete → снятие).
  • Существующие тесты collaboration.handler / comment.service — без изменений семантики mark-операций.
  • Прод-верификация: p95 /api/comments/resolve на дашборде < 100 мс; сценарий «агент резолвит 10+ комментариев» не триггерит алерт; в открытом клиенте подсветка гаснет при resolve, якорь исчезает при dismiss (смок вручную).

Вне скоупа

  • Клиентский ре-рендер списка комментариев — #340.
  • Стоимость store-пайплайна — #348.
  • Батч-API — follow-up при необходимости.
  • Физическое снятие меток при resolve — сознательно НЕ делаем (см. #328: снятие ломает reopen и скролл-к-месту; resolved-якоря прячутся на уровне представления).

План работ

  1. Джоб COMMENT_MARK_UPDATE в GENERAL_QUEUE + перевод resolveCommentMark/deleteCommentMark на enqueue, семантика операций без изменений.
  2. Timestamp-защита от гонки resolve/unresolve.
  3. Тесты, прогон существующих.
  4. Прод-замер по дашборду до/после (панель API p95 уже есть).
# Суть Алерт `api-p95-degradation` (07.07, ~00:00) сработал по `route="/api/comments/resolve"`: p95 ≈ **4.5 с**. Данные из VM за 2 часа (12 запросов): 6 запросов в диапазоне **1–2.5 с**, один 2.5–5 с — это не выброс, resolve стабильно тяжёлый. Триггер — пачка резолвов за короткое окно (11 запросов за 5 минут, типичный агентский сценарий «разобрать комментарии на странице»). Это серверная половина боли, известной по #340 (там — клиентский ре-рендер на resolve; тут — секунды на сервере). **Семантика операций над метками НЕ меняется**: resolve, как и сейчас, проставляет метке `resolved:true` (клиенты прячут подсветку), dismiss/delete эфемерных предложений (#329), как и сейчас, снимает якорь с текста. Меняется только одно: HTTP-ответ перестаёт ждать эту работу. # Диагноз Цепочка `resolveComment` ([comment.service.ts#L291-L328](apps/server/src/core/comment/comment.service.ts#L291-L328)): 1. Обновление строки в БД (быстро, мс) — **источник истины**. 2. `await collaborationGateway.handleYjsEvent('resolveCommentMark', …)` → [collaboration.handler.ts#L58-L80](apps/server/src/collaboration/collaboration.handler.ts#L58-L80) → `withYdocConnection`: - `openDirectConnection` — если документ не открыт (никто не редактирует — типично для агентских резолвов), hocuspocus **загружает весь Y.Doc**: чтение большого bytea из Postgres + `Y.applyUpdate` — O(размер документа), на больших страницах сотни мс — секунды; - `transact` — обход всего XmlFragment в поиске метки (`updateYjsMarkAttribute`) — O(документа); - **ключевое:** `DirectConnection.transact` у hocuspocus вызывает `storeDocument` **немедленно, минуя debounce 10–45 с** (задокументировано в комментарии `withYdocConnection`: «after the transaction (and its storeDocument hooks) resolve»). То есть HTTP-ответ ждёт весь store-пайплайн из [persistence.extension.ts](apps/server/src/collaboration/extensions/persistence.extension.ts): `fromYdoc`→JSON, `encodeStateAsUpdate`, `jsonToText`, deep-equal всего контента, запись страницы, transclusion-синк (3 SELECT), два обхода на меншены, enqueue'ы. Итого: **операция над одной меткой оплачивается полной загрузкой + полной сериализацией + полным сохранением документа, синхронно в HTTP-запросе**. `unloadImmediately: false` уже стоит ([collaboration.gateway.ts#L56](apps/server/src/collaboration/collaboration.gateway.ts#L56)) — повторные резолвы переиспользуют загруженный док (поэтому не все запросы по 4.5 с), но immediate-store платится каждым вызовом. Ирония: ошибки этого шага уже сейчас **глотаются** (`catch` → `logger.warn`) — то есть мы ждём секунды ради операции, провал которой и так не влияет на ответ. # Решение ## 1. Убрать Yjs-операцию с критического пути HTTP (единственное изменение) Строка в БД — источник истины; операция над меткой в документе — eventual-зеркало для подключённых клиентов. Вместо `await` в запросе — идемпотентный джоб `COMMENT_MARK_UPDATE` в существующую `GENERAL_QUEUE` (`{documentName, commentId, action: 'resolve'|'unresolve'|'delete', ts}`), воркер выполняет **тот же самый `handleYjsEvent` с той же семантикой**: - `resolve`/`unresolve` → `updateYjsMarkAttribute` (флип `resolved`, как сейчас); - `delete` (dismiss эфемерных предложений) → `removeYjsMarkByAttribute` (снятие якоря, как сейчас). Очередь, а не fire-and-forget: переживает рестарт, ретраится, failed-джобы видны в BullMQ-метриках на дашборде. Операции идемпотентны — ретраи безопасны. Ожидание: p95 роута падает с ~4.5 с до десятков мс (остаются 2 запроса к БД + нотификация). **НЕ трогать `applySuggestion`**: он реально редактирует текст документа, его результат должен быть синхронным для корректного ответа клиенту — там ожидание оправдано (и его p95 отдельно понаблюдать после этого фикса). ## 2. Смягчить сам store-пайплайн — уже в #348 transclusion short-circuit, дифф меншенов, дедуп AI-джобов — режут хвост каждого immediate-store. Не дублировать, просто зафиксировать связь. ## 3. (Опция, follow-up) Батч для агентов MCP-сценарий «резолвить N комментариев страницы» мог бы делать одну сессию `withYdocConnection` на все метки вместо N циклов. После п. 1 станет некритично — оставить на потом. # Крайние случаи - **Окно рассинхрона**: HTTP-ответ вернётся до операции над меткой — открытый клиент увидит зеркало через collab с задержкой очереди (обычно <1 с). Клиент и так делает оптимистичное обновление (#340), UI не мигает. Приемлемо by design. - **Порядок resolve/unresolve**: в payload — timestamp (`resolvedAt`); воркер скипает события старше уже применённого состояния (unresolve после resolve в обратном порядке — теоретическая гонка, timestamp её закрывает). - **Документ под активным редактированием**: DirectConnection на открытый док дёшев — джоб отработает мгновенно, ничего не грузя. - **Провал джоба после исчерпания ретраев**: расхождение БД↔метка — то же, что сегодняшний проглоченный warn, но теперь видимое: failed-джобы наблюдаются метриками BullMQ на дашборде. # Тесты / проверка - Юнит: resolveComment не await'ит gateway (спай), джоб enqueue'ится с правильным payload; воркер вызывает `handleYjsEvent` с теми же аргументами и той же семантикой (resolve → флип атрибута, delete → снятие). - Существующие тесты `collaboration.handler` / comment.service — без изменений семантики mark-операций. - Прод-верификация: p95 `/api/comments/resolve` на дашборде < 100 мс; сценарий «агент резолвит 10+ комментариев» не триггерит алерт; в открытом клиенте подсветка гаснет при resolve, якорь исчезает при dismiss (смок вручную). # Вне скоупа - Клиентский ре-рендер списка комментариев — #340. - Стоимость store-пайплайна — #348. - Батч-API — follow-up при необходимости. - **Физическое снятие меток при resolve** — сознательно НЕ делаем (см. #328: снятие ломает reopen и скролл-к-месту; resolved-якоря прячутся на уровне представления). # План работ 1. Джоб `COMMENT_MARK_UPDATE` в GENERAL_QUEUE + перевод `resolveCommentMark`/`deleteCommentMark` на enqueue, семантика операций без изменений. 2. Timestamp-защита от гонки resolve/unresolve. 3. Тесты, прогон существующих. 4. Прод-замер по дашборду до/после (панель API p95 уже есть).
agent_vscode changed title from [perf][comments] POST /api/comments/resolve: p95 до 4.5 с — HTTP-ответ ждёт загрузку Y.Doc и полный store-пайплайн ради флипа одного атрибута метки to [perf][comments] POST /api/comments/resolve: p95 до 4.5 с (HTTP ждёт Y.Doc + полный store) + resolve должен СНИМАТЬ метку с текста, а не флипать атрибут 2026-07-07 00:07:36 +03:00
agent_vscode changed title from [perf][comments] POST /api/comments/resolve: p95 до 4.5 с (HTTP ждёт Y.Doc + полный store) + resolve должен СНИМАТЬ метку с текста, а не флипать атрибут to [perf][comments] POST /api/comments/resolve: p95 до 4.5 с — HTTP-ответ ждёт загрузку Y.Doc и полный store-пайплайн ради операции над меткой 2026-07-07 00:11:48 +03:00
Author
Collaborator

Уточнение механики джоба по итогам обсуждения (вопрос был: «это просто fire-and-forget?» — нет):

Гарантии доставки. HTTP-ответ возвращается после await queue.add(...) — задача персистентно в Redis (~1 мс). Воркер выполняет ровно тот же handleYjsEvent, что сегодня: DirectConnection → транзакция над меткой → hocuspocus рассылает Yjs-апдейт подключённым клиентам → immediate store. Отказы: ретраи с бэкоффом, после исчерпания — failed-set, видимый в BullMQ-метриках дашборда. Для сравнения, текущее поведение — одна попытка с проглатыванием ошибки в logger.warn: очередь даёт строго больше гарантий, чем есть сейчас.

Упрощение против гонок: reconcile вместо императива. Джоб несёт не действие («сними»/«поставь»), а только {documentName, commentId} = «приведи метку комментария X в соответствие с БД». Воркер в момент выполнения читает актуальное состояние (resolvedAt; комментарий hard-deleted → снять якорь) и применяет его. Следствия:

  • любой порядок джобов и любые дубли сходятся к последнему состоянию БД — timestamp-защита из исходного плана не нужна, пункт 2 плана работ снимается;
  • ретраи тривиально идемпотентны;
  • resolve/unresolve/dismiss — один тип джоба, три исхода по состоянию БД.

План работ соответственно: 1 (джоб-reconcile + перевод обоих хендлеров) → 3 (тесты) → 4 (прод-замер).

Уточнение механики джоба по итогам обсуждения (вопрос был: «это просто fire-and-forget?» — нет): **Гарантии доставки.** HTTP-ответ возвращается после `await queue.add(...)` — задача персистентно в Redis (~1 мс). Воркер выполняет ровно тот же `handleYjsEvent`, что сегодня: DirectConnection → транзакция над меткой → hocuspocus рассылает Yjs-апдейт подключённым клиентам → immediate store. Отказы: ретраи с бэкоффом, после исчерпания — failed-set, видимый в BullMQ-метриках дашборда. Для сравнения, текущее поведение — одна попытка с проглатыванием ошибки в `logger.warn`: очередь даёт строго больше гарантий, чем есть сейчас. **Упрощение против гонок: reconcile вместо императива.** Джоб несёт не действие («сними»/«поставь»), а только `{documentName, commentId}` = «приведи метку комментария X в соответствие с БД». Воркер в момент выполнения читает актуальное состояние (`resolvedAt`; комментарий hard-deleted → снять якорь) и применяет его. Следствия: - любой порядок джобов и любые дубли сходятся к последнему состоянию БД — timestamp-защита из исходного плана **не нужна**, пункт 2 плана работ снимается; - ретраи тривиально идемпотентны; - resolve/unresolve/dismiss — один тип джоба, три исхода по состоянию БД. План работ соответственно: 1 (джоб-reconcile + перевод обоих хендлеров) → 3 (тесты) → 4 (прод-замер).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#399