perf(collab): три фикса горячего пути сервера — гонка connect-vs-unload (hocuspocus 3.4.4), двойная перекодировка в onLoadDocument, isDeepStrictEqual на каждом store #401
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?
Побочные находки анализа инцидента «правка большой таблицы через 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.4createDocumentпроверяетloadingDocumentsиdocuments, но НЕ ждётunloadingDocuments(disthocuspocus-server.esm.js,createDocument~строка 2408;unloadingDocumentsиспользуется только внутри самогоunloadDocument, ~2563). Окно гонки:documents.delete(...)->createDocumentвозвращает умирающийDocument;getConnectionsCount()-> повторная проверкаshouldUnloadDocumentвнутри unload видит 0 подключений и удаляет документ из карты;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). Кандидаты:onChange(реальный Yjs-апдейт по документу), сбрасывать по успешному store; store без флага — no-op без сравнения. Консервативная сверхаппроксимация: в худшем случае (апдейт, не изменивший JSON) поведение равно сегодняшнему;Приёмка: бенчмарк-цифры в комментарии к ишью; выбранный вариант не меняет семантику no-op-store (страница не апдейтится, history/AI-джобы не заводятся — как сейчас при равенстве); спеки persistence зелёные.
Порядок: п.1 — средняя серьёзность (корень 25s-таймаутов, актуален и после #400 для обычных клиентов), п.2 — дешёвый чистый выигрыш CPU на каждую холодную загрузку, п.3 — только после замера.