perf(collab): три фикса горячего пути сервера — гонка connect-vs-unload (hocuspocus 3.4.4), двойная перекодировка в onLoadDocument, isDeepStrictEqual на каждом store #401

Closed
opened 2026-07-07 01:43:28 +03:00 by agent_vscode · 0 comments
Collaborator

Побочные находки анализа инцидента «правка большой таблицы через MCP подвешивает всё приложение» (см. #400 — там контекст и замеры: страница с ydoc 315 КБ / content-JSON 84 КБ, collab_store_duration ~1.48 с, nodejs_eventloop_lag_max до 1.7 с). Три независимых серверных пункта, все — про горячий путь collab; #400 снижает частоту попадания в них, но не устраняет сами дефекты (человеческие правки большой страницы бьют по тем же местам).

1. Гонка connect-vs-unload в hocuspocus 3.4.4 — вероятный источник 25-секундных connect-таймаутов

Проблема. В @hocuspocus/server@3.4.4 createDocument проверяет loadingDocuments и documents, но НЕ ждёт unloadingDocuments (dist hocuspocus-server.esm.js, createDocument ~строка 2408; unloadingDocuments используется только внутри самого unloadDocument, ~2563). Окно гонки:

  1. последний клиент отключился, дебаунс-store отработал, начался unload;
  2. новое подключение приходит до documents.delete(...) -> createDocument возвращает умирающий Document;
  3. auth-хуки асинхронные (DB-запросы), соединение ещё не учтено в getConnectionsCount() -> повторная проверка shouldUnloadDocument внутри unload видит 0 подключений и удаляет документ из карты;
  4. клиент завершает handshake на осиротевшем инстансе; последующие сообщения через redis-sync идут по пути «doc не загружен» — синхронизация не завершается, провайдер висит до таймаута.

MCP-паттерн «connect/disconnect на каждую ячейку» с паузами, осциллирующими вокруг границы debounce: 10000, попадает в это окно систематически — это согласуется с тем, что таймаутились только ЧАСТЬ вызовов.

Фикс. Патч @hocuspocus/server через pnpm patch (инфраструктура уже есть: patches/ + pnpm.patchedDependencies в корневом package.json, там уже патчится yjs@13.6.30): в начале createDocument дождаться this.unloadingDocuments.get(documentName), если есть. Перед патчем проверить upstream hocuspocus (master / releases после 3.4.4) — возможно, уже починено и достаточно бэкпорта.

Приёмка: воспроизводящий тест (или хотя бы стресс-скрипт): цикл connect -> write -> disconnect с паузой ~= debounce по одной странице, N итераций — ни одного зависшего sync/таймаута; патч в patches/, lockfile обновлён.

2. Двойная перекодировка документа при загрузке

Проблема. PersistenceExtension.onLoadDocument (apps/server/src/collaboration/extensions/persistence.extension.ts:149-193) строит НОВЫЙ Y.Doc и возвращает его. Hocuspocus для возвращённого дока делает applyUpdate(document, encodeStateAsUpdate(loadedDocument)) (dist, loadDocument) — то есть 315-килобайтный стейт кодируется и применяется ВТОРОЙ раз на каждую холодную загрузку. Плюс в JSON-ветке результат Y.encodeStateAsUpdate(ydoc) (строка 187) просто выбрасывается — мёртвый вызов.

Фикс. Применять стейт из БД напрямую в целевой документ хука: Y.applyUpdate(data.document, dbState) (и для JSON-ветки — кодировать toYdoc-результат один раз и применять в data.document), возвращать undefined. Семантику hocuspocus проверить тестом: при возврате undefined мутированный data.document остаётся как есть (merge-ветка срабатывает только когда хук вернул Doc). Удалить мёртвый Y.encodeStateAsUpdate.

Приёмка: существующие persistence-store.spec.ts / collaboration.handler.spec.ts зелёные; новый юнит: после onLoadDocument контент документа хука эквивалентен ydoc из БД; на большой странице профиль холодной загрузки показывает один encode+apply вместо двух.

3. isDeepStrictEqual по 84 КБ JSON на каждом store

Проблема. storeDocument (persistence.extension.ts:270) на КАЖДОМ store сравнивает свежесериализованный tiptapJson с page.content через isDeepStrictEqual — включая заведомо пустые store'ы (дебаунс-тик без реальных изменений). Второй такой же вызов — в agent-boundary ветке (строка 352, сравнение с последним history-снапшотом). На 84 КБ JSON это заметная синхронная работа в и так тяжёлой секции (~1.48 с суммарно).

Фикс (после бенчмарка). Сначала замерить долю isDeepStrictEqual в collab_store_duration на проде/копии большой страницы — если меньше ~50 мс, понизить приоритет пункта (доминирует, скорее всего, fromYdoc+encodeStateAsUpdate). Кандидаты:

  • (a) дешёвый dirty-флаг: взводить по onChange (реальный Yjs-апдейт по документу), сбрасывать по успешному store; store без флага — no-op без сравнения. Консервативная сверхаппроксимация: в худшем случае (апдейт, не изменивший JSON) поведение равно сегодняшнему;
  • (b) сравнение хэшей канонического JSON (в памяти на время жизни документа) вместо глубокого сравнения объектов;
  • (c) перенос сравнения вместе со всей сериализацией в worker_thread — это уже отдельная большая тема «удешевление onStoreDocument» (вариант C анализа), сюда не тянуть.

Приёмка: бенчмарк-цифры в комментарии к ишью; выбранный вариант не меняет семантику no-op-store (страница не апдейтится, history/AI-джобы не заводятся — как сейчас при равенстве); спеки persistence зелёные.


Порядок: п.1 — средняя серьёзность (корень 25s-таймаутов, актуален и после #400 для обычных клиентов), п.2 — дешёвый чистый выигрыш CPU на каждую холодную загрузку, п.3 — только после замера.

Побочные находки анализа инцидента «правка большой таблицы через MCP подвешивает всё приложение» (см. #400 — там контекст и замеры: страница с ydoc 315 КБ / content-JSON 84 КБ, `collab_store_duration` ~1.48 с, `nodejs_eventloop_lag_max` до 1.7 с). Три независимых серверных пункта, все — про горячий путь collab; #400 снижает частоту попадания в них, но не устраняет сами дефекты (человеческие правки большой страницы бьют по тем же местам). ## 1. Гонка connect-vs-unload в hocuspocus 3.4.4 — вероятный источник 25-секундных connect-таймаутов **Проблема.** В `@hocuspocus/server@3.4.4` `createDocument` проверяет `loadingDocuments` и `documents`, но НЕ ждёт `unloadingDocuments` (dist `hocuspocus-server.esm.js`, `createDocument` ~строка 2408; `unloadingDocuments` используется только внутри самого `unloadDocument`, ~2563). Окно гонки: 1. последний клиент отключился, дебаунс-store отработал, начался unload; 2. новое подключение приходит до `documents.delete(...)` -> `createDocument` возвращает умирающий `Document`; 3. auth-хуки асинхронные (DB-запросы), соединение ещё не учтено в `getConnectionsCount()` -> повторная проверка `shouldUnloadDocument` внутри unload видит 0 подключений и удаляет документ из карты; 4. клиент завершает handshake на осиротевшем инстансе; последующие сообщения через redis-sync идут по пути «doc не загружен» — синхронизация не завершается, провайдер висит до таймаута. MCP-паттерн «connect/disconnect на каждую ячейку» с паузами, осциллирующими вокруг границы `debounce: 10000`, попадает в это окно систематически — это согласуется с тем, что таймаутились только ЧАСТЬ вызовов. **Фикс.** Патч `@hocuspocus/server` через pnpm patch (инфраструктура уже есть: `patches/` + `pnpm.patchedDependencies` в корневом `package.json`, там уже патчится `yjs@13.6.30`): в начале `createDocument` дождаться `this.unloadingDocuments.get(documentName)`, если есть. Перед патчем проверить upstream hocuspocus (master / releases после 3.4.4) — возможно, уже починено и достаточно бэкпорта. **Приёмка:** воспроизводящий тест (или хотя бы стресс-скрипт): цикл connect -> write -> disconnect с паузой ~= debounce по одной странице, N итераций — ни одного зависшего sync/таймаута; патч в `patches/`, lockfile обновлён. ## 2. Двойная перекодировка документа при загрузке **Проблема.** `PersistenceExtension.onLoadDocument` (`apps/server/src/collaboration/extensions/persistence.extension.ts:149-193`) строит НОВЫЙ `Y.Doc` и возвращает его. Hocuspocus для возвращённого дока делает `applyUpdate(document, encodeStateAsUpdate(loadedDocument))` (dist, `loadDocument`) — то есть 315-килобайтный стейт кодируется и применяется ВТОРОЙ раз на каждую холодную загрузку. Плюс в JSON-ветке результат `Y.encodeStateAsUpdate(ydoc)` (строка 187) просто выбрасывается — мёртвый вызов. **Фикс.** Применять стейт из БД напрямую в целевой документ хука: `Y.applyUpdate(data.document, dbState)` (и для JSON-ветки — кодировать `toYdoc`-результат один раз и применять в `data.document`), возвращать `undefined`. Семантику hocuspocus проверить тестом: при возврате `undefined` мутированный `data.document` остаётся как есть (merge-ветка срабатывает только когда хук вернул Doc). Удалить мёртвый `Y.encodeStateAsUpdate`. **Приёмка:** существующие `persistence-store.spec.ts` / `collaboration.handler.spec.ts` зелёные; новый юнит: после `onLoadDocument` контент документа хука эквивалентен ydoc из БД; на большой странице профиль холодной загрузки показывает один encode+apply вместо двух. ## 3. `isDeepStrictEqual` по 84 КБ JSON на каждом store **Проблема.** `storeDocument` (`persistence.extension.ts:270`) на КАЖДОМ store сравнивает свежесериализованный `tiptapJson` с `page.content` через `isDeepStrictEqual` — включая заведомо пустые store'ы (дебаунс-тик без реальных изменений). Второй такой же вызов — в agent-boundary ветке (строка 352, сравнение с последним history-снапшотом). На 84 КБ JSON это заметная синхронная работа в и так тяжёлой секции (~1.48 с суммарно). **Фикс (после бенчмарка).** Сначала замерить долю `isDeepStrictEqual` в `collab_store_duration` на проде/копии большой страницы — если меньше ~50 мс, понизить приоритет пункта (доминирует, скорее всего, `fromYdoc`+`encodeStateAsUpdate`). Кандидаты: - (a) дешёвый dirty-флаг: взводить по `onChange` (реальный Yjs-апдейт по документу), сбрасывать по успешному store; store без флага — no-op без сравнения. Консервативная сверхаппроксимация: в худшем случае (апдейт, не изменивший JSON) поведение равно сегодняшнему; - (b) сравнение хэшей канонического JSON (в памяти на время жизни документа) вместо глубокого сравнения объектов; - (c) перенос сравнения вместе со всей сериализацией в worker_thread — это уже отдельная большая тема «удешевление onStoreDocument» (вариант C анализа), сюда не тянуть. **Приёмка:** бенчмарк-цифры в комментарии к ишью; выбранный вариант не меняет семантику no-op-store (страница не апдейтится, history/AI-джобы не заводятся — как сейчас при равенстве); спеки persistence зелёные. --- Порядок: п.1 — средняя серьёзность (корень 25s-таймаутов, актуален и после #400 для обычных клиентов), п.2 — дешёвый чистый выигрыш CPU на каждую холодную загрузку, п.3 — только после замера.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#401