perf(mcp): [follow-up #400/#431] кэш CollabSession в проде почти не хитует — свежий collab-токен на каждую мутацию меняет ключ кэша; нужен кэш токена #435

Closed
opened 2026-07-10 04:56:42 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема

Кэш живого collab-соединения (#400, реализация #431, смержено в develop 10.07 ~04:26) в продовых сценариях почти никогда не переиспользует сессию. Ключ реестра включает сам токен (packages/mcp/src/lib/collab-session.ts, sessionKey = wsUrl + " " + pageId + " " + token, инвариант 4 «изоляция identity»), а оба пути получения collab-токена выдают свежий токен на каждый content-мутирующий вызов:

  1. In-app агент (apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts, buildDocmostClient): getCollabToken = () => tokenService.generateCollabToken(user, workspaceId, {actor:'agent', aiChatId}) — каждый вызов подписывает новый JWT. iat/exp в секундах ⇒ строка токена меняется каждую секунду.
  2. Внешний MCP (packages/mcp/src/client.ts, getCollabTokenWithReauth()lib/auth-utils.ts getCollabToken()): POST /auth/collab-token на каждую мутацию, сервер подписывает новый JWT — тот же эффект.

Дизайн #400 предполагал: «Ротация токена (getCollabTokenWithReauth) создаёт новую запись, старая уходит по idle» — т.е. что ротация редкая. По факту «ротация» происходит на каждом вызове, и кэш вырождается в легаси-поведение (provider-per-op), ради устранения которого и делался #400. Юнит-тесты #431 это не ловят, потому что гоняют фиксированный токен — «20 правок → 1 connect» в тестах проходит, в проде нет.

Последствия (наблюдались вживую 10.07 ~04:30, страница со ~356 тредами комментариев)

Пачка createComment от in-app агента (вычитка статьи, ~15–20 инлайн-комментариев):

  • Каждый комментарий = анкоринг через mutatePageContentacquireCollabSession с новым ключом → полный connect + auth + onLoadDocument + начальная синхронизация огромного ydoc — на каждый вызов.
  • Старые сессии не умирают сразу: idle TTL 60с. «Зомби»-сессии остаются подписаны на документ и получают все броадкасты ⇒ при пачке из N комментариев трафик/CPU растут ~квадратично, всё в том же event loop NestJS-процесса (loopback).
  • Наблюдаемое: первые 3–4 комментария пачки проходят за миллисекунды (токены сминчены в одну и ту же секунду ⇒ строки токена совпали ⇒ ключ совпал ⇒ реюз!), дальше токены разъезжаются по секундам — connect-штормы, 25с таймауты, очередь на page-lock растёт, ход агента обрывается, в чате остаются синтетические Tool call did not complete.
  • Побочный эффект: серверная часть вызова доезжает после обрыва хода (Node не отменяет промисы) ⇒ комментарий создаётся, агент видит «ошибку», повторяет ⇒ дубликаты комментариев.

Тот факт, что same-second токены дают реюз, — прямое подтверждение диагноза: сессия и кэш работают, ломается только стабильность ключа.

Решение

Кэшировать collab-токен в клиенте, в getCollabTokenWithReauth() (packages/mcp/src/client.ts) — одно место закрывает оба пути (provider-путь in-app агента и REST-путь внешнего MCP):

// Cached collab token: reuse within TTL so the CollabSession registry key
// (wsUrl + pageId + token) stays stable across a series of mutations and the
// #400 session cache actually hits. TTL is well under the 24h token life and
// no longer than the session max-age (10 min), so the permission-staleness
// window is NOT extended beyond what #431 already accepted.
private collabTokenCache: { token: string; mintedAt: number } | null = null;

private async getCollabTokenWithReauth(forceRefresh = false): Promise<string> {
  const ttl = readCollabTokenTtlMs(); // env MCP_COLLAB_TOKEN_TTL_MS, default ~5 min, 0 = disable (legacy)
  if (!forceRefresh && ttl > 0 && this.collabTokenCache
      && Date.now() - this.collabTokenCache.mintedAt < ttl) {
    return this.collabTokenCache.token;
  }
  // ... existing provider/REST fetch logic ...
  this.collabTokenCache = { token, mintedAt: Date.now() };
  return token;
}

Ключевые требования:

  • Инвалидция на auth-ошибке: существующий retry-путь на 401/403 обязан миновать кэш (forceRefresh) — иначе ретрай вернёт тот же протухший токен и реаут выродится в no-op.
  • TTL по умолчанию ~5 мин (env MCP_COLLAB_TOKEN_TTL_MS, 0 = отключить, легаси): сильно меньше жизни токена (24ч, token.service.ts) и не длиннее session max-age (10 мин) ⇒ окно устаревания прав, осознанно принятое в ревью #431, не расширяется.
  • Изоляция identity сохраняется: кэш — per-instance поле DocmostClient, клиент строится per-user/per-session (in-app — per chat request), между пользователями ничего не шарится, инвариант 4 не трогаем.
  • Кэш-поле должно сбрасываться и при login()/смене this.token (REST-путь), чтобы токен не пережил смену identity клиента.

Альтернатива (рассмотрена, отложена)

Ключевать реестр сессий по декодированному identity (sub + actor + aiChatId) вместо сырой строки токена. Устойчивее к любой ротации, но трогает security-инвариант 4 и семантику реестра; кэш токена — на порядок меньший и локальный фикс. Если когда-нибудь понадобится реюз поверх TTL — вернуться к этому.

Не в скоупе (смежное, наблюдалось в том же инциденте)

  • Каждый createComment делает полный getPageJson pre-check + fromYdoc + 2× deep-clone JSON + diff на огромном документе — CPU-цена на комментарий остаётся; батч-инструмент «N комментариев за одну мутацию» — отдельная тема.
  • Tool call did not complete. + доезжающая после обрыва серверная работа ⇒ дубликаты: идемпотентность/отмена tool-вызовов при аборте хода — отдельная тема (ai-chat).

Критерии приёмки

  • Пачка из 10+ createComment/table_update_cell по одной странице от in-app агента: ровно 1 connect/auth/initial-sync на серию (DEBUG-логи Reusing collab session / метрики #402), никаких зомби-сессий по одной на вызов.
  • unit (client): в пределах TTL повторные getCollabTokenWithReauth возвращают ту же строку (provider-функция/REST вызваны 1 раз); после TTL — новый токен; 401/403 ⇒ форс-рефреш мимо кэша; MCP_COLLAB_TOKEN_TTL_MS=0 ⇒ точное легаси-поведение (фетч на каждый вызов).
  • unit (интеграция с collab-session): две последовательные мутации одной страницы при включённом кэше токена дают один и тот же ключ реестра ⇒ 1 сессия.
  • Существующие тесты mcp зелёные; изоляция identity (ключ включает токен) не изменена.
# Проблема Кэш живого collab-соединения (#400, реализация #431, смержено в develop 10.07 ~04:26) в продовых сценариях **почти никогда не переиспользует сессию**. Ключ реестра включает сам токен (`packages/mcp/src/lib/collab-session.ts`, `sessionKey = wsUrl + " " + pageId + " " + token`, инвариант 4 «изоляция identity»), а **оба** пути получения collab-токена выдают свежий токен на каждый content-мутирующий вызов: 1. **In-app агент** (`apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts`, `buildDocmostClient`): `getCollabToken = () => tokenService.generateCollabToken(user, workspaceId, {actor:'agent', aiChatId})` — каждый вызов подписывает новый JWT. `iat`/`exp` в секундах ⇒ строка токена меняется каждую секунду. 2. **Внешний MCP** (`packages/mcp/src/client.ts`, `getCollabTokenWithReauth()` → `lib/auth-utils.ts getCollabToken()`): `POST /auth/collab-token` на каждую мутацию, сервер подписывает новый JWT — тот же эффект. Дизайн #400 предполагал: «Ротация токена (`getCollabTokenWithReauth`) создаёт новую запись, старая уходит по idle» — т.е. что ротация **редкая**. По факту «ротация» происходит на каждом вызове, и кэш вырождается в легаси-поведение (provider-per-op), ради устранения которого и делался #400. Юнит-тесты #431 это не ловят, потому что гоняют **фиксированный** токен — «20 правок → 1 connect» в тестах проходит, в проде нет. ## Последствия (наблюдались вживую 10.07 ~04:30, страница со ~356 тредами комментариев) Пачка `createComment` от in-app агента (вычитка статьи, ~15–20 инлайн-комментариев): - Каждый комментарий = анкоринг через `mutatePageContent` → `acquireCollabSession` с **новым** ключом → полный connect + auth + onLoadDocument + начальная синхронизация огромного ydoc — на каждый вызов. - Старые сессии не умирают сразу: idle TTL 60с. «Зомби»-сессии остаются подписаны на документ и получают все броадкасты ⇒ при пачке из N комментариев трафик/CPU растут ~квадратично, всё в том же event loop NestJS-процесса (loopback). - Наблюдаемое: первые 3–4 комментария пачки проходят за миллисекунды (токены сминчены в одну и ту же секунду ⇒ строки токена совпали ⇒ ключ совпал ⇒ реюз!), дальше токены разъезжаются по секундам — connect-штормы, 25с таймауты, очередь на page-lock растёт, ход агента обрывается, в чате остаются синтетические `Tool call did not complete.` - Побочный эффект: серверная часть вызова **доезжает после** обрыва хода (Node не отменяет промисы) ⇒ комментарий создаётся, агент видит «ошибку», повторяет ⇒ **дубликаты комментариев**. Тот факт, что same-second токены дают реюз, — прямое подтверждение диагноза: сессия и кэш работают, ломается только стабильность ключа. # Решение Кэшировать collab-токен **в клиенте**, в `getCollabTokenWithReauth()` (`packages/mcp/src/client.ts`) — одно место закрывает оба пути (provider-путь in-app агента и REST-путь внешнего MCP): ```ts // Cached collab token: reuse within TTL so the CollabSession registry key // (wsUrl + pageId + token) stays stable across a series of mutations and the // #400 session cache actually hits. TTL is well under the 24h token life and // no longer than the session max-age (10 min), so the permission-staleness // window is NOT extended beyond what #431 already accepted. private collabTokenCache: { token: string; mintedAt: number } | null = null; private async getCollabTokenWithReauth(forceRefresh = false): Promise<string> { const ttl = readCollabTokenTtlMs(); // env MCP_COLLAB_TOKEN_TTL_MS, default ~5 min, 0 = disable (legacy) if (!forceRefresh && ttl > 0 && this.collabTokenCache && Date.now() - this.collabTokenCache.mintedAt < ttl) { return this.collabTokenCache.token; } // ... existing provider/REST fetch logic ... this.collabTokenCache = { token, mintedAt: Date.now() }; return token; } ``` Ключевые требования: - **Инвалидция на auth-ошибке**: существующий retry-путь на 401/403 обязан миновать кэш (`forceRefresh`) — иначе ретрай вернёт тот же протухший токен и реаут выродится в no-op. - **TTL по умолчанию ~5 мин** (env `MCP_COLLAB_TOKEN_TTL_MS`, `0` = отключить, легаси): сильно меньше жизни токена (24ч, `token.service.ts`) и не длиннее session max-age (10 мин) ⇒ окно устаревания прав, осознанно принятое в ревью #431, **не расширяется**. - **Изоляция identity сохраняется**: кэш — per-instance поле `DocmostClient`, клиент строится per-user/per-session (in-app — per chat request), между пользователями ничего не шарится, инвариант 4 не трогаем. - Кэш-поле должно сбрасываться и при `login()`/смене `this.token` (REST-путь), чтобы токен не пережил смену identity клиента. ## Альтернатива (рассмотрена, отложена) Ключевать реестр сессий по декодированному identity (`sub` + `actor` + `aiChatId`) вместо сырой строки токена. Устойчивее к любой ротации, но трогает security-инвариант 4 и семантику реестра; кэш токена — на порядок меньший и локальный фикс. Если когда-нибудь понадобится реюз поверх TTL — вернуться к этому. ## Не в скоупе (смежное, наблюдалось в том же инциденте) - Каждый `createComment` делает полный `getPageJson` pre-check + `fromYdoc` + 2× deep-clone JSON + diff на огромном документе — CPU-цена на комментарий остаётся; батч-инструмент «N комментариев за одну мутацию» — отдельная тема. - `Tool call did not complete.` + доезжающая после обрыва серверная работа ⇒ дубликаты: идемпотентность/отмена tool-вызовов при аборте хода — отдельная тема (ai-chat). # Критерии приёмки - [ ] Пачка из 10+ `createComment`/`table_update_cell` по одной странице от in-app агента: ровно 1 connect/auth/initial-sync на серию (DEBUG-логи `Reusing collab session` / метрики #402), никаких зомби-сессий по одной на вызов. - [ ] unit (client): в пределах TTL повторные `getCollabTokenWithReauth` возвращают ту же строку (provider-функция/REST вызваны 1 раз); после TTL — новый токен; 401/403 ⇒ форс-рефреш мимо кэша; `MCP_COLLAB_TOKEN_TTL_MS=0` ⇒ точное легаси-поведение (фетч на каждый вызов). - [ ] unit (интеграция с collab-session): две последовательные мутации одной страницы при включённом кэше токена дают один и тот же ключ реестра ⇒ 1 сессия. - [ ] Существующие тесты mcp зелёные; изоляция identity (ключ включает токен) не изменена.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#435