31 KiB
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.py—AsyncNetworkModule[C:5] сAsyncAPIClient[C:3] — уже существует. Полноценный асинхронный клиент наhttpx.AsyncClientсSupersetAuthCache, 401-retry, error translation. Требуется расширение: добавлениеasyncio.Semaphore, connection pool config.backend/src/core/utils/network.py—NetworkModule[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.py—AsyncOpenAI+httpx.AsyncClient— эталон для асинхронных LLM-вызовов.core/async_superset_client.py— существующийAsyncSupersetClientбудет объединён с syncSupersetClient.
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:
- Given настроено окружение Superset, When одновременно вызываются
get_dashboards()(пагинированный),get_dataset_detail()иexport_dashboard(), Then все три вызова выполняются конкурентно, event loop не блокируется. - Given Superset отвечает медленно (5 секунд на дашборд), When одновременно приходит обычный GET-запрос (например,
/health), Then GET-запрос получает ответ мгновенно, не дожидаясь Superset. - 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:
- Given запущен предпросмотр с LLM-вызовом (8-15 секунд), When немедленно отправляется запрос проверки схемы с поллингом SQL Lab (до 15 попыток), Then проверка схемы завершается до окончания предпросмотра.
- Given активный поллинг SQL Lab (
time.sleepзаменён наasyncio.sleep), When одновременно приходит лёгкий GET-запрос, Then он обрабатывается без задержки. - 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:
- Given запущен бэкап 50 дашбордов (каждый экспортируется и сохраняется на диск), When одновременно запускается деплой дашборда из Git, Then деплой не ждёт окончания бэкапа.
- Given запущен LLM-анализ дашборда (загрузка скриншотов, вызов LLM), When одновременно загружается файл в storage, Then загрузка файла не блокируется LLM-вызовом.
- 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:
- Given отправляется Telegram-уведомление (
requests.postзаменён наhttpx.AsyncClient.post), When одновременно приходит запрос на получение списка джоб, Then запрос списка не ждёт отправки уведомления. - 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.Lockper‑environment. Только одна корутина выполняет 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.Lockper‑environment. 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, настраивается через конфигурацию окружения). Семафор — глобальный per‑environment черезSupersetClientRegistry(синглтон), не per‑client. При исчерпании пула запрос ожидает до 30 секунд (настраивается), затем возвращает503 Service Unavailable. - NFR-007: Блокирующие адаптеры (Git, файлы, БД) используют именованные bounded‑executor'ы через
loop.run_in_executor(named_executor, fn):db_executor,file_executor,git_executorсmax_workersи queue timeout (по умолчанию 10 воркеров каждый). Прямое использованиеasyncio.to_thread(default executor) запрещено для production‑путей. Рекомендуется helperrun_blocking(kind, fn, *args, timeout)вcore/utils/executors.py. - NFR-008: На shutdown: прекратить приём новых задач, отменить активные asyncio‑задачи с graceful‑timeout 30 секунд, закрыть все
httpx.AsyncClientчерезSupersetClientRegistry, слить/закрыть EventBus, пометить незавершённые задачи как CANCELLED. - NFR-009: Рекомендуемые runtime‑метрики (post‑MVP):
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— хранит sharedhttpx.AsyncClient+ sharedasyncio.Semaphore+ auth‑lock. Закрывает все клиенты на 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_mock→pytest_httpx, добавлениеpytest.mark.asyncio). - SC-006: Ноль утечек соединений после 500 последовательных запросов к любым эндпоинтам.
- SC-007: Время выполнения предпросмотра перевода не увеличилось более чем на 10% относительно синхронной версии.
- SC-008: Количество «грязных»
async def-ручек, содержащих синхронные блокирующие вызовы, равно нулю (верифицируется статическим анализом).