Files
ss-tools/specs/032-translate-requests-httpx/spec.md
2026-06-04 19:41:08 +03:00

31 KiB
Raw Blame History

Feature Specification: Async Backend Refactoring

Feature Branch: 032-translate-requests-httpx Created: 2026-06-04 Status: Draft Input: Полный рефакторинг всех модулей backend на асинхронный неблокирующий ввод-вывод: замена синхронного requests на httpx.AsyncClient, асинхронное взаимодействие с БД, отказ от time.sleep в пользу asyncio.sleep, полная ликвидация блокировки event loop'а FastAPI при любых внешних вызовах.

Problem Statement

При одном воркере uvicorn любая синхронная блокирующая операция (HTTP-вызов к Superset, LLM, SMTP, Telegram, Slack; файловые операции shutil; time.sleep в циклах поллинга; Git-клонирование) останавливает все входящие запросы. Это делает систему неюзабельной при активной работе даже одного оператора и полностью непригодной для командного использования.

Scope: ~15 файлов, ~45 блокирующих вызовов

Корневая причина: APIClient (core/utils/network.py, 542 строки) — центральный HTTP-клиент на requests.Session. Через него проходит весь трафик ss-tools к Superset. Все 13 методов-миксинов SupersetClient используют его синхронно. Любой запрос к Superset (получение дашбордов, экспорт, импорт, SQL Lab, chart data, скриншоты) блокирует event loop.

Migration Strategy

Выбрано: Big-bang (Option A). Все синхронные вызовы requests, time.sleep, синхронный SupersetClient заменяются в одном PR. Синхронный код не сохраняется для обратной совместимости — асинхронный клиент становится единственным. AsyncSupersetClient и sync SupersetClient сливаются в один асинхронный клиент.

Модули, требующие рефакторинга

Priority Module Что блокирует
P1 core/utils/network.py — APIClient Центральный хаб: requests.Session, все методы синхронные
P1 core/superset_client/ — SupersetClient + миксины 13 миксинов × синхронные методы, зависят от APIClient
P2 plugins/translate/ — LLM HTTP, SQL Lab, preview requests.post к LLM, client.network.request к Superset, time.sleep в поллинге
P2 api/routes/dashboards/ — thumbnail, detail Создаёт синхронный SupersetClient прямо в async def ручке
P3 plugins/backup.py — BackupPlugin Экспорт дашбордов, файловый I/O — внутри async def execute()
P3 plugins/git_plugin.py — GitPlugin shutil.*, SupersetClient, Git-клонирование — внутри async def
P3 plugins/llm_analysis/plugin.py — dashboard logs client.network.request внутри async def _fetch_dashboard_logs()
P3 core/utils/fileio.py — файловые операции shutil.rmtree, read_bytes, open() — вызываются из плагинов
P3 core/utils/superset_compilation_adapter.py client.network.request для компиляции SQL — вызывается из dataset_review
P4 services/notifications/providers.py — SMTP, Telegram, Slack smtplib.SMTP, requests.post — внутри async def send()
P4 services/git/_base.py — Git-операции shutil, GitPython-клонирование
P4 plugins/storage/plugin.py — файлы shutil.copyfileobj() в async def save_file()
P4 api/routes/ — прочие ручки Остаточные синхронные вызовы
P4 core/task_manager/ — Task Manager ThreadPoolExecutor заменяется на asyncio.create_task; WebSocket-логирование и lifecycle — асинхронные

Эталонные паттерны и существующая инфраструктура

  • backend/src/core/utils/async_network.pyAsyncNetworkModule [C:5] с AsyncAPIClient [C:3] — уже существует. Полноценный асинхронный клиент на httpx.AsyncClient с SupersetAuthCache, 401-retry, error translation. Требуется расширение: добавление asyncio.Semaphore, connection pool config.
  • backend/src/core/utils/network.pyNetworkModule [C:3] с APIClient [C:3] + SupersetAuthCache — синхронный клиент, будет заменён (Tombstone). SupersetAuthCache — общий для sync/async, сохраняется.
  • plugins/llm_analysis/service.py_call_sync() + asyncio.to_thread() — эталонный паттерн для обёртки sync-вызовов (БД, файловые операции).
  • plugins/llm_analysis/service.pyAsyncOpenAI + httpx.AsyncClient — эталон для асинхронных LLM-вызовов.
  • core/async_superset_client.py — существующий AsyncSupersetClient будет объединён с sync SupersetClient.

Clarifications

Session 2026-06-04

  • Q: Migration strategy — dual-stack staged vs big-bang vs wrapper-only vs full async SQLAlchemy? → A: Option A — Big-bang: заменить все синхронные вызовы в одном PR. Sync-код удаляется, async становится единственным путём.
  • Q: Test mock library for httpx — pytest-httpx vs respx vs MockTransport vs keep requests_mock? → A: Option A — pytest-httpx. Стандартный мок для httpx, минимум изменений в assertions, нативная интеграция с pytest.
  • Q: Connection pool & concurrency limits — no limits vs semaphore per env vs semaphore per operation type? → A: Option B — asyncio.Semaphore на каждое окружение Superset (по умолчанию 20). Все операции конкурируют за один пул, first-come-first-served.
  • Q: Semaphore exhaustion behavior — instant fail vs wait with timeout vs unlimited queue? → A: Option B — ожидание с таймаутом 30 секунд (настраивается). При превышении таймаута → 503 Service Unavailable.
  • Q: Background tasks & ThreadPoolExecutor — per-task event loop vs full Task Manager async vs hybrid? → A: Option B — Task Manager полностью переходит на asyncio: asyncio.create_task вместо ThreadPoolExecutor для запуска задач.

User Scenarios & Testing

User Story 1 — Неблокирующее ядро: Superset API (Priority: P1)

Фундамент всего рефакторинга. Любое взаимодействие с Superset API — получение списка дашбордов, экспорт/импорт, SQL Lab execute, chart data, скриншоты, информация о датасетах — не блокирует обработку других HTTP-запросов. APIClient заменяется на асинхронный транспорт, SupersetClient становится полностью асинхронным.

Why this priority: Все плагины (translate, backup, git, llm_analysis, migration) зависят от SupersetClient. Без его асинхронности невозможен рефакторинг ни одного плагина.

Independent Test: Отправить три параллельных запроса к разным эндпоинтам Superset API через систему. Замерить общее время — должно быть ≈ max(time1, time2, time3), а не sum.

Acceptance Scenarios:

  1. Given настроено окружение Superset, When одновременно вызываются get_dashboards() (пагинированный), get_dataset_detail() и export_dashboard(), Then все три вызова выполняются конкурентно, event loop не блокируется.
  2. Given Superset отвечает медленно (5 секунд на дашборд), When одновременно приходит обычный GET-запрос (например, /health), Then GET-запрос получает ответ мгновенно, не дожидаясь Superset.
  3. Given асинхронный SupersetClient, When происходит сетевая ошибка (connection refused), Then исключение всплывает только в запросе-инициаторе, не затрагивая другие конкурентные запросы.

User Story 2 — Параллельный Translate: preview + schema check (Priority: P2)

Оператор настраивает переводческую задачу. Нажимает «Предпросмотр» (preview) на вкладке Preview — система загружает данные из Superset, отправляет в LLM. Не дожидаясь, переключается на Target Config и нажимает «Проверить схему» — система немедленно обрабатывает запрос, возвращает результат проверки схемы, не дожидаясь окончания предпросмотра.

Why this priority: Прямая жалоба пользователя. Этот сценарий стал триггером всего рефакторинга.

Independent Test: POST /translate/jobs/A/preview (sample_size=10) и одновременно POST /translate/check-target-schema. Проверка схемы возвращает ответ до завершения предпросмотра.

Acceptance Scenarios:

  1. Given запущен предпросмотр с LLM-вызовом (8-15 секунд), When немедленно отправляется запрос проверки схемы с поллингом SQL Lab (до 15 попыток), Then проверка схемы завершается до окончания предпросмотра.
  2. Given активный поллинг SQL Lab (time.sleep заменён на asyncio.sleep), When одновременно приходит лёгкий GET-запрос, Then он обрабатывается без задержки.
  3. Given выполняются три параллельных LLM-вызова (разные джобы, разные операторы), When один из них получает rate-limit (429) и уходит в backoff, Then два других продолжают выполняться без задержки.

User Story 3 — Все плагины: backup, git, llm_analysis, storage (Priority: P3)

Резервное копирование дашбордов (backup), деплой из Git, LLM-анализ дашбордов, загрузка файлов в storage — все эти операции выполняются асинхронно, не блокируя другие запросы.

Why this priority: После решения P1 (SupersetClient) и P2 (translate) — остальные плагины становятся «низко висящими фруктами». Их рефакторинг сводится к замене синхронных клиентов на асинхронные.

Independent Test: Запустить backup всех дашбордов (50+) и одновременно выполнить deploy из Git. Обе операции выполняются конкурентно.

Acceptance Scenarios:

  1. Given запущен бэкап 50 дашбордов (каждый экспортируется и сохраняется на диск), When одновременно запускается деплой дашборда из Git, Then деплой не ждёт окончания бэкапа.
  2. Given запущен LLM-анализ дашборда (загрузка скриншотов, вызов LLM), When одновременно загружается файл в storage, Then загрузка файла не блокируется LLM-вызовом.
  3. Given Git-операция клонирования репозитория (5-10 секунд), When одновременно пользователь открывает список дашбордов, Then список загружается без задержки.

User Story 4 — Уведомления и фоновая отправка (Priority: P4)

Отправка уведомлений (SMTP, Telegram, Slack) по завершении задач, scheduled reports, alert'ы — все асинхронны и не блокируют основные запросы.

Why this priority: Уведомления — фоновая операция. Их блокировка не критична для UX, но smtplib.SMTP и requests.post в async def send() — нарушение контракта.

Independent Test: Отправить SMTP-уведомление (медленный SMTP-сервер, 3 секунды на подключение) одновременно с обычным API-запросом. API-запрос не задерживается.

Acceptance Scenarios:

  1. Given отправляется Telegram-уведомление (requests.post заменён на httpx.AsyncClient.post), When одновременно приходит запрос на получение списка джоб, Then запрос списка не ждёт отправки уведомления.
  2. Given SMTP-сервер недоступен (connection timeout 10 секунд), When отправляется email-уведомление, Then таймаут не блокирует другие запросы, ошибка логируется.

Edge Cases

  • Отмена запроса клиентом (disconnect): При разрыве соединения все связанные внешние вызовы (HTTP, поллинг, файловые операции) должны быть отменены. Никаких «висящих» соединений и утечек.
  • Таймауты внешних сервисов: Каждый внешний вызов имеет настраиваемый таймаут. Превышение таймаута — исключение только для запроса-инициатора.
  • Сетевые ошибки: DNS failure, connection refused, TLS handshake failure — корректная обработка без влияния на другие запросы.
  • CPU-bound парсинг: JSON-парсинг больших ответов LLM, валидация данных — выполняются через run_blocking(kind='cpu', fn=...).
  • Файловые операции с большими файлами: Экспорт дашбордов (ZIP до 50MB), сохранение на диск — через aiofiles или run_blocking(kind='file', fn=...).
  • Git-клонирование: Длительная операция (до 30 секунд) — выполняется в thread pool, не блокирует event loop.
  • Гонка состояний (race conditions): При конкурентных запросах к одному ресурсу (например, одновременный deploy и backup одного дашборда) система должна обеспечивать консистентность данных.
  • Исчерпание пула соединений к Superset: Когда все 20 слотов семафора заняты, новые запросы ожидают до 30 секунд. При превышении таймаута — ошибка 503 Service Unavailable с сообщением «Superset connection pool exhausted». Другие API (не использующие Superset) продолжают работать.
  • Конкурентная аутентификация: Несколько корутин могут одновременно обнаружить истёкший CSRFтокен. Обновление токена защищено asyncio.Lock perenvironment. Только одна корутина выполняет refresh, остальные ждут и переиспользуют результат.
  • Graceful shutdown: При остановке сервера долгоживущие клиенты (httpx.AsyncClient), семафоры, очереди EventBus и активные задачи должны быть корректно закрыты в течение 30 секунд.
  • Зависший поток в run_blocking: Если операция в run_blocking зависает (например, Gitклонирование не отвечает), общий таймаут через asyncio.wait_for прерывает ожидание, но поток продолжает работу в фоне. Слот executor'а освобождается после возврата функции. Система логирует превышение таймаута и продолжает работу.
  • Таймаут поллинга SQL Lab: Вся операция execute_and_poll (включая все попытки поллинга) должна иметь общий таймаут (настраиваемый, по умолчанию 120 секунд), чтобы не висеть бесконечно при зависшем SQLзапросе на стороне Superset.

Requirements

Functional Requirements

Core Infrastructure (P1):

  • FR-001: APIClient (базовый HTTP-клиент) должен быть переведён на асинхронный транспорт (httpx.AsyncClient). Все методы (request, authenticate, upload_file, fetch_paginated_*) становятся асинхронными.
  • FR-002: SupersetClient и все 13 миксинов переводятся на асинхронный APIClient. Синхронный SupersetClient и AsyncSupersetClient объединяются в единый асинхронный клиент. Синхронный код удаляется полностью (big-bang).
  • FR-003: Все вызовы SupersetClient из async def ручек должны перейти на асинхронный клиент. Создание синхронного SupersetClient(env) внутри async def запрещено.

Translate Plugin (P2):

  • FR-004: LLM-вызовы (_llm_http.py, preview_llm_client.py) должны использовать httpx.AsyncClient вместо requests. Rate-limit backoff — asyncio.sleep вместо time.sleep.
  • FR-005: SQL Lab executor (superset_executor.py) — поллинг с asyncio.sleep вместо time.sleep. Execute и poll — асинхронные методы.
  • FR-006: Загрузка данных из Superset (chart data, source rows) — через асинхронный клиент.
  • FR-007: Preview и schema check должны выполняться конкурентно без взаимной блокировки.

All Plugins (P3):

  • FR-008: BackupPlugin — экспорт дашбордов и файловый I/O через асинхронный клиент + run_blocking(kind='file', fn=...) / aiofiles.
  • FR-009: GitPlugin — shutil операции и Git-клонирование через run_blocking(kind='file'/'git', fn=...).
  • FR-011: Storage Plugin — shutil.copyfileobj() заменён на run_blocking(kind='file', fn=...) или aiofiles.
  • FR-015: Git-сервис (services/git/_base.py) — файловые операции через run_blocking(kind='file'/'git', fn=...).

Cross-cutting:

  • FR-016: Все внешние HTTP-вызовы должны иметь настраиваемый таймаут.
  • FR-017: Отмена клиентского запроса cascade-отменяет связанные внешние вызовы: awaitable HTTP/polling — немедленная отмена (httpx + CancelledError); операции в run_blocking (Git, файлы, БД) помечаются cancelled, завершаются до ближайшего checkpoint и освобождают ресурсы после возврата. Принудительная остановка потока не гарантируется.
  • FR-018: Существующие success-коды (200, 201) и форматы ответов всех эндпоинтов остаются неизменными. Допускается добавление новых failure-кодов для ранее не обрабатывавшихся режимов: 503 (pool exhaustion), 504 (upstream timeout).
  • FR-019: Лёгкие запросы (GET /jobs, GET /dashboards) не должны деградировать по времени ответа после рефакторинга.
  • FR-020: CoT-логирование (REASON/REFLECT/EXPLORE) сохраняется на всех асинхронных путях.
  • FR-021: Все операции с БД, вызываемые из асинхронного контекста, должны быть обёрнуты в run_blocking(kind='db', fn=...). SQLAlchemyсессии не пересекают границу потоков (FR-025).

Task Manager (P4):

  • FR-022: ThreadPoolExecutor для запуска задач заменяется на asyncio.create_task. Задачи выполняются в основном event loop'е.
  • FR-023: WebSocket-логирование задач переводится на асинхронную отправку (без блокирующего I/O). EventBus использует asyncio.Queue(maxsize=10000, настраивается); при переполнении новые события отбрасываются с предупреждением в лог (не блокируют задачу). При task.cancel() публикация логов для task_id прекращается, формируется финальное событие {"type":"cancelled"}. Уже поставленные в очередь события могут быть доставлены.
  • FR-024: Lifecycle задач (запуск, отмена, статус) остаётся обратно совместимым — контракты TaskManager не меняются для потребителей.

Safety & Contracts:

  • FR-025: SQLAlchemyсессии создаются, используются, коммитятся/откатываются и закрываются внутри одного workerпотока. Никакие ORMобъекты не пересекают границу потока без detach/serialization. DBадаптер владеет жизненным циклом сессии.
  • FR-026: Обновление CSRF/authтокенов защищено asyncio.Lock perenvironment. Retry после 401 разрешён только для идемпотентных методов (GET). Для неидемпотентных (POST import, PUT) — ошибка всплывает вызывающему без автоматического retry.
  • FR-027: Graceful shutdown: прекратить приём новых задач, отменить активные asyncioзадачи, закрыть все httpx.AsyncClient через SupersetClientRegistry, слить EventBus, пометить незавершённые задачи как CANCELLED в течение 30 секунд (настраивается).
  • FR-028: Планировщик задач (APScheduler) должен быть переведён на AsyncIOScheduler, либо все вызовы TaskManager.run_task() из scheduled jobs должны выполняться через asyncio.run_coroutine_threadsafe() с ожиданием результата.

Non-Functional Requirements

  • NFR-001: При одном воркере uvicorn 3 параллельных «тяжёлых» запроса должны выполняться конкурентно (пересекающиеся временные интервалы), а не последовательно.
  • NFR-002: CPU-bound операции (JSON-парсинг > 1MB, валидация, хеширование) выполняются через run_blocking(kind='cpu', fn=...).
  • NFR-003: Утечки соединений (незакрытые HTTP-клиенты, файловые дескрипторы) после 500 последовательных запросов к любым эндпоинтам равны нулю.
  • NFR-004: Время выполнения отдельной операции в отсутствие конкуренции (baseline) не увеличивается более чем на 10% относительно синхронной версии, замеренной до рефакторинга.
  • NFR-005: Все новые зависимости (httpx, aiofiles, aiosmtplib) должны быть добавлены в requirements.txt.
  • NFR-006: Количество одновременных соединений к одному окружению Superset ограничивается asyncio.Semaphore (по умолчанию 20, настраивается через конфигурацию окружения). Семафор — глобальный perenvironment через SupersetClientRegistry (синглтон), не perclient. При исчерпании пула запрос ожидает до 30 секунд (настраивается), затем возвращает 503 Service Unavailable.
  • NFR-007: Блокирующие адаптеры (Git, файлы, БД) используют именованные boundedexecutor'ы через loop.run_in_executor(named_executor, fn): db_executor, file_executor, git_executor с max_workers и queue timeout (по умолчанию 10 воркеров каждый). Прямое использование asyncio.to_thread (default executor) запрещено для productionпутей. Рекомендуется helper run_blocking(kind, fn, *args, timeout) в core/utils/executors.py.
  • NFR-008: На shutdown: прекратить приём новых задач, отменить активные asyncioзадачи с gracefultimeout 30 секунд, закрыть все httpx.AsyncClient через SupersetClientRegistry, слить/закрыть EventBus, пометить незавершённые задачи как CANCELLED.
  • NFR-009: Рекомендуемые runtimeметрики (postMVP): event_loop_lag_ms, superset_pool_in_use{env}, superset_pool_wait_ms{env}, httpx_connections_open, task_cancel_total, threadpool_queue_depth.

Key Entities

  • APIClient: Центральный HTTP-клиент — из синхронного requests.Session становится асинхронным httpx.AsyncClient. Точка входа для всех внешних HTTP-вызовов.
  • SupersetClient: Фасад над APIClient с доменными методами (get_dashboards, export_dashboard, get_dataset_detail, etc.) — все методы становятся асинхронными.
  • SQL Lab Executor: Сервис отправки SQL и поллинга результатов — поллинг становится неблокирующим.
  • LLM API Client: Клиент для OpenAI-совместимых API — транспорт меняется на httpx.AsyncClient.
  • Notification Providers: SMTP, Telegram, Slack — каждый провайдер становится действительно асинхронным.
  • Plugin Execute Contract: Все плагины реализуют async def execute() — теперь без синхронных блокирующих вызовов внутри.
  • Task Manager: Менеджер фоновых задач — переходит с ThreadPoolExecutor на asyncio.create_task. Задачи разделяют основной event loop.
  • SupersetClientRegistry: Синглтон-реестр долгоживущих клиентов по env_id — хранит shared httpx.AsyncClient + shared asyncio.Semaphore + authlock. Закрывает все клиенты на shutdown.

Success Criteria

Measurable Outcomes

  • SC-001: Проверка схемы (/check-target-schema), отправленная одновременно с предпросмотром (/preview), завершается до окончания предпросмотра — результат доступен пользователю параллельно, а не последовательно.
  • SC-002: Три параллельных «тяжёлых» запроса (preview + backup + schema check) при одном воркере uvicorn завершаются за время ≈ max(t1, t2, t3), а не sum.
  • SC-003: Легковесный GET-запрос (/health, /translate/jobs) во время активного поллинга SQL Lab (до 30 секунд) получает ответ за < 100ms.
  • SC-004: Экспорт 50 дашбордов (backup) и деплой дашборда (git) выполняются конкурентно без взаимной блокировки.
  • SC-005: Существующие поведенческие assertions в тестах backend остаются эквивалентными (логика проверок не меняется). Тестовые фикстуры и asyncмеханика могут измениться (замена requests_mockpytest_httpx, добавление pytest.mark.asyncio).
  • SC-006: Ноль утечек соединений после 500 последовательных запросов к любым эндпоинтам.
  • SC-007: Время выполнения предпросмотра перевода не увеличилось более чем на 10% относительно синхронной версии.
  • SC-008: Количество «грязных» async def-ручек, содержащих синхронные блокирующие вызовы, равно нулю (верифицируется статическим анализом).