surreal-db: бэкпорт 5 concurrency-фиксов + откат deserialize к keyof T & string #1

Merged
vezdexod merged 5 commits from fix/backport-surreal-concurrency into reactor 2026-07-15 02:03:10 +00:00
Owner

Суть

reactor был на 1 коммит позади main (не содержал канонного зомби-WS фикса
0e5467d) — сначала fast-forward до main, затем поверх него бэкпорт находок
кросс-модельного ревью с rasko/desktop (#105), где этот же канон уже стоит и
был доработан.

Что сделано

P1 — гонки:

  • auth/rootDb.tsgetRootDB возвращал null под конкуренцией: параллельный
    catch обнулял глобальный cached уже ПОСЛЕ чужого успешного re-signin, и
    return cached отдавал null вызывающему. Фикс: локальный захват conn,
    глобаль обнуляется только if (cached === conn).
  • auth/rootDb.ts — нет single-flight на реконнект: при инциденте БД N
    параллельных connectRoot (rate_limit/lockout дёргают getRootDB на каждый
    вход), выживает последний, остальные висят открытыми. Фикс: pendingReconnect,
    симметрично getDBInstance.
  • db/surreal/getAuthedDBSession.ts — таймаут cached-authenticate() трактовался
    как обычный auth-fail → newSession+authenticate на том же дохлом
    db-инстансе, три 15с-таймаута подряд = ~45с. Фикс: SurrealRpcTimeoutError
    пробрасывается сразу, ретрай новой сессией — только на честный auth-fail.

P2 — утечки:

  • db/surreal/timeout.tsPromise.race без clearTimeout: проигравший
    setTimeout висел все 15с. Фикс: clearTimeout в .finally.
  • auth/rootDb.ts + db/surreal/getDBInstance.ts — недобитый инстанс при
    таймауте connect/signin/use не закрывался — поздний живой WS повисал
    бесхозным. Фикс: void instance.close().catch(() => {}) в catch.

Плюс — deserialize:

  • Overload K extends string был unsound (рантайм даёт RecordId, тип —
    string) и не используется ни одним реальным caller'ом здесь (все 4 вызова —
    top-level ключи: 'id', 'userId'). Откат к keyof T & string восстанавливает
    typo-защиту на тайпчеке для всех реальных вызовов; дот-пути — через явный
    as any, если когда-нибудь понадобятся (тип всё равно их не разворачивает,
    см. комментарий в файле).

Проверка

bun run typecheck — ни одной новой ошибки в затронутых 5 файлах (весь прочий
шум typecheck — предсуществующий, не от этого PR: routeTree.gen не
сгенерирован и т.п.).

Тестов на эти файлы в srcTemplate нет (в отличие от briefer/rasko, где то же
самое покрыто и зелено).

## Суть `reactor` был на 1 коммит позади `main` (не содержал канонного зомби-WS фикса `0e5467d`) — сначала fast-forward до `main`, затем поверх него бэкпорт находок кросс-модельного ревью с `rasko/desktop` (#105), где этот же канон уже стоит и был доработан. ## Что сделано **P1 — гонки:** - `auth/rootDb.ts` — `getRootDB` возвращал `null` под конкуренцией: параллельный `catch` обнулял глобальный `cached` уже ПОСЛЕ чужого успешного re-signin, и `return cached` отдавал `null` вызывающему. Фикс: локальный захват `conn`, глобаль обнуляется только `if (cached === conn)`. - `auth/rootDb.ts` — нет single-flight на реконнект: при инциденте БД N параллельных `connectRoot` (rate_limit/lockout дёргают `getRootDB` на каждый вход), выживает последний, остальные висят открытыми. Фикс: `pendingReconnect`, симметрично `getDBInstance`. - `db/surreal/getAuthedDBSession.ts` — таймаут cached-`authenticate()` трактовался как обычный auth-fail → `newSession`+`authenticate` на том же дохлом db-инстансе, три 15с-таймаута подряд = ~45с. Фикс: `SurrealRpcTimeoutError` пробрасывается сразу, ретрай новой сессией — только на честный auth-fail. **P2 — утечки:** - `db/surreal/timeout.ts` — `Promise.race` без `clearTimeout`: проигравший `setTimeout` висел все 15с. Фикс: `clearTimeout` в `.finally`. - `auth/rootDb.ts` + `db/surreal/getDBInstance.ts` — недобитый инстанс при таймауте `connect`/`signin`/`use` не закрывался — поздний живой WS повисал бесхозным. Фикс: `void instance.close().catch(() => {})` в `catch`. **Плюс — deserialize:** - Overload `K extends string` был unsound (рантайм даёт `RecordId`, тип — `string`) и не используется ни одним реальным caller'ом здесь (все 4 вызова — top-level ключи: `'id'`, `'userId'`). Откат к `keyof T & string` восстанавливает typo-защиту на тайпчеке для всех реальных вызовов; дот-пути — через явный `as any`, если когда-нибудь понадобятся (тип всё равно их не разворачивает, см. комментарий в файле). ## Проверка `bun run typecheck` — ни одной новой ошибки в затронутых 5 файлах (весь прочий шум typecheck — предсуществующий, не от этого PR: `routeTree.gen` не сгенерирован и т.п.). Тестов на эти файлы в srcTemplate нет (в отличие от briefer/rasko, где то же самое покрыто и зелено).
Кросс-модельное ревью на rasko/desktop (#105) поймало 3 P1-гонки и 2 P2-утечки
в этом же каноне (унаследован briefer/rasko вендорингом), плюс unsound-overload
у deserialize. Бэкпортируем находки сюда, в источник канона:

- auth/rootDb.ts: getRootDB возвращал null под конкуренцией — параллельный catch
  обнулял cached ПОСЛЕ чужого успешного re-signin. Фикс: локальный захват `conn`,
  глобаль обнуляется только если всё ещё указывает на него.
- auth/rootDb.ts: нет single-flight на реконнект — N параллельных connectRoot при
  инциденте БД (rate_limit/lockout дёргают getRootDB на каждый вход), выживает
  последний. Фикс: pendingReconnect, симметрично getDBInstance.
- auth/rootDb.ts + db/surreal/getDBInstance.ts: недобитый инстанс при таймауте
  connect/signin/use не закрывался — поздний живой WS повисал бесхозным. Фикс:
  close() в catch, fire-and-forget.
- db/surreal/getAuthedDBSession.ts: таймаут cached-authenticate трактовался как
  обычный auth-fail → newSession+authenticate на том же дохлом инстансе, три
  15с-таймаута подряд = ~45с. Фикс: SurrealRpcTimeoutError пробрасывается сразу,
  ретрай новой сессией — только на честный auth-fail.
- db/surreal/timeout.ts: Promise.race без clearTimeout — проигравший setTimeout
  висел все 15с. Фикс: clearTimeout в .finally.
- db/surreal/deserialize.ts: overload на K extends string был unsound (рантайм
  даёт RecordId, тип — string) и не используется ни одним реальным caller'ом
  (все top-level ключи) — ни здесь, ни в briefer/rasko. Откат к keyof T & string
  восстанавливает typo-защиту на тайпчеке; дот-пути — через `as any`, если
  когда-нибудь понадобятся.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ревью первого коммита (fallback /code-review, codex недоступен) нашло 4 доп.
пробела того же класса, что и исходный бэкпорт:

- db/surreal/getDBInstance.ts: не было single-flight на реконнект (тот же
  connection storm, что чинили в rootDb.ts) и умирающий инстанс при проваленном
  health-check не закрывался. Взята полная структура single-flight+capture из
  rasko/desktop (connectFresh + pending-промис + close(dying) перед реконнектом).
- db/surreal/getDBInstance.ts: codecOptions терялся — передавался в конструктор
  Surreal только вместе с debug-веткой, из-за чего valueDecodeVisitor для
  DateTime (используется в getDBSession.ts) тихо не применялся вне debug-режима.
- db/surreal/getAuthedDBSession.ts: конкурентные первые вызовы для одного userId
  (холодный кеш) не координировались — каждый делал свой newSession+authenticate,
  sessions.set перезаписывался, все сессии кроме последней аутентифицированной
  утекали без ссылок. Фикс: in-flight coalescing по userId, присоединившийся
  дожимает свой токен отдельным authenticate.
- auth/resolveSessionUser.ts, auth/syncSession.ts (refreshSession): RPC на
  auth-пути (newSession/authenticate/query/invalidate) не были обёрнуты в
  withTimeout — тот же класс зомби-сокета, что чинили везде остальные, но эти
  два файла остались непокрыты (самый горячий путь — на каждый аутентифицированный
  запрос).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Второй раунд ревью (fallback /code-review, codex по-прежнему недоступен) нашёл:
- creating не чистился при смене db-инстанса — джойнер мог дождаться in-flight
  создания на уже закрытом соединении и словить полный 15с-таймаут вместо
  быстрого создания на новом инстансе.
- честный (не-таймаут) auth-fail на cache-hit пути не инвалидировал сессию на
  сервере — утечка одной осиротевшей аутентифицированной сессии на каждый
  протухший/битый токен.

Заодно закрыл гонку в finally coalescing-блока: слепой creating.delete(userId)
после реконнекта мог снести уже НОВУЮ in-flight запись для того же userId —
теперь удаляется, только если это всё ещё своя запись (тот же паттерн, что
в rootDb.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Третий раунд ревью (fallback /code-review, codex по-прежнему недоступен):

- getAuthedDBSession.invalidate() на честном auth-fail (добавлен предыдущим
  коммитом) был единственным непрокрытым withTimeout RPC-вызовом в файле —
  зомби-сокет в этот момент вешал бы запрос навсегда, ровно то, что весь этот
  PR должен убирать. Обернул.
- create/join-пути кэшировали сессию (sessions.set) безусловно: если реконнект
  происходил, пока create()/join ждали newSession+authenticate, db этого вызова
  уже не совпадал с новым currentDb, но сессия всё равно уходила в кеш —
  следующий вызов ловил её на закрытом соединении (полный 15с-таймаут вместо
  мгновенного создания на новом инстансе). Кэшируем, только если currentDb не
  сменился; сессию всё равно возвращаем текущему вызову (authenticate прошёл,
  разово безопасно).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
vezdexod merged commit 777c0ca701 into reactor 2026-07-15 02:03:10 +00:00
vezdexod deleted branch fix/backport-surreal-concurrency 2026-07-15 02:03:11 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
vezdexod/srcTemplate!1
No description provided.