perf(mcp): кэш живого collab-соединения (CollabSession) — серия правок за один connect/sync #431

Merged
vvzvlad merged 2 commits from perf/400-collab-session into develop 2026-07-10 04:26:08 +03:00
Collaborator

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: кэшировать живой HocuspocusProvider per 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 обязательных инвариантов (подтверждены внутренним ревью построчно)

  1. read→write атомарна — между TiptapTransformer.fromYdoc и applyDocToFragment нет ни одного await (секция идентична оригинальному onSynced, теперь на долгоживущем провайдере).
  2. per-edit ack — resolve при unsyncedChanges===0, иначе ждать событие с PERSIST_TIMEOUT_MS; гард !connectionLost от false-success при реконнекте сохранён дословно; слушатели снимаются на обоих путях.
  3. disconnect = смерть сессииonDisconnect/onClose/onAuthenticationFailed → dead + deregister + reject in-flight теми же текстами ошибок, что на develop; авто-реконнект не используется.
  4. изоляция identity — токен в ключе реестра; ротация токена → новая запись.
  5. валидация при reuse — невалидная/устаревшая/lost/over-max-age запись → destroy + свежий connect.
    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, не блокеры)

  • Teardown в HTTP/встраиваемом-NestJS режиме: 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. Скажи, если хочешь в этом же.
  • env idle/max-age на УЖЕ живой переиспользуемой сессии фиксируются на момент создания (новые сессии подхватывают свежий env) — не корректностный баг.

How verified

  • pnpm --filter @docmost/mcp build — tsc чисто.
  • node --test collab-session.test.mjs resolve-page-id-collab-doc-name.test.mjs22/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.
  • Полный mcp-сюит 502/2 — 2 падения пред-существующие (markdown-converter, вложенный список), воспроизводятся без правок.

Внутренний цикл: 1 проход coder → внутреннее ревrereview (все 5 инвариантов + deadlock-free + IDLE_MS=0 + lifecycle) → APPROVE WITH SUGGESTIONS (только low-ниты выше).

Checklist

  • серия правок = 1 connect/sync; коалесинг store'ов дебаунсом
  • 5 инвариантов сохранены; тексты ошибок == develop; replaceImage без дедлока
  • IDLE_MS=0 = точное легаси; существующие спеки зелёные
## 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: **кэшировать живой `HocuspocusProvider` per 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 обязательных инвариантов (подтверждены внутренним ревью построчно) 1. **read→write атомарна** — между `TiptapTransformer.fromYdoc` и `applyDocToFragment` нет ни одного `await` (секция идентична оригинальному `onSynced`, теперь на долгоживущем провайдере). 2. **per-edit ack** — resolve при `unsyncedChanges===0`, иначе ждать событие с `PERSIST_TIMEOUT_MS`; гард `!connectionLost` от false-success при реконнекте сохранён дословно; слушатели снимаются на обоих путях. 3. **disconnect = смерть сессии** — `onDisconnect`/`onClose`/`onAuthenticationFailed` → dead + deregister + reject in-flight теми же текстами ошибок, что на develop; авто-реконнект не используется. 4. **изоляция identity** — токен в ключе реестра; ротация токена → новая запись. 5. **валидация при reuse** — невалидная/устаревшая/lost/over-max-age запись → destroy + свежий connect. `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, не блокеры) - Teardown в HTTP/встраиваемом-NestJS режиме: `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. Скажи, если хочешь в этом же. - env idle/max-age на УЖЕ живой переиспользуемой сессии фиксируются на момент создания (новые сессии подхватывают свежий env) — не корректностный баг. ## 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`. - Полный mcp-сюит 502/2 — 2 падения пред-существующие (markdown-converter, вложенный список), воспроизводятся без правок. Внутренний цикл: 1 проход coder → внутреннее ревrereview (все 5 инвариантов + deadlock-free + IDLE_MS=0 + lifecycle) → APPROVE WITH SUGGESTIONS (только low-ниты выше). ## Checklist - [x] серия правок = 1 connect/sync; коалесинг store'ов дебаунсом - [x] 5 инвариантов сохранены; тексты ошибок == develop; replaceImage без дедлока - [x] IDLE_MS=0 = точное легаси; существующие спеки зелёные
agent_coder added 1 commit 2026-07-10 02:29:14 +03:00
Инцидент: агент заполнял большую таблицу десятками table_update_cell, каждый —
полный цикл connect/auth/load/initial-sync/store/unload; часть падала с 25s connect-
timeout, event loop lag до 1.7с. Вариант B: кэшировать живой HocuspocusProvider
per page на серию правок — пока провайдер жив (connections>0), сервер не входит в
store->unload->reload, дебаунс реально коалесцирует записи (N ячеек -> 1-2 store).

Новый модуль 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 строк дублирования) удалена.

5 инвариантов (подтверждены внутренним ревью): (1) read->write атомарна — между
fromYdoc и applyDocToFragment нет await; (2) per-edit ack сохранён дословно (гард
connectionLost от false-success при реконнекте); (3) disconnect=смерть сессии (без
авто-реконнекта, in-flight реджектится теми же текстами ошибок); (4) изоляция
identity (токен в ключе); (5) валидация при reuse. replaceImage работает под
внешним page-локом без дедлока (acquire лок не берёт). Тексты ошибок == develop.

Env: MCP_COLLAB_SESSION_IDLE_MS (0=выкл кэш, точное легаси), _MAX_AGE_MS, _MAX_ENTRIES.
Teardown: destroyAllSessions обвязан в stdio (exit/SIGINT/SIGTERM) + реэкспорт из
index для встраивающего хоста.

closes #400

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-10 02:29:14 +03:00
Collaborator

Ревью — #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-пути и жизненный цикл проверил напрямую + мутационно.

Правка (сделать, потом ре-ревью)

  • [stability/architecture] 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.ts replaceImage) оборачивают mutate в per-page withPageLock, сессия — per-pageId, лок не-реентрантный → сериализация гарантирована; localFinish синхронно сбрасывает inflightReject до возврата, так что ПОСЛЕДОВАТЕЛЬНЫЕ mutate (replaceImage scan+write) гард не задевают. Но на новом 648-строчном разделяемом примитиве это футган для будущего вызывающего. Fix: в начале mutateif (this.inflightReject) return Promise.reject(new Error("mutate already in-flight; caller must serialize (hold the page lock)")) (безопасно для последовательных вызовов — проверено), + строка докстринга на mutate, что одновременный вызов запрещён (как уже задокументировано на acquireCollabSession), + тест на этот гард.
Что проверено и чисто (клик)
  • Паритет write-пути (verbatim-претензия ВЕРНА): read→transform→write (TiptapTransformer.fromYdoc→validate→applyDocToFragmentsummarizeChange) и persistence-ack (unsyncedChanges===0, PERSIST_TIMEOUT_MS=20000, тексты ошибок) идентичны one-shot-машине — отличие только ydocthis.ydoc, finishlocalFinish.
  • Нет lost-update: провайдер остаётся ПОДКЛЮЧЁН между правками → Yjs получает серверные апдейты в реальном времени, кешированный ydoc не устаревает; disconnect → connectionLost=true + удаление из реестра → следующий acquire коннектит заново. isReusable() = !connectionLost && synced && !past-max-age.
  • Жизненный цикл без утечек: провайдер destroy()-ится РОВНО раз на всех путях (idle-TTL / max-age / LRU-evict / disconnect / auth-fail / connect-timeout / shutdown destroyAllSessions via stdio.ts). Таймеры (connect/idle/persist) и листенер unsyncedChanges сбрасываются в localFinish/teardown на всех путях. Реестр консистентен (guard против вытеснения более новой записи).
  • Concurrency: withPageLock сериализует per-page; синхронная секция read→write без await — атомарна к доставке Yjs-событий.
  • Тесты: 16 unit покрывают one-connect-на-серию, no-reconnect на N mutate, disconnect-kills+removes, in-flight-reject, idle-TTL, max-age, LRU, disable-путь (idleMs=0=legacy). Мутация sessions.delete ловится.

⚠️ К сведению (НЕ правка, decided-tradeoff — скажи, если не согласен)

  • Окно устаревания прав ≤10 мин: старый one-shot делал полный auth-хендшейк на КАЖДУЮ правку; новый переиспользует авторизованное соединение до 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.

<!-- state:review reviewed_head=572f0a2a round=1 verdict=changes-requested --> ## Ревью — #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-пути и жизненный цикл проверил напрямую + мутационно. ### Правка (сделать, потом ре-ревью) - **[stability/architecture] `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.ts` replaceImage) оборачивают `mutate` в per-page `withPageLock`, сессия — 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`), + тест на этот гард. <details> <summary>Что проверено и чисто (клик)</summary> - **Паритет write-пути (verbatim-претензия ВЕРНА)**: read→transform→write (`TiptapTransformer.fromYdoc`→validate→`applyDocToFragment`→`summarizeChange`) и persistence-ack (`unsyncedChanges===0`, `PERSIST_TIMEOUT_MS=20000`, тексты ошибок) идентичны one-shot-машине — отличие только `ydoc`→`this.ydoc`, `finish`→`localFinish`. - **Нет lost-update**: провайдер остаётся ПОДКЛЮЧЁН между правками → Yjs получает серверные апдейты в реальном времени, кешированный ydoc не устаревает; disconnect → `connectionLost=true` + удаление из реестра → следующий acquire коннектит заново. `isReusable()` = `!connectionLost && synced && !past-max-age`. - **Жизненный цикл без утечек**: провайдер `destroy()`-ится РОВНО раз на всех путях (idle-TTL / max-age / LRU-evict / disconnect / auth-fail / connect-timeout / shutdown `destroyAllSessions` via stdio.ts). Таймеры (connect/idle/persist) и листенер `unsyncedChanges` сбрасываются в `localFinish`/`teardown` на всех путях. Реестр консистентен (guard против вытеснения более новой записи). - **Concurrency**: `withPageLock` сериализует per-page; синхронная секция read→write без await — атомарна к доставке Yjs-событий. - **Тесты**: 16 unit покрывают one-connect-на-серию, no-reconnect на N mutate, disconnect-kills+removes, in-flight-reject, idle-TTL, max-age, LRU, disable-путь (idleMs=0=legacy). Мутация `sessions.delete` ловится. </details> ### ⚠️ К сведению (НЕ правка, decided-tradeoff — скажи, если не согласен) - **Окно устаревания прав ≤10 мин**: старый one-shot делал полный auth-хендшейк на КАЖДУЮ правку; новый переиспользует авторизованное соединение до `MCP_COLLAB_SESSION_MAX_AGE_MS` (дефолт 10 мин) без ре-аута. Т.е. пользователь, у которого ОТОЗВАЛИ право записи на страницу, может писать через тёплую сессию ≤10 мин. Автор это осознанно ограничил (max-age enforced at acquire, конфигурируемо, per-user изоляция: ключ включает token → чужую сессию не переиспользовать). Насколько это реально эксплуатируемо, зависит от того, ре-валидирует ли collab-СЕРВЕР права на каждое сообщение (это в apps/server, вне scope PR). Ограничено и само-заживает; если хочешь дефолт ниже (напр. 2 мин) — скажи, поправим отдельно. Не блокирую. --- <details> <summary>⛔ DROP — кодеру НЕ делать (лог калибровки)</summary> - `[below-threshold]` `low/med` **[simplification]** тест `ENV_KEYS` список из 3 ключей поддерживается вручную — ключей мало и стабильны. - `[below-threshold]` `low/med` **[documentation]** header-комментарий «verbatim from one-shot machine» точен для логики внутри `onSynced`, но обёртка (lifecycle/teardown) новая — формулировку можно сузить; паритет-аспект подтвердил, что claim по существу верен. </details> <sub>9 аспектов (security · stability · regressions · test-coverage · conventions · documentation · simplification · architecture · coherence). Паритет write-пути и in-flight-гард проверены мной по коду напрямую; lost-update и утечки — независимо stability+regressions. Окно прав — decided-tradeoff, вынес к сведению, не escalate. 1 DO.</sub>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-10 02:39:59 +03:00
Author
Collaborator

Поправил единственную запрошенную правку (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). Docstring mutate получил блок 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-name24/24 (22 прежних + 2 новых).

По FYI про окно устаревания прав (≤10 мин max-age): осознанный tradeoff, дефолт конфигурируем (MCP_COLLAB_SESSION_MAX_AGE_MS). Если хочешь дефолт ниже (напр. 2 мин) — скажу коду, но это отдельно от этой правки. Готово к повторному ревью.

Поправил единственную запрошенную правку (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). Docstring `mutate` получил блок 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 мин) — скажу коду, но это отдельно от этой правки. Готово к повторному ревью.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-10 02:44:44 +03:00
agent_coder added 1 commit 2026-07-10 02:44:45 +03:00
mutate трекает in-flight одним полем inflightReject; наложенный второй вызов
перезаписал бы рехджектор первого -> при disconnect отклонился бы только второй,
первый бы висел до PERSIST_TIMEOUT_MS (20с). В проде безопасно (оба call-site
сериализуют через per-page withPageLock), но это футган на разделяемом примитиве.
Гард в начале mutate (после ready-проверки, до касания inflightReject): наличие
in-flight -> reject нового вызова без порчи состояния первого. Docstring CONCURRENCY.
Последовательные mutate не задеты (localFinish синхронно чистит inflightReject до
резолва). +2 теста: конкурентный второй реджектится, первый цел; последовательные
оба успешны.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collaborator

Ревью (раунд 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, не регистрирует таймер/листенер. Первая операция остаётся нетронутой.

  • Мутационная проба (gm-ci, с ПЕРЕСБОРКОЙ mcp): нейтрализовал гард (if (false && this.inflightReject)) → пересобрал → тест «concurrent mutate: the second rejects, the first is unaffected» падает (17/18). Гард реально держит.
  • Тест проверяет ОБА свойства: (a) наложение отклоняется точным сообщением; (b) первая операция не повреждена — тест ацкает первую после отклонения второй и ассертит, что она резолвится, connectCount==1 (нет реконнекта от отклонённого наложения).
  • Последовательные mutate не задевают гард: localFinish синхронно сбрасывает inflightReject ДО settle промиса, поэтому awaited-последовательность (replaceImage scan+write под одним page-lock) проходит — покрыто вторым новым тестом.
  • Докстринг CONCURRENCY на 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 нет.

<!-- state:review reviewed_head=0d4f719f round=2 verdict=approved --> ## Ревью (раунд 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`, не регистрирует таймер/листенер. Первая операция остаётся нетронутой. - **Мутационная проба (gm-ci, с ПЕРЕСБОРКОЙ mcp)**: нейтрализовал гард (`if (false && this.inflightReject)`) → пересобрал → тест «concurrent mutate: the second rejects, the first is unaffected» **падает** (17/18). Гард реально держит. - **Тест проверяет ОБА свойства**: (a) наложение отклоняется точным сообщением; (b) первая операция не повреждена — тест ацкает первую после отклонения второй и ассертит, что она резолвится, `connectCount==1` (нет реконнекта от отклонённого наложения). - **Последовательные mutate не задевают гард**: `localFinish` синхронно сбрасывает `inflightReject` ДО settle промиса, поэтому awaited-последовательность (replaceImage scan+write под одним page-lock) проходит — покрыто вторым новым тестом. - Докстринг CONCURRENCY на `mutate()` точен и зеркалит уже существующую заметку на `acquireCollabSession` («caller MUST hold the page lock»); оба call-site оборачивают в `withPageLock`. <details> <summary>Контекст раунда 1 (всё подтверждено, без изменений) — клик</summary> Write-путь verbatim-идентичен one-shot-машине; lost-update нет (соединение живое → Yjs апдейты в реальном времени); утечек провайдера/таймеров/листенеров нет (destroy ровно раз на всех путях, teardown чистит-перед-вызовом); реестр консистентен; conventions/documentation/simplification — LGTM. </details> ### ⚠️ К сведению (без изменений с раунда 1, не блокирую) Окно устаревания прав ≤10 мин (кеш переиспользует авторизованное соединение до `MCP_COLLAB_SESSION_MAX_AGE_MS`) — decided-tradeoff, конфигурируемо, per-user изоляция. Если хочешь дефолт ниже — скажи. DO/ESCALATE нет. Правка раунда 1 закрыта корректно и мутационно-подтверждена; гейт зелёный. Approve. <sub>9 аспектов. Гард проверен мной по коду + мутационно (с пересборкой mcp — иначе node --test гоняет старый build); корректность «не трогает ин-флайт» и последовательная безопасность независимо подтверждены stability + testcov. Escalate нет.</sub>
agent_reviewer added review/approved and removed review/needs labels 2026-07-10 02:54:12 +03:00
vvzvlad merged commit 50bb086edf into develop 2026-07-10 04:26:08 +03:00
Sign in to join this conversation.