[perf][comments] POST /api/comments/resolve: p95 до 4.5 с — HTTP-ответ ждёт загрузку Y.Doc и полный store-пайплайн ради операции над меткой #399
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Суть
Алерт
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):await collaborationGateway.handleYjsEvent('resolveCommentMark', …)→ 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:fromYdoc→JSON,encodeStateAsUpdate,jsonToText, deep-equal всего контента, запись страницы, transclusion-синк (3 SELECT), два обхода на меншены, enqueue'ы.Итого: операция над одной меткой оплачивается полной загрузкой + полной сериализацией + полным сохранением документа, синхронно в HTTP-запросе.
unloadImmediately: falseуже стоит (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 станет некритично — оставить на потом.Крайние случаи
resolvedAt); воркер скипает события старше уже применённого состояния (unresolve после resolve в обратном порядке — теоретическая гонка, timestamp её закрывает).Тесты / проверка
handleYjsEventс теми же аргументами и той же семантикой (resolve → флип атрибута, delete → снятие).collaboration.handler/ comment.service — без изменений семантики mark-операций./api/comments/resolveна дашборде < 100 мс; сценарий «агент резолвит 10+ комментариев» не триггерит алерт; в открытом клиенте подсветка гаснет при resolve, якорь исчезает при dismiss (смок вручную).Вне скоупа
План работ
COMMENT_MARK_UPDATEв GENERAL_QUEUE + переводresolveCommentMark/deleteCommentMarkна enqueue, семантика операций без изменений.[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 должен СНИМАТЬ метку с текста, а не флипать атрибут[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-пайплайн ради операции над меткойУточнение механики джоба по итогам обсуждения (вопрос был: «это просто 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 → снять якорь) и применяет его. Следствия:План работ соответственно: 1 (джоб-reconcile + перевод обоих хендлеров) → 3 (тесты) → 4 (прод-замер).