perf(mcp): кэш живого collab-соединения (CollabSession) — серия правок за один connect/sync #431
Reference in New Issue
Block a user
Delete Branch "perf/400-collab-session"
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?
Summary
Инцидент: AI-агент заполнял большую таблицу (ydoc 315 КБ) десятками
table_update_cell, каждый — полный цикл connect/auth/onLoadDocument/initial-sync/store/unload; часть падала сConnection timeout to collaboration server(25с), event loop lag до 1.7с,collab_store_duration~1.48с.Вариант B: кэшировать живой
HocuspocusProviderper page на серию правок. Пока провайдер жив (connections>0) — сервер не входит в цикл store→unload→reload, дебаунс 10/45с реально коалесцирует записи (N ячеек → 1–2 store вместо N), исчезают повторные auth/load/initial-sync и долбёжка unload-гонки.Новый модуль
packages/mcp/src/lib/collab-session.ts: классCollabSession(connecting→ready→dead) + реестр (ключwsUrl+pageId+token) с idle-TTL / max-age / LRU-evict.mutatePageContent(collaboration.ts) иmutateLiveContentUnlocked(client.ts,replaceImage) переведены наacquireCollabSession; one-shot Promise-машина (~360 строк дублирования) удалена (нетто −392/+1057, из них тесты).closes #400
5 обязательных инвариантов (подтверждены внутренним ревью построчно)
TiptapTransformer.fromYdocиapplyDocToFragmentнет ни одногоawait(секция идентична оригинальномуonSynced, теперь на долгоживущем провайдере).unsyncedChanges===0, иначе ждать событие сPERSIST_TIMEOUT_MS; гард!connectionLostот false-success при реконнекте сохранён дословно; слушатели снимаются на обоих путях.onDisconnect/onClose/onAuthenticationFailed→ dead + deregister + reject in-flight теми же текстами ошибок, что на develop; авто-реконнект не используется.replaceImageработает под ВНЕШНИМ page-локом без дедлока (acquire/mutate лок не берут). Тексты ошибок == develop.Env / rollback
MCP_COLLAB_SESSION_IDLE_MS(0 = выключить кэш = точное легаси-поведение, путь отката),MCP_COLLAB_SESSION_MAX_AGE_MS(10мин),MCP_COLLAB_SESSION_MAX_ENTRIES(32).Известные follow-up (ниты внутреннего ревью, LOW, не блокеры)
destroyAllSessionsобвязан в stdio (exit/SIGINT/SIGTERM) и реэкспортирован из index для хоста, но сам apps/server его пока не зовёт на shutdown. Runtime-утечки нет (idle-таймерыunref, max-age, LRU-cap ограничивают). Проводка в NestJS-lifecycle идёт через ESM-load-путь (барьер CJS/ESM) — оставил отдельным follow-up, чтобы не тянуть apps/server в этот PR. Скажи, если хочешь в этом же.How verified
pnpm --filter @docmost/mcp build— tsc чисто.node --test collab-session.test.mjs resolve-page-id-collab-doc-name.test.mjs→ 22/22 PASS: reuse == та же сессия; N=20 mutates → 1 connect; токен в ключе → разные сессии; disconnect/auth/mutate-error инвалидация + re-acquire; pending-ack резолвится наunsyncedChanges→0; connect-timeout текст+метрика; idle-TTL и max-age destroy (mock timers); LRU-evict;IDLE_MS=0легаси provider-per-op; replaceImage-flow под одним внешним page-локом → без дедлока, 1 сессия;destroyAllSessions.Внутренний цикл: 1 проход coder → внутреннее ревrereview (все 5 инвариантов + deadlock-free + IDLE_MS=0 + lifecycle) → APPROVE WITH SUGGESTIONS (только low-ниты выше).
Checklist
Ревью — #431
perf(mcp): кэш живого collab-соединения (CollabSession) — серия правок за один connect/syncВердикт: ПРАВКИ (CHANGES) — 1 правка (fail-fast гард на mutate). Всё остальное чисто.
Полный веер из 9 аспектов по всему диффу PR (merge-base
d90c3b8b..572f0a2a, +1057/−392). Гейт зелёный (clean-room/home/claude/gm-ci, чистая пересборка): prosemirror-markdown build 0 → mcp build (tsc) 0 → node --test 504/504 → server tsc 0. Паритет write-пути и жизненный цикл проверил напрямую + мутационно.Правка (сделать, потом ре-ревью)
mutate()не гардит одновременный вызов — тихий 20-сек висяк при нарушении контракта —packages/mcp/src/lib/collab-session.ts(mutate, полеthis.inflightReject)Ин-флайт трекается ОДНИМ полем
this.inflightReject. Входной гард mutate проверяетdead/state/connectionLost/provider/synced, но НЕ «уже есть ин-флайт». Если на одной сессии стартуют дваmutateвнахлёст, второй ПЕРЕЗАПИШЕТinflightRejectпервого → при disconnect отклонится только второй, а первый провисит доPERSIST_TIMEOUT_MS(20с) с висячим промисом. В проде безопасно: оба call-site (collaboration.ts:234,client.tsreplaceImage) оборачиваютmutateв per-pagewithPageLock, сессия — per-pageId, лок не-реентрантный → сериализация гарантирована;localFinishсинхронно сбрасываетinflightRejectдо возврата, так что ПОСЛЕДОВАТЕЛЬНЫЕ mutate (replaceImage scan+write) гард не задевают. Но на новом 648-строчном разделяемом примитиве это футган для будущего вызывающего. Fix: в началеmutate—if (this.inflightReject) return Promise.reject(new Error("mutate already in-flight; caller must serialize (hold the page lock)"))(безопасно для последовательных вызовов — проверено), + строка докстринга наmutate, что одновременный вызов запрещён (как уже задокументировано наacquireCollabSession), + тест на этот гард.Что проверено и чисто (клик)
TiptapTransformer.fromYdoc→validate→applyDocToFragment→summarizeChange) и persistence-ack (unsyncedChanges===0,PERSIST_TIMEOUT_MS=20000, тексты ошибок) идентичны one-shot-машине — отличие толькоydoc→this.ydoc,finish→localFinish.connectionLost=true+ удаление из реестра → следующий acquire коннектит заново.isReusable()=!connectionLost && synced && !past-max-age.destroy()-ится РОВНО раз на всех путях (idle-TTL / max-age / LRU-evict / disconnect / auth-fail / connect-timeout / shutdowndestroyAllSessionsvia stdio.ts). Таймеры (connect/idle/persist) и листенерunsyncedChangesсбрасываются вlocalFinish/teardownна всех путях. Реестр консистентен (guard против вытеснения более новой записи).withPageLockсериализует per-page; синхронная секция read→write без await — атомарна к доставке Yjs-событий.sessions.deleteловится.⚠️ К сведению (НЕ правка, decided-tradeoff — скажи, если не согласен)
MCP_COLLAB_SESSION_MAX_AGE_MS(дефолт 10 мин) без ре-аута. Т.е. пользователь, у которого ОТОЗВАЛИ право записи на страницу, может писать через тёплую сессию ≤10 мин. Автор это осознанно ограничил (max-age enforced at acquire, конфигурируемо, per-user изоляция: ключ включает token → чужую сессию не переиспользовать). Насколько это реально эксплуатируемо, зависит от того, ре-валидирует ли collab-СЕРВЕР права на каждое сообщение (это в apps/server, вне scope PR). Ограничено и само-заживает; если хочешь дефолт ниже (напр. 2 мин) — скажи, поправим отдельно. Не блокирую.⛔ DROP — кодеру НЕ делать (лог калибровки)
[below-threshold]low/med[simplification] тестENV_KEYSсписок из 3 ключей поддерживается вручную — ключей мало и стабильны.[below-threshold]low/med[documentation] header-комментарий «verbatim from one-shot machine» точен для логики внутриonSynced, но обёртка (lifecycle/teardown) новая — формулировку можно сузить; паритет-аспект подтвердил, что claim по существу верен.9 аспектов (security · stability · regressions · test-coverage · conventions · documentation · simplification · architecture · coherence). Паритет write-пути и in-flight-гард проверены мной по коду напрямую; lost-update и утечки — независимо stability+regressions. Окно прав — decided-tradeoff, вынес к сведению, не escalate. 1 DO.
Поправил единственную запрошенную правку (fail-fast гард на конкурентный mutate).
[stability/architecture]
mutate()не гардил одновременный вызов — исправлено. В началеmutate, после ready-проверки и ДО касанияinflightReject, добавлен гард: если уже есть in-flight —Promise.reject(new Error("mutate already in-flight; caller must serialize (hold the page lock)")), БЕЗ порчи состояния первого (никакого localFinish/teardown). Docstringmutateполучил блок CONCURRENCY (одновременный вызов запрещён, вызывающий держит per-page лок — как уже задокументировано наacquireCollabSession).Последовательные mutate не задеты:
localFinishсинхронно чиститinflightRejectдо резолва/реджекта на всех путях (abort/transform-error/persist-success/timeout/teardown), так что replaceImage scan+write и «N mutates серией» проходят.+2 теста: конкурентный второй
mutateреджектится «already in-flight», первый доезжает до успеха (connectCount=1, без реконнекта); последовательные — оба успешны.Проверка:
pnpm --filter @docmost/mcp buildчисто;node --test collab-session + resolve-page-id-collab-doc-name→ 24/24 (22 прежних + 2 новых).По FYI про окно устаревания прав (≤10 мин max-age): осознанный tradeoff, дефолт конфигурируем (
MCP_COLLAB_SESSION_MAX_AGE_MS). Если хочешь дефолт ниже (напр. 2 мин) — скажу коду, но это отдельно от этой правки. Готово к повторному ревью.Ревью (раунд 2) — #431
perf(mcp): кэш живого collab-соединения (CollabSession) — серия правок за один connect/syncВердикт: PASS ✅ — единственная правка раунда 1 закрыта.
Полный веер из 9 аспектов по всему диффу PR (merge-base
d90c3b8b..0d4f719f). Гейт зелёный (clean-room/home/claude/gm-ci, чистая пересборка): prosemirror-markdown build 0 → mcp build (tsc) 0 → node --test 506/506 (+2 к раунду 1) → server tsc 0.Закрытие DO раунда 1 (fail-fast гард на конкурентный mutate) — проверено адверсариально
Добавлен гард в начале
mutate():if (this.inflightReject) return Promise.reject(new Error("mutate already in-flight; caller must serialize (hold the page lock)")). Ключевая корректность: гард отклоняет наложение, НЕ трогая состояние ин-флайт-операции — возвращает reject ДО входа в конструктор Promise, не чиститinflightReject, не зовётlocalFinish/teardown, не регистрирует таймер/листенер. Первая операция остаётся нетронутой.if (false && this.inflightReject)) → пересобрал → тест «concurrent mutate: the second rejects, the first is unaffected» падает (17/18). Гард реально держит.connectCount==1(нет реконнекта от отклонённого наложения).localFinishсинхронно сбрасываетinflightRejectДО settle промиса, поэтому awaited-последовательность (replaceImage scan+write под одним page-lock) проходит — покрыто вторым новым тестом.mutate()точен и зеркалит уже существующую заметку наacquireCollabSession(«caller MUST hold the page lock»); оба call-site оборачивают вwithPageLock.Контекст раунда 1 (всё подтверждено, без изменений) — клик
Write-путь verbatim-идентичен one-shot-машине; lost-update нет (соединение живое → Yjs апдейты в реальном времени); утечек провайдера/таймеров/листенеров нет (destroy ровно раз на всех путях, teardown чистит-перед-вызовом); реестр консистентен; conventions/documentation/simplification — LGTM.
⚠️ К сведению (без изменений с раунда 1, не блокирую)
Окно устаревания прав ≤10 мин (кеш переиспользует авторизованное соединение до
MCP_COLLAB_SESSION_MAX_AGE_MS) — decided-tradeoff, конфигурируемо, per-user изоляция. Если хочешь дефолт ниже — скажи.DO/ESCALATE нет. Правка раунда 1 закрыта корректно и мутационно-подтверждена; гейт зелёный. Approve.
9 аспектов. Гард проверен мной по коду + мутационно (с пересборкой mcp — иначе node --test гоняет старый build); корректность «не трогает ин-флайт» и последовательная безопасность независимо подтверждены stability + testcov. Escalate нет.