# Research: Async Backend Refactoring **Feature**: `032-translate-requests-httpx` **Date**: 2026-06-04 **Status**: ✅ Implemented — all decisions applied ## R1: APIClient Migration — requests.Session → httpx.AsyncClient ### Decision Расширить существующий `AsyncAPIClient` в `backend/src/core/utils/async_network.py` — добавить семафор `asyncio.Semaphore(connection_pool_size)`, connection pool config и поддержку параметров из `EnvironmentConfig`. Новый файл `async_client.py` **не создаётся** — `async_network.py` остаётся каноническим путём. `SupersetAuthCache` (общий для sync/async) и пагинированные хелперы уже существуют и переиспользуются. ### Rationale - `httpx==0.28.1` уже в `requirements.txt`. Никаких новых зависимостей для базового HTTP. - `httpx.AsyncClient` поддерживает connection pooling (keepalive) и таймауты на уровне клиента. - `async_network.py` уже содержит `AsyncAPIClient` с auth‑cache и error translation. Расширение существующего кода устраняет дублирование. - API-поверхность методов `APIClient.request()` остаётся похожей, что упрощает миграцию миксинов. ### Alternatives Considered - **aiohttp**: Отвергнут — httpx уже в зависимостях, имеет лучший API и поддержку sync/async dual-mode. - **Оставить `requests` + `asyncio.to_thread`**: Отвергнут — создаёт тред на каждый запрос, неэффективно при высокой нагрузке. - **`httpx.Client` (sync mode)**: Отвергнут — это просто замена requests на httpx без решения проблемы блокировки event loop'а. ### Impact On Contracts / Tasks - Новый контракт: `AsyncAPIClient` [C:4] — stateful (connection pool, cookie jar, CSRF token). - Контракты на `auth.py` [C:3] и `paginated.py` [C:3] извлекаются из старого `network.py`. - Все 13 миксинов `SupersetClient` обновляют `@RELATION DEPENDS_ON -> [AsyncAPIClient]`. --- ## R2: SupersetClient Merge — Sync + AsyncSupersetClient → Unified Async ### Decision Синхронный `SupersetClient` (`_base.py`) и `AsyncSupersetClient` (`async_superset_client.py`) сливаются в единый `SupersetClient` с полностью асинхронными методами. `AsyncSupersetClient` удаляется. Все 13 миксинов становятся асинхронными. Старый синхронный код удаляется полностью (big-bang). ### Rationale - Big-bang выбран на этапе clarification — не требует поддержки dual-stack. - `AsyncSupersetClient` уже реализует асинхронные версии некоторых методов (`get_dashboards`, `get_datasets`, `export_dashboard`) — их сигнатуры становятся целевыми для объединённого клиента. - Удаление sync-кода исключает риск случайного использования блокирующего клиента в будущем. ### Alternatives Considered - **Dual-stack (sync + async)**: Отвергнут на этапе clarification. - **Wrapper-only (asyncio.to_thread на всё)**: Отвергнут — избыточные треды, не решает проблему connection pooling'а. ### Impact On Contracts / Tasks - Контракт `SupersetClient` [C:4] обновляется: все методы → `async def`, @SIDE_EFFECT — HTTP-вызовы, @PRE — валидный env_config. - `AsyncSupersetClient` → Tombstone с @DEPRECATED + @REPLACED_BY -> [SupersetClient]. - 13 файлов миксинов требуют обновления сигнатур. --- ## R3: Task Manager Asyncio Migration ### Decision `ThreadPoolExecutor` в `TaskManager` заменяется на `asyncio.create_task`. Все фоновые задачи выполняются в основном event loop'е. `EventBus` для WebSocket-логирования становится асинхронным (использует `asyncio.Queue` вместо `queue.Queue`). `TaskLifecycle` переходит на async-контекстные менеджеры. ### Rationale - Выбрано на этапе clarification (Option B). - `asyncio.create_task` не создаёт дополнительных тредов, задачи разделяют ресурсы event loop'а. - Упрощает отмену задач: `task.cancel()` + `CancelledError` cascade. - WebSocket-логирование через `asyncio.Queue` интегрируется нативно. ### Alternatives Considered - **Per-task event loop (`asyncio.run`)**: Отвергнут — создаёт изоляцию между задачами, усложняет общую отмену. - **Hybrid (лёгкие на create_task, тяжёлые на ThreadPoolExecutor)**: Отвергнут — усложняет архитектуру без выигрыша, т.к. все тяжёлые операции теперь асинхронны. ### Impact On Contracts / Tasks - Контракт `TaskManager` [C:5] — @INVARIANT: задачи не блокируют event loop, @DATA_CONTRACT: Input: TaskParams → Output: TaskResult. - Контракт `EventBus` [C:4] обновляется: `queue.Queue` → `asyncio.Queue`, `call_soon_threadsafe` удаляется. - Контракт `TaskLifecycle` [C:4] обновляется: async context managers. --- ## R4: Connection Pool / Semaphore Design ### Decision `asyncio.Semaphore(20)` на каждое окружение Superset. Семафор создаётся при инициализации `AsyncAPIClient` для конкретного `env_id`. Все операции (экспорт, SQL Lab, chart data) конкурируют за один пул. При исчерпании — `asyncio.wait_for(semaphore.acquire(), timeout=30)`. Таймаут и размер пула настраиваются через конфигурацию окружения. ### Rationale - Выбрано на этапе clarification (Option B — semaphore per env, wait with timeout). - `asyncio.Semaphore` — это built-in, не требует внешних зависимостей. - Конфигурация на уровне окружения позволяет задать разные лимиты для dev/staging/prod Superset. - Таймаут 30 секунд даёт достаточно времени для освобождения слота при типичных операциях (экспорт дашборда — 2-5 секунд). ### Alternatives Considered - **Без ограничений**: Отвергнут — risk of overwhelming Superset. - **Семафоры по типам операций**: Отвергнут — избыточная сложность для текущего масштаба. ### Impact On Contracts / Tasks - Контракт `AsyncAPIClient` [C:4] добавляет @INVARIANT: количество одновременных запросов ≤ semaphore_size. - Новый edge case: `503 Service Unavailable` при таймауте семафора. - Тесты на семафор: `test_semaphore_timeout_returns_503`, `test_semaphore_releases_after_completion`. --- ## R5: Test Mock Migration — requests_mock → pytest_httpx ### Decision Все тесты, использующие `requests_mock`, переводятся на `pytest-httpx`. `pytest-httpx` предоставляет фикстуру `httpx_mock`, которая перехватывает вызовы `httpx.AsyncClient` на транспортном уровне. Логика assertions (проверка URL, method, payload, response) сохраняется. ### Rationale - Выбрано на этапе clarification (Option A). - `pytest-httpx` — de-facto стандарт для тестирования httpx в pytest. - Минимальные изменения в assertions: `httpx_mock.add_response(url=..., method="GET", json=...)` — синтаксис похож на `requests_mock`. ### Alternatives Considered - **respx**: Отвергнут — менее распространён в pytest-экосистеме. - **Встроенный `httpx.MockTransport`**: Отвергнут — больше boilerplate. - **Сохранить `requests_mock` + врапперы**: Отвергнут — костыль, тесты не проверяют реальное асинхронное поведение. ### Impact On Contracts / Tasks - Новая dev-зависимость: `pytest-httpx` в `requirements-dev.txt` или `pyproject.toml`. - Обновление `conftest.py`: глобальная фикстура `httpx_mock`. - Все тестовые файлы, использующие `requests_mock`, обновляются. --- ## R6: LLM Client Migration — requests → httpx.AsyncClient ### Decision Два файла LLM-клиентов (`_llm_http.py` и `preview_llm_client.py`) переводятся на `httpx.AsyncClient`. Функции `call_openai_compatible()` становятся `async def`. Rate-limit backoff (`time.sleep`) заменяется на `asyncio.sleep`. Оба файла используют один и тот же паттерн, поэтому логика HTTP-вызова выносится в общий модуль `_llm_async_http.py`. ### Rationale - `httpx.AsyncClient` уже используется в `llm_analysis/service.py` через `AsyncOpenAI` — прецедент есть. - Вынос общей HTTP-логики в отдельный модуль устраняет дублирование между `_llm_http.py` (для execution) и `preview_llm_client.py` (для preview). - `asyncio.sleep` вместо `time.sleep` — не блокирует event loop при rate-limit ожидании. ### Alternatives Considered - **`AsyncOpenAI` (как в llm_analysis)**: Отвергнут для translate — translate использует OpenAI-совместимый протокол, но не официальный OpenAI SDK. `AsyncOpenAI` не поддерживает произвольные base_url с кастомными провайдерами (OpenRouter, LiteLLM, Kilo). - **`httpx.Client` (sync) в asyncio.to_thread**: Отвергнут — избыточно, httpx.AsyncClient решает проблему нативно. ### Impact On Contracts / Tasks - Контракт `LLMHttpClient` [C:4] — новый модуль `_llm_async_http.py` с `async def call_openai_compatible()`. - `_llm_http.py` и `preview_llm_client.py` → Tombstone с @DEPRECATED + @REPLACED_BY. - `_llm_call.py`: вызов `call_llm_for_batch()` становится `async def`. --- ## R7: SQL Lab Executor Async Migration ### Decision `SupersetSqlLabExecutor.execute_sql()` и `poll_execution_status()` становятся `async def`. Вызовы `client.network.request()` заменяются на `await async_client.request()`. `time.sleep(poll_interval)` заменяется на `await asyncio.sleep(poll_interval)`. Класс остаётся в `superset_executor.py`, но получает `AsyncAPIClient` вместо sync `SupersetClient`. ### Rationale - Поллинг — основной блокировщик в проверке схемы (до 15 × 1s + HTTP latency = до 30s блокировки). - `asyncio.sleep` передаёт управление event loop'у между попытками поллинга. - `asyncio.wait_for` может быть использован для общего таймаута всей операции проверки схемы. ### Impact On Contracts / Tasks - Контракт `SupersetSqlLabExecutor` [C:4] — все методы `async def`, @SIDE_EFFECT — асинхронные HTTP-вызовы к Superset SQL Lab API. - `check_target_schema` (`_job_routes.py`) — уже `async def`, теперь вызывает `await validate_target_table_schema()`. --- ## R8: File I/O Async Strategy ### Decision Гибридный подход: - **Большие файлы** (экспорт дашбордов ZIP > 1MB, загрузка в storage > 10MB): `aiofiles` для потокового чтения/записи. - **Мелкие операции** (shutil.rmtree, shutil.copy2, read_bytes, CRC32): `asyncio.to_thread()`. - `aiofiles` добавляется в `requirements.txt`. ### Rationale - `asyncio.to_thread` для мелких операций — минимальный overhead, не требует новой зависимости. - `aiofiles` для больших файлов — предоставляет async-интерфейс, избегает блокировки event loop (может использовать executor-потоки внутри, но API остаётся неблокирующим). - Гибридный подход балансирует простоту и эффективность. ### Alternatives Considered - **Только `asyncio.to_thread`**: Отвергнут — для больших файлов (50MB ZIP) тред блокируется на секунды, что исчерпывает thread pool. - **Только `aiofiles`**: Отвергнут — для мелких операций (shutil.rmtree) `aiofiles` избыточен и не поддерживает рекурсивные операции. ### Impact On Contracts / Tasks - Контракт `FileIO` [C:3] обновляется: @RATIONALE для гибридного подхода. - `aiofiles` добавляется в `requirements.txt`. - `shutil` вызовы в плагинах оборачиваются в `asyncio.to_thread`. --- ## R9: WebSocket Logging Async Migration ### Decision `EventBus` переходит с `queue.Queue` + `call_soon_threadsafe` на `asyncio.Queue`. WebSocket-отправка логов становится нативной асинхронной операцией. `TaskLifecycle` использует `asyncio.Queue` для получения событий от задач. ### Rationale - `asyncio.Queue` интегрируется с event loop'ом без тредовой синхронизации. - `call_soon_threadsafe` больше не нужен — все задачи в одном event loop'е. - Упрощает отмену: при закрытии WebSocket очередь очищается, задачи не зависают. ### Impact On Contracts / Tasks - Контракт `EventBus` [C:4] — замена `queue.Queue` → `asyncio.Queue`. - Контракт `TaskLifecycle` [C:4] — асинхронный `run_task()`. - WebSocket-ручки (`/ws/tasks/{id}`) — уже `async def`, изменений не требуют. --- ## R10: Notification Providers Async Migration ### Decision - **SMTP**: `smtplib.SMTP` → `aiosmtplib.SMTP` (async SMTP-клиент). - **Telegram/Slack**: `requests.post` → `httpx.AsyncClient.post`. - Все методы `send()` становятся действительно асинхронными. ### Rationale - `aiosmtplib` — зрелая библиотека (2k+ stars), поддерживает STARTTLS, аутентификацию. - `httpx.AsyncClient` для Telegram/Slack — переиспользование того же клиента, что и для Superset/LLM. ### Impact On Contracts / Tasks - Контракты `SMTPProvider`, `TelegramProvider`, `SlackProvider` [C:3] — `async def send()`. - `aiosmtplib` добавляется в `requirements.txt`. --- ## Unmitigated Risks ### R1: Сложность отладки асинхронных race conditions **Вероятность**: Средняя | **Влияние**: Высокое Ошибки конкурентности могут не воспроизводиться в тестах (одновременный deploy + backup одного дашборда, параллельное обновление кэша). **Митигация**: Интеграционные тесты с `asyncio.gather` и случайными задержками (fuzzing). Увеличенное логирование с `trace_id`. ### R2: Миграция тестов с `requests_mock` на `pytest-httpx` **Вероятность**: Средняя | **Влияние**: Среднее Различия в API моков могут привести к тому, что тесты перестанут проверять правильные заголовки/куки. **Митигация**: Пошаговая верификация (T049-T053). Параллельный запуск старых и новых тестов на время переходного периода. ### R3: Исчерпание семафора и таймауты **Вероятность**: Высокая | **Влияние**: Среднее При долгой операции (экспорт >30 с) ожидающие запросы получат 503, хотя слот скоро освободится. **Митигация**: `connection_pool_timeout` настраивается. Рекомендуется увеличить default до 60 с для prod. Метрика `superset_pool_wait_time` для мониторинга (post‑MVP). ### R4: Утечки ресурсов при `asyncio.to_thread` **Вероятность**: Низкая | **Влияние**: Высокое Поток в `to_thread` может зависнуть (сетевой вызов внутри). Приведёт к утечке потока из bounded‑executor. **Митигация**: Bounded‑executor'ы с `max_workers`. Где возможно — `asyncio.wait_for` вокруг `to_thread`. Для Git/файлов — явный таймаут в адаптере. ### R5: Сложность рефакторинга Task Manager **Вероятность**: Низкая | **Влияние**: Высокое Переход с `ThreadPoolExecutor` на `asyncio.create_task` может нарушить логику отмены задач и WebSocket‑логирования, если текущий код полагается на тредовую изоляцию. **Митигация**: Сквозной тест: запустить долгую задачу, отменить через API, проверить WebSocket‑событие отмены и завершение всех корутин. ### R6: Производительность при массовых DB-операциях **Вероятность**: Средняя | **Влияние**: Среднее При 100 параллельных запросах каждый создаёт поток для БД через `to_thread`. **Митигация**: Bounded `db_executor` с `max_workers=10`. Очередь запросов через `asyncio.Semaphore` для БД (отдельный от Superset пула). --- ## Summary of New Dependencies | Package | Purpose | Already in requirements.txt? | |---------|---------|------------------------------| | `httpx==0.28.1` | Async HTTP client | ✅ Yes | | `anyio==4.12.0` | Async I/O primitives | ✅ Yes | | `pytest-httpx` | Test mock for httpx | ❌ Add to dev deps | | `aiofiles` | Async file I/O | ❌ Add to requirements.txt | | `aiosmtplib` | Async SMTP | ❌ Add to requirements.txt |