refactor(mcp): структурные инварианты конкурентности записи — resolve-then-lock в хелперах, UUID-assert в page-lock, guard на no-await-окно, политика multi-replica #449

Closed
opened 2026-07-10 06:52:38 +03:00 by agent_vscode · 0 comments
Collaborator

Зачем

Механика конкурентной записи (write path: withPageLockacquireCollabSessionsession.mutate) написана аккуратно, но её корректность держится на цепочке конвенций, закреплённых комментариями, а не кодом. Новый write-метод, написанный без знания этих правил, компилируется, проходит большинство тестов — и гоняется в проде. История класса: #260 (slugId/UUID split), #152 (cursor yanking), #159 (split-brain title/body), свежий fail-fast guard 0d4f719f.

Инварианты-по-конвенции (as-is)

  1. resolve-then-lock: каждый write-метод обязан сам вызвать resolvePageId(pageId) до withPageLock, чтобы и ключ мьютекса, и ключ кэша CollabSession были каноническим UUID (client.ts:~1853, комменты «#260»). Хелперы mutatePage/replacePage передают pageId насквозь и полностью полагаются на вызывающего. Забыл resolve / передал slugId → лок под другим ключом → тихая потеря сериализации.
  2. no-await-окно: атомарность read→transform→write в session.mutate держится на «NO await between TiptapTransformer.fromYdoc and applyDocToFragment» (INVARIANT 1, collab-session.ts:~298-320) — только комментарий; случайный await тихо переоткрывает клоббер живых правок человека.
  3. hold-lock-before-mutate: acquireCollabSession намеренно не лочит (collab-session.ts:~571); одиночный inflightReject-guard корректен только пока вызывающий держит page-lock.
  4. Мьютекс per-process: module-level Map (page-lock.ts:11) — при multi-replica HTTP-деплое без sticky-sessions две реплики держат «лок» одновременно. Та же история у RAM-only stash_page.
  5. Окно устаревших прав: кэшированная сессия пишет под токеном момента connect до MCP_COLLAB_SESSION_MAX_AGE_MS (10 мин) — отозванный доступ лагает на это окно.

Механика

  1. mutatePage/replacePage (и родственные хелперы) сами вызывают resolvePageId — убрать обязанность вызывающего; withPageLock ассертит UUID-формат ключа (fail-fast на slugId).
  2. no-await-окно: зафиксировать тестом (регресс через фейковый async-хук / линт-правило no-await-in-block по маркер-комментарию) — любой способ, лишь бы нарушение стало красным, а не тихим.
  3. Multi-replica: зафиксировать позицию — либо задокументировать «single instance / sticky-sessions — требование деплоя» (README + Dockerfile-комментарий), либо (по потребности) advisory-lock через Postgres. Сейчас достаточно документа — но осознанного.
  4. Окно прав: задокументировать как trade-off рядом с MCP_COLLAB_SESSION_MAX_AGE_MS в .env.example; опционально — инвалидация кэша сессий при смене прав.

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

  • Write-метод с непере-resolved slugId → немедленный fail-fast, не тихий пропуск сериализации.
  • Введение await в критическое окно mutate → красный тест/линт.
  • Требования к деплою (реплики, sticky) явно записаны.

Связано: #416 (роадмап), #435/#439 (кэш collab-токена), #260, #152, #159; коммиты 0d4f719f, 572f0a2a.

## Зачем Механика конкурентной записи (write path: `withPageLock` → `acquireCollabSession` → `session.mutate`) написана аккуратно, но её корректность держится на **цепочке конвенций, закреплённых комментариями**, а не кодом. Новый write-метод, написанный без знания этих правил, компилируется, проходит большинство тестов — и гоняется в проде. История класса: #260 (slugId/UUID split), #152 (cursor yanking), #159 (split-brain title/body), свежий fail-fast guard 0d4f719f. ## Инварианты-по-конвенции (as-is) 1. **resolve-then-lock**: каждый write-метод обязан сам вызвать `resolvePageId(pageId)` до `withPageLock`, чтобы и ключ мьютекса, и ключ кэша CollabSession были каноническим UUID (`client.ts:~1853`, комменты «#260»). Хелперы `mutatePage`/`replacePage` передают `pageId` насквозь и полностью полагаются на вызывающего. Забыл resolve / передал slugId → лок под другим ключом → **тихая потеря сериализации**. 2. **no-await-окно**: атомарность read→transform→write в `session.mutate` держится на «NO `await` between `TiptapTransformer.fromYdoc` and `applyDocToFragment`» (INVARIANT 1, `collab-session.ts:~298-320`) — только комментарий; случайный `await` тихо переоткрывает клоббер живых правок человека. 3. **hold-lock-before-mutate**: `acquireCollabSession` намеренно не лочит (`collab-session.ts:~571`); одиночный `inflightReject`-guard корректен только пока вызывающий держит page-lock. 4. **Мьютекс per-process**: module-level `Map` (`page-lock.ts:11`) — при multi-replica HTTP-деплое без sticky-sessions две реплики держат «лок» одновременно. Та же история у RAM-only `stash_page`. 5. **Окно устаревших прав**: кэшированная сессия пишет под токеном момента connect до `MCP_COLLAB_SESSION_MAX_AGE_MS` (10 мин) — отозванный доступ лагает на это окно. ## Механика 1. `mutatePage`/`replacePage` (и родственные хелперы) **сами** вызывают `resolvePageId` — убрать обязанность вызывающего; `withPageLock` ассертит UUID-формат ключа (fail-fast на slugId). 2. no-await-окно: зафиксировать тестом (регресс через фейковый async-хук / линт-правило no-await-in-block по маркер-комментарию) — любой способ, лишь бы нарушение стало красным, а не тихим. 3. Multi-replica: **зафиксировать позицию** — либо задокументировать «single instance / sticky-sessions — требование деплоя» (README + Dockerfile-комментарий), либо (по потребности) advisory-lock через Postgres. Сейчас достаточно документа — но осознанного. 4. Окно прав: задокументировать как trade-off рядом с `MCP_COLLAB_SESSION_MAX_AGE_MS` в `.env.example`; опционально — инвалидация кэша сессий при смене прав. ## Критерии приёмки - Write-метод с непере-resolved slugId → немедленный fail-fast, не тихий пропуск сериализации. - Введение `await` в критическое окно `mutate` → красный тест/линт. - Требования к деплою (реплики, sticky) явно записаны. Связано: #416 (роадмап), #435/#439 (кэш collab-токена), #260, #152, #159; коммиты 0d4f719f, 572f0a2a.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#449