Files
ss-tools/specs/032-translate-requests-httpx/spec.md
busya ec6421de35 rename ss-tools to superset-tools across the entire project
- Replace all occurrences of 'ss-tools' with 'superset-tools' in 104 files
- Rename git bundle file ss-tools.bundle → superset-tools.bundle
- Update .gitignore pattern accordingly
- Preserve variable names (hasSsTools etc.) and code identifiers
2026-06-16 11:15:19 +03:00

212 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Feature Specification: Async Backend Refactoring
**Feature Branch**: `032-translate-requests-httpx`
**Created**: 2026-06-04
**Status**: Implemented
**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`. Через него проходит **весь** трафик superset-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` будет объединён с 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_mock` `pytest_httpx`, добавление `pytest.mark.asyncio`).
- **SC-006**: Ноль утечек соединений после 500 последовательных запросов к любым эндпоинтам.
- **SC-007**: Время выполнения предпросмотра перевода не увеличилось более чем на 10% относительно синхронной версии.
- **SC-008**: Количество «грязных» `async def`-ручек, содержащих синхронные блокирующие вызовы, равно нулю (верифицируется статическим анализом).