Skip to content

Unbounded per-channel TokenMapper -> monotonic RSS growth -> OOM; TOKEN_RE \d{5} breaks detokenization past 99999 #12

Description

@DVZak

Кратко

При ANONYMIZATION_ENABLED=true память прокси (onec_mcp_toolkit_proxy) монотонно растёт по аптайму и не возвращается. На больших результатах запросов рост скачкообразный. При жёстком лимите памяти контейнера процесс уходит в OOM, in-memory MCP-сессия теряется → разрыв клиента (Claude Code / Cursor требует Reload Window). Причина — безлимитная карта токенов анонимайзера, которая живёт per-channel всё время процесса и никогда не вытесняет старые записи.

Корневая причина (по коду main)

  1. onec_mcp_toolkit_proxy/anonymizer/registry.pyAnonymizerRegistry._registry: Dict[str, Anonymizer]: один Anonymizer создаётся на канал и живёт всё время процесса, из реестра не удаляется (кроме явного clear(), который автоматически не вызывается).

  2. onec_mcp_toolkit_proxy/anonymizer/token_mapper.pyTokenMapper.tokenize() добавляет записи в _real_to_token / _token_to_real и никогда не вытесняет. clear() есть, но в обычном потоке не зовётся.

Итог: каждое уникальное анонимизированное значение ([ORG-00001], [PER-00002], …) оседает в двух словарях навсегда. Один большой результат execute_query вносит тысячи уникальных значений за раз.

Воспроизведение / замеры

  • Долгий аптайм: RSS рос с ~50 МБ (свежий старт) до ~3.2 ГБ за 12 дней, выбирая ОЗУ + swap небольшого VPS.
  • Точечный замер: один execute_query с большим массивом поднял RSS прокси с 47 → 425 МБ и память не вернулась (через 2 минуты простоя всё ещё 425 МБ; на холостом long-poll медленно росла дальше).
  • При лимите контейнера 768 МБ — подтверждённый dmesg: Memory cgroup out of memory: Killed process (python) anon-rss ~780000 kB.
  • Часть всплеска — ленивая загрузка модели spaCy ru_core_news_md (резидентный «пол», неустранимо при анонимизации). Но безграничный рост даёт именно карта токенов.

Второй, отдельный баг — формат токена

token_mapper.py:

TOKEN_RE = re.compile(r"\[([A-Z]+)-(\d{5})\]", re.IGNORECASE)
...
token = f"[{category}-{count:05d}]"

count монотонный и не сбрасывается. После 99999 уникальных значений в категории tokenize() выдаёт [ORG-100000] (6 цифр), но TOKEN_RE матчит ровно \d{5}detokenize() / detokenize_for_query() такой токен не находят и оставляют как есть. Деанонимизация молча ломается. Достаточно сменить шаблон на \d{5,}.

Предлагаемый фикс

  1. Ограничить карту токенов. Любой из вариантов:

    • порог размера с вытеснением (LRU / FIFO), env-настраиваемый (напр. ANONYMIZATION_TOKENMAP_MAX);
    • TTL на записи (с очисткой после деанонимизации / по таймауту);
    • вызывать TokenMapper.clear() по жизненному циклу запроса/сессии, где это безопасно для деанонимизации.

    Счётчики категорий при вытеснении сбрасывать нельзя (иначе коллизии номеров среди выживших токенов).

  2. Регулярка: \d{5}\d{5,}.

Временный обход (на нашей стороне)

Overlay поверх :latest: в tokenize() добавлено FIFO-вытеснение при превышении ANONYMIZATION_TOKENMAP_MAX (default 50000) + регулярка \d{5,}. Проверено: 300 000 уникальных вставок → карта остаётся 50 000, RSS +~17 МБ (не растёт); 5- и 6-значные токены деанонимизируются. Готовы прислать PR, если интересно.

Окружение

  • образ roctup/1c-mcp-toolkit-proxy:latest, режим прокси (long-poll), ANONYMIZATION_ENABLED=true
  • spaCy 3.8.0 + ru_core_news_md, toon-format 0.9.0b1

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions