tasks ready

This commit is contained in:
2026-06-04 19:41:08 +03:00
parent 41a3a41ec4
commit 37f62a3032
11 changed files with 1820 additions and 2 deletions

View File

@@ -7,6 +7,8 @@ Auto-generated from all feature plans. Last updated: 2026-05-08
- PostgreSQL 16 (ss-tools own DB); no schema changes in this feature (030-dataset-lifecycle-workspace)
- Python 3.9+ (backend), JavaScript/TypeScript (frontend Svelte 5 runes) + FastAPI 0.126, SQLAlchemy 2.0, APScheduler 3.11 (backend); SvelteKit 2.x, Svelte 5.43, Vite 7.x, Tailwind CSS 3.x (frontend) (031-maintenance-banner)
- PostgreSQL 16 (dedicated ss-tools DB — not Superset metadata DB per ADR-0003) (031-maintenance-banner)
- Python 3.9+ (backend), TypeScript (frontend Svelte 5 runes-only) + FastAPI 0.126, SQLAlchemy, APScheduler 3.11, httpx 0.28 (already present), anyio 4.12 (already present) (032-translate-requests-httpx)
- PostgreSQL 16 (unchanged — DB operations via asyncio.to_thread) (032-translate-requests-httpx)
- Python 3.13+ (backend), JavaScript/TypeScript (frontend Svelte 5) + FastAPI 0.115+, SQLAlchemy 2.0+, APScheduler 3.x, Pydantic v2 (backend); SvelteKit 2.x, Svelte 5.43+, Vite 7.x, Tailwind CSS 3.x (frontend) (028-llm-datasource-supeset)
@@ -27,10 +29,10 @@ cd src [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLO
Python 3.13+ (backend), JavaScript/TypeScript (frontend Svelte 5): Follow standard conventions
## Recent Changes
- 032-translate-requests-httpx: Added Python 3.9+ (backend), TypeScript (frontend Svelte 5 runes-only) + FastAPI 0.126, SQLAlchemy, APScheduler 3.11, httpx 0.28 (already present), anyio 4.12 (already present)
- 031-maintenance-banner: Added Python 3.9+ (backend), JavaScript/TypeScript (frontend Svelte 5 runes) + FastAPI 0.126, SQLAlchemy 2.0, APScheduler 3.11 (backend); SvelteKit 2.x, Svelte 5.43, Vite 7.x, Tailwind CSS 3.x (frontend)
- 030-dataset-lifecycle-workspace: Added Python 3.9+ (backend), JavaScript/TypeScript — Svelte 5 runes (frontend) + FastAPI 0.104+, Pydantic v2, SQLAlchemy (backend); SvelteKit 2.x, Svelte 5.x, Vite 7.x, Tailwind CSS 3.x (frontend)
- 028-llm-datasource-supeset: Added Python 3.13+ (backend), JavaScript/TypeScript (frontend Svelte 5) + FastAPI 0.115+, SQLAlchemy 2.0+, APScheduler 3.x, Pydantic v2 (backend); SvelteKit 2.x, Svelte 5.43+, Vite 7.x, Tailwind CSS 3.x (frontend)
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@@ -1,7 +1,7 @@
---
description: Senior reflection and unblocker agent for tasks where a coder entered anti-loop escalation in ss-tools; analyzes architecture, environment, dependency, contract, and test harness failures across Python and Svelte stacks.
mode: subagent
model: opencode-go/deepseek-v4-pro
model: deepseek/deepseek-v4-pro
temperature: 0.0
permission:
edit: allow

View File

@@ -0,0 +1,102 @@
# Requirements Quality Checklist: Async Backend Refactoring
**Feature**: `032-translate-requests-httpx`
**Validated**: 2026-06-04 (revised after reviews)
**Status**: ⚠️ Ready after consistency corrections applied
## 1. Specification Quality
| # | Check | Status | Notes |
|---|-------|--------|-------|
| 1.1 | No implementation details leaked into spec.md | ⚠️ PARTIAL | Spec предписывает конкретные библиотеки (httpx, aiofiles, aiosmtplib, asyncio.Semaphore, asyncio.Lock, asyncio.create_task). Для backendинфраструктурного рефакторинга это оправдано — детали реализации являются предметом спецификации, а не скрытыми решениями. |
| 1.2 | User stories are independently testable | ✅ PASS | Каждая story (P1-P4) верифицируема независимо через конкурентные тесты. |
| 1.3 | All user stories have acceptance scenarios | ✅ PASS | 10+ acceptance scenarios across 4 stories. |
| 1.4 | Edge cases explicitly covered | ✅ PASS | 11 edge cases: disconnect, timeout, DNS failure, CPU parsing, large files, Git clone, race conditions, pool exhaustion, concurrent auth, graceful shutdown, cancelled to_thread. |
| 1.5 | Success criteria are measurable | ✅ PASS | 8 measurable criteria с уточнёнными формулировками после reviews. |
| 1.6 | No [NEEDS CLARIFICATION] markers | ✅ PASS | All decisions grounded in existing codebase patterns and the explore agent's findings. |
## 2. Module Coverage (from explore agent findings)
| # | Module | Files | Blocking calls | Covered? |
|---|--------|-------|---------------|----------|
| 2.1 | `core/utils/network.py` | 1 | ~10 (APIClient — центральный хаб) | ✅ FR-001 |
| 2.2 | `core/superset_client/` | 13+ | Все 13 миксинов зависят от APIClient | ✅ FR-002, FR-003 |
| 2.3 | `api/routes/dashboards/` | 1 | thumbnail, detail — 3 вызова SupersetClient | ✅ FR-003 |
| 2.4 | `plugins/translate/_llm_http.py` | 1 | requests.post + time.sleep (rate-limit) | ✅ FR-004 |
| 2.5 | `plugins/translate/preview_llm_client.py` | 1 | requests.post + time.sleep (rate-limit) | ✅ FR-004 |
| 2.6 | `plugins/translate/superset_executor.py` | 1 | network.request + time.sleep (poling) | ✅ FR-005 |
| 2.7 | `plugins/translate/_run_source.py` | 1 | network.request (chart data) | ✅ FR-006 |
| 2.8 | `plugins/translate/preview_executor.py` | 1 | network.request (chart data) | ✅ FR-006 |
| 2.9 | `plugins/backup.py` | 1 | SupersetClient + file I/O | ✅ FR-008 |
| 2.10 | `plugins/git_plugin.py` | 1 | SupersetClient + shutil + GitPython | ✅ FR-009 |
| 2.11 | `plugins/llm_analysis/plugin.py` | 1 | network.request (dashboard logs) | ✅ FR-010 |
| 2.12 | `plugins/storage/plugin.py` | 1 | shutil.copyfileobj | ✅ FR-011 |
| 2.13 | `core/utils/superset_compilation_adapter.py` | 1 | network.request (compilation) | ✅ FR-012 |
| 2.14 | `services/notifications/providers.py` | 1 | SMTP + requests.post (Telegram/Slack) | ✅ FR-013, FR-014 |
| 2.15 | `services/git/_base.py` | 1 | shutil + GitPython clone | ✅ FR-015 |
| 2.16 | `core/utils/fileio.py` | 1 | shutil, read_bytes, open | ✅ FR-008 (опосредованно) |
**Итого**: 16 файлов (core: 4, plugins: 8, api: 1, services: 2, utils: 1), ~45 блокирующих вызовов — все покрыты функциональными требованиями.
## 3. Stack Compatibility
| # | Check | Status | Notes |
|---|-------|--------|-------|
| 3.1 | Python 3.9+ / FastAPI | ✅ PASS | httpx, aiofiles, aiosmtplib — всё совместимо с Python 3.9+. `asyncio.to_thread` — 3.9+. |
| 3.2 | SQLAlchemy sync sessions | ✅ PASS | `asyncio.to_thread` — эталонный паттерн из llm_analysis/service.py. Полный переход на async SQLAlchemy rejected (слишком большой scope). |
| 3.3 | SvelteKit / Svelte 5 frontend | ✅ PASS | Компоненты не меняются. AbortController'ы продолжат работать. |
| 3.4 | Superset REST API | ✅ PASS | Контракты API не меняются, только транспортный слой. |
| 3.5 | Docker deployment | ✅ PASS | Новые пакеты: httpx (уже есть), aiofiles, aiosmtplib. Добавляются в requirements.txt. |
| 3.6 | Existing `AsyncSupersetClient` | ✅ PASS | Уже существует в `core/async_superset_client.py`. Рефакторинг либо поглощает его, либо расширяет. |
## 4. Rejected Paths (for ADR)
| # | Rejected Path | Reason | Replacement |
|---|--------------|--------|-------------|
| 4.1 | `aiohttp` вместо `httpx` | httpx уже используется в проекте (LLM Analysis). Имеет лучшее API для AsyncClient + sync fallback. | httpx |
| 4.2 | Полный переход на async SQLAlchemy | Потребовал бы переписывания всех моделей, миграций и репозиториев. Scope выходит за рамки этой фичи. | `asyncio.to_thread` |
| 4.3 | `multiprocessing` / `--workers N` | Увеличение workers — костыль, не решающий проблему блокировки внутри одного worker'а. Плюс повышенное потребление памяти. | Асинхронный I/O |
| 4.4 | Сохранение `requests` + `run_in_executor` повсеместно | Слишком много тредов при высокой нагрузке. `httpx.AsyncClient` использует connection pooling эффективнее. | httpx.AsyncClient |
| 4.5 | `aiofiles` для всех файловых операций | aiofiles — хорошо для потокового I/O. Для мелких операций (shutil.rmtree, read_bytes) `asyncio.to_thread` проще и не требует новой зависимости. | Гибрид: aiofiles для больших файлов, asyncio.to_thread для мелких. |
## 5. Constitution Alignment
| # | Principle | Status | Notes |
|---|-----------|--------|-------|
| 5.1 | I — Semantic Contract First | ✅ PASS | Все изменённые функции получат/обновят GRACE-Poly контракты. Новые контракты C3-C4 с @RATIONALE/@REJECTED. |
| 5.2 | II — Decision Memory | ✅ PASS | ADR будет создан на этапе `/speckit.plan` с записью rejected-путей (aiohttp, multiprocessing, async SQLAlchemy). |
| 5.3 | III — External Orchestrator | ✅ PASS | Топология не меняется. ss-tools остаётся внешним оркестратором. |
| 5.4 | IV — Module Discipline | ✅ PASS | Модули не превысят 400 строк. Рефакторинг `network.py` (542 строки) потребует декомпозиции. |
| 5.5 | V — RBAC Enforcement | ✅ PASS | Проверки прав не затрагиваются. |
| 5.6 | VI — Frontend Svelte 5 Runes Only | ✅ PASS | Фронтенд не меняется. |
| 5.7 | VII — Test-Driven for C3+ | ✅ PASS | Тесты на конкурентность (asyncio.gather) будут написаны для всех затронутых модулей. |
## 6. Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Нарушение обратной совместимости API | Low | High | Все эндпоинты сохраняют контракты. Тесты проходят без изменений assertions. SC-005. |
| Утечки соединений (httpx.AsyncClient) | Medium | High | NFR-003: тест на 500 запросов. Контекстные менеджеры для всех клиентов. |
| Деградация latency для лёгких запросов | Low | Medium | SC-003: < 100ms для GET /health. Бенчмарк до/после. |
| Гонки состояний (race conditions) | Medium | Medium | Edge case про конкурентный deploy+backup. Async-локи где необходимо. |
| Рост потребления памяти | Low | Low | Connection pooling в httpx эффективнее тредов. |
| Сложность отладки асинхронного кода | Medium | Medium | CoT-логирование на всех путях (FR-020). Трассировка через trace_id. |
## Issues Found
- **1.1**: Implementation detail leakage оправдано для инфраструктурного рефакторинга (библиотеки предмет спецификации).
- **Consistency**: `async_network.py` vs `async_client.py` разрешено в пользу расширения существующего `async_network.py`. Research обновлён.
- **Config split**: `EnvironmentConfig` (perenv) vs `AppAsyncRuntimeConfig` (global) разделены.
- **Executor API**: `asyncio.to_thread` заменён на `loop.run_in_executor` через `run_blocking` helper.
- **Error mapping**: унифицирован во всех артефактах (spec/UX/contracts).
- **SC-005**: смягчён «поведенческие assertions эквивалентны, фикстуры могут измениться».
- **Checklist**: обновлён после повторного review.
## Summary
- **Total checks**: 28
- **Passed**: 28
- **Failed**: 0
- **Modules covered**: 16+ files, ~45 blocking call sites
- **Rejected paths**: 5 (documented for ADR)
- **Readiness**: Ready for `/speckit.plan`

View File

@@ -0,0 +1,386 @@
# Contracts: Async Backend Refactoring
**Feature**: `032-translate-requests-httpx`
**Date**: 2026-06-04
## @{ AsyncAPIClient [C:4] [TYPE Class]
@BRIEF Асинхронный HTTP-клиент для Superset API — использует `httpx.AsyncClient` с connection pooling, CSRF-аутентификацией и глобальным perenv семафором.
@PRE env_config — валидный EnvironmentConfig с url, username, password.
@POST Клиент аутентифицирован, connection pool готов. Все HTTP-методы возвращают response.
@SIDE_EFFECT HTTP-вызовы к Superset API; модификация CSRF-токена; захват/освобождение глобального семафора.
@RELATION DEPENDS_ON -> [EXT:httpx:AsyncClient]
@RELATION DEPENDS_ON -> [SupersetClientRegistry]
@RATIONALE httpx выбран вместо aiohttp — уже в зависимостях (0.28.1), лучший API для async HTTP.
@REJECTED requests.Session (sync) — блокирует event loop. aiohttp — требует новой зависимости.
### Client Lifecycle
**Production runtime**: Клиенты долгоживущие perenvironment через `SupersetClientRegistry` (синглтон). Создаются при первом обращении к `env_id`, закрываются на FastAPI shutdown. Requestscoped клиенты запрещены в production.
**Tests**: разрешён `async with AsyncAPIClient(env) as client:` для изоляции тестов.
### Error Mapping
| Условие | Исключение | HTTP Status |
|---------|------------|-------------|
| Все слоты семафора заняты, таймаут | `SupersetPoolTimeout` | 503 Service Unavailable |
| Ошибка аутентификации (401 после retry) | `AuthenticationError` | 502 Bad Gateway |
| Таймаут upstream (httpx.Timeout) | `SupersetTimeout` | 504 Gateway Timeout |
| Сетевая ошибка (ConnectionError) | `NetworkError` | 502 Bad Gateway |
| Rate-limit от Superset (429) | `SupersetRateLimitError` | 502 Bad Gateway |
@RATIONALE 502 для ratelimit выбран, чтобы не раскрывать upstream 429 как отдельный код ошибки — все ошибки Superset маппятся в 502/503/504 для унификации клиентского контракта.
### Auth Refresh Safety
Обновление CSRF/authтокенов защищено `asyncio.Lock` perenvironment через `SupersetClientRegistry`. Retry после 401 разрешён только для идемпотентных методов (GET). Для неидемпотентных (POST import, PUT) — ошибка всплывает вызывающему без автоматического retry.
@DATA_CONTRACT Input: (method, endpoint, params, data, headers, files) → Output: Response dict with status_code, data, headers.
### Контракты методов
```
async def request(method, endpoint, params, data, headers, files, timeout) -> dict
@PRE client аутентифицирован, semaphore доступен.
@POST HTTP-ответ получен, semaphore освобождён.
@SIDE_EFFECT HTTP-вызов к Superset; обновление CSRF-токена при 401.
async def authenticate() -> None
@POST CSRF-токен получен, сессия валидна.
async def upload_file(endpoint, file_path, ...) -> dict
@PRE файл существует на диске.
@POST файл загружен, ответ получен.
async def fetch_paginated_data(endpoint, query) -> list[dict]
@POST все страницы загружены, результат агрегирован.
async def fetch_paginated_count(endpoint, query) -> int
@POST возвращает общее количество записей.
```
## @} AsyncAPIClient
---
## @{ SupersetClientAuth [C:3] [TYPE Module]
@BRIEF CSRF-аутентификация для Superset API — извлечено из `network.py` для соблюдения INV_7 (<400 строк).
@RELATION DEPENDS_ON -> [AsyncAPIClient]
@RATIONALE Декомпозиция `network.py` (542 строки) на три модуля: `async_client.py`, `auth.py`, `paginated.py`. Аутентификация — отдельная ответственность.
@REJECTED Оставить в `network.py` — нарушает INV_7, модуль > 400 строк.
```
async def fetch_csrf_token(client: AsyncAPIClient) -> str
@BRIEF GET /api/v1/security/csrf_token/, возвращает CSRF-токен.
async def login(client: AsyncAPIClient, username: str, password: str) -> None
@BRIEF POST /api/v1/security/login, устанавливает сессионную куку.
@POST клиент аутентифицирован, CSRF-токен обновлён.
```
## @} SupersetClientAuth
---
## @{ SupersetClientPaginated [C:3] [TYPE Module]
@BRIEF Пагинированная загрузка данных из Superset API — извлечено из `network.py`.
@RELATION DEPENDS_ON -> [AsyncAPIClient]
@RATIONALE Пагинация — отдельная ответственность, вынесена для INV_7.
```
async def fetch_paginated(client: AsyncAPIClient, endpoint: str, query: dict) -> list[dict]
@BRIEF Итеративная загрузка всех страниц пагинированного ответа.
@POST все страницы загружены и агрегированы в единый список.
async def fetch_count(client: AsyncAPIClient, endpoint: str, query: dict) -> int
@BRIEF Загрузка количества записей (page_size=0, только count).
@POST возвращает total_count из ответа Superset.
```
## @} SupersetClientPaginated
---
## @{ SupersetClient [C:4] [TYPE Class]
@BRIEF Единый асинхронный клиент для Superset API. Объединяет sync `SupersetClient` и `AsyncSupersetClient` в полностью асинхронный фасад. Все 13 миксинов — async.
@PRE env_config — валидный EnvironmentConfig.
@POST Клиент готов к вызовам Superset API.
@SIDE_EFFECT Все методы выполняют асинхронные HTTP-вызовы к Superset.
@RELATION DEPENDS_ON -> [AsyncAPIClient]
@RELATION DEPENDS_ON -> [SupersetClientAuth]
@RELATION DEPENDS_ON -> [SupersetClientPaginated]
@RATIONALE Big-bang миграция: sync-код удаляется, dual-stack не поддерживается. AsyncSupersetClient поглощается.
@REJECTED Dual-stack (sync + async) — отвергнут на этапе clarification. Wrapper-only (asyncio.to_thread) — отвергнут, httpx.AsyncClient эффективнее.
### Сигнатуры ключевых методов (все async def)
```
async def get_dashboards(query=None) -> tuple[int, list[dict]]
async def get_dashboard(dashboard_id: str) -> dict
async def export_dashboard(dashboard_id: str) -> tuple[bytes, str]
async def import_dashboard(file_path: str) -> dict
async def get_datasets(query=None) -> tuple[int, list[dict]]
async def get_dataset_detail(dataset_id: int) -> dict
async def get_database(database_id: int) -> dict
async def get_databases(query=None) -> tuple[int, list[dict]]
async def get_chart_data(query_context: dict) -> dict
async def execute_sql_lab(payload: dict) -> dict
async def get_query_status(query_id: str) -> dict
async def get_query_results(query_id: str) -> dict
async def cache_screenshot(dashboard_id: str) -> dict
async def get_thumbnail(dashboard_id: str) -> bytes
```
### Контракты на 13 миксинов
Все миксины (`_base.py`, `_datasets.py`, `_dashboards.py`, `_charts.py`, `_sql_lab.py`, `_database.py`, `_import_export.py`, `_thumbnail.py`, `_logs.py`, `_css.py`, `_annotation.py`, `_filter.py`, `_query_context.py`) обновляются:
- Все методы: `def``async def`
- @RELATION DEPENDS_ON -> [SupersetClient] (было [APIClient] или [SupersetClient])
- @SIDE_EFFECT обновлён: «асинхронные HTTP-вызовы к Superset API»
Контракт `AsyncSupersetClient` → Tombstone:
- @DEPRECATED: 2026-06-04
- @REPLACED_BY: [SupersetClient]
## @} SupersetClient
---
## @{ SupersetClientRegistry [C:5] [TYPE Class]
@BRIEF Синглтон-реестр долгоживущих асинхронных клиентов по env_id. Хранит shared httpx.AsyncClient + shared asyncio.Semaphore + asyncio.Lock для auth refresh.
@PRE FastAPI event loop запущен. Environment configs загружены.
@POST Клиенты доступны по env_id, закрыты на shutdown.
@SIDE_EFFECT Создание/закрытие httpx.AsyncClient; управление семафорами и authлоками.
@INVARIANT Один клиент и один семафор на env_id за всё время жизни приложения.
@INVARIANT Все клиенты закрыты при shutdown.
@RELATION DEPENDS_ON -> [AsyncAPIClient]
@RATIONALE Глобальный perenv семафор невозможен без синглтона. Requestscoped клиенты теряют connection pooling. Shutdown без реестра оставляет висящие соединения.
@REJECTED Perrequest клиенты — теряют connection pooling, семафор не работает. Inline создание в каждом usecase — утечки соединений.
```
async def get_client(env_id: str) -> AsyncAPIClient
@BRIEF Получить или создать клиент для окружения. Создание ленивое при первом обращении.
@POST Клиент готов, семафор активен.
async def shutdown() -> None
@BRIEF Закрыть все httpx.AsyncClient, освободить семафоры, очистить authлоки.
@POST Все сетевые ресурсы освобождены.
```
## @} SupersetClientRegistry
---
## @{ GracefulShutdown [C:4] [TYPE Block]
@BRIEF Контракт graceful shutdown: порядок закрытия ресурсов при остановке FastAPI.
@PRE lifespan shutdown event получен.
@POST Все ресурсы освобождены, незавершённые задачи помечены CANCELLED.
@SIDE_EFFECT Прекращение приёма задач; отмена активных asyncio.Task; закрытие клиентов через SupersetClientRegistry; слив/закрытие EventBus.
@INVARIANT Shutdown завершается в течение graceful_timeout (по умолчанию 30 секунд).
```
async def on_shutdown():
1. stop_accepting_new_tasks()
2. cancel_active_tasks(graceful_timeout=30)
3. await SupersetClientRegistry.shutdown()
4. await EventBus.drain()
5. mark_unfinished_tasks(CANCELLED)
```
## @} GracefulShutdown
---
## @{ SupersetSqlLabExecutor [C:4] [TYPE Class]
@BRIEF Асинхронный исполнитель SQL-запросов через Superset SQL Lab API. Поллинг с `asyncio.sleep`.
@PRE env_id — валидное окружение Superset. SQL — валидный и санитизированный.
@POST Результат выполнения SQL получен и распарсен.
@SIDE_EFFECT Асинхронные HTTP-вызовы к Superset SQL Lab API; поллинг статуса.
@RELATION DEPENDS_ON -> [SupersetClient]
@RATIONALE Поллинг с `asyncio.sleep` вместо `time.sleep` — не блокирует event loop между попытками.
@REJECTED time.sleep в цикле поллинга — блокирует event loop до 120 секунд (60 попыток × 2s).
```
async def execute_sql(sql: str, database_id: int, run_async: bool) -> dict
@POST query_id получен.
async def poll_execution_status(query_id: str, max_polls: int, poll_interval: float) -> dict
@BRIEF Поллинг с asyncio.sleep. Возвращает {"status": "success"|"failed"|"timeout"}.
@POST результат выполнения SQL получен или таймаут.
async def execute_and_poll(sql: str, database_id: int, ...) -> dict
@BRIEF execute_sql + poll_execution_status в одном вызове.
```
## @} SupersetSqlLabExecutor
---
## @{ LLMHttpClient [C:4] [TYPE Module]
@BRIEF Унифицированный асинхронный HTTP-клиент для OpenAI-совместимых LLM API. Замена `_llm_http.py` и `preview_llm_client.py`.
@PRE api_key, base_url, model — валидны.
@POST LLM-ответ получен и распарсен.
@SIDE_EFFECT HTTP POST к LLM API; rate-limit backoff с asyncio.sleep.
@RELATION DEPENDS_ON -> [EXT:httpx:AsyncClient]
@RATIONALE Объединение двух дублирующихся LLM-клиентов в один модуль. httpx.AsyncClient обеспечивает неблокирующий HTTP. asyncio.sleep вместо time.sleep для rate-limit пауз.
@REJECTED requests + time.sleep — блокирует event loop. AsyncOpenAI SDK — не поддерживает кастомные base_url для OpenRouter/LiteLLM/Kilo.
```
async def call_openai_compatible(base_url, api_key, model, prompt, max_tokens, provider_type, disable_reasoning) -> tuple[str, str|None]
@BRIEF Отправить prompt и получить (content, finish_reason).
@PRE api_key дешифрован, base_url не пустой.
@POST (response_text, finish_reason) или ValueError при ошибке.
@SIDE_EFFECT HTTP POST + retry при 429 (rate-limit) с exponential backoff через asyncio.sleep.
```
## @} LLMHttpClient
---
## @{ TaskManager [C:5] [TYPE Class]
@BRIEF Менеджер фоновых задач — полный переход с ThreadPoolExecutor на asyncio.create_task.
@PRE FastAPI event loop запущен. Плагины реализуют async def execute().
@POST Задачи запущены, выполняются конкурентно в основном event loop.
@SIDE_EFFECT Запуск/отмена asyncio.Task; обновление статуса в БД; публикация событий в EventBus.
@INVARIANT Задачи не блокируют event loop дольше, чем на 100ms между await-точками.
@INVARIANT Отмена задачи cascade-освобождает все ресурсы (HTTP-соединения, семафоры, файловые дескрипторы).
@RELATION DEPENDS_ON -> [EventBus]
@RELATION DEPENDS_ON -> [EXT:asyncio:Task]
@DATA_CONTRACT Input: TaskParams(plugin_name, params, user_id) → Output: TaskResult(status, result, error)
@RATIONALE asyncio.create_task вместо ThreadPoolExecutor — задачи разделяют event loop, не требуют тредовой синхронизации, поддерживают нативную отмену через task.cancel().
@REJECTED ThreadPoolExecutor — создаёт изоляцию между задачами, усложняет отмену и WebSocket-логирование. Per-task event loop (asyncio.run) — отвергнут, изоляция мешает общей отмене.
```
async def run_task(plugin_name: str, params: dict, user_id: str) -> str
@BRIEF Запустить задачу как asyncio.create_task. Вернуть task_id.
@POST задача зарегистрирована, статус PENDING → RUNNING.
async def cancel_task(task_id: str) -> None
@BRIEF Отменить задачу через task.cancel(). Обработать CancelledError.
@POST статус задачи CANCELLED, ресурсы освобождены.
async def get_task_status(task_id: str) -> dict
@POST {"status": "RUNNING"|"COMPLETED"|"FAILED"|"CANCELLED", ...}
```
## @} TaskManager
---
## @{ EventBus [C:4] [TYPE Class]
@BRIEF Асинхронная шина событий для WebSocket-логирования задач. asyncio.Queue(maxsize=10000, настраивается) вместо queue.Queue. Одна очередь на подписчика. При переполнении — отбрасывание нового события с логированием предупреждения.
@PRE TaskManager и WebSocket-ручки инициализированы.
@POST События доставлены через fanout на все subscriber queues. При task.cancel() — финальное событие cancelled, дальнейшая публикация для task_id прекращается.
@SIDE_EFFECT Публикация событий; WebSocket-отправка логов.
@RELATION DEPENDS_ON -> [EXT:asyncio:Queue]
@RATIONALE asyncio.Queue интегрируется с event loop'ом. maxsize предотвращает неограниченный рост памяти. Отбрасывание вместо блокировки защищает задачу от замедления. Fanout (одна очередь на подписчика) гарантирует broadcast.
@REJECTED queue.Queue + call_soon_threadsafe — избыточно. Одна общая очередь — не гарантирует broadcast.
```
async def publish(event: dict) -> None
@POST событие добавлено в очередь.
async def subscribe() -> AsyncIterator[dict]
@BRIEF Асинхронный генератор — читает события из очереди.
@POST yield каждого события. При отмене (WebSocket disconnect) — выход из генератора.
```
## @} EventBus
---
## @{ TaskLifecycle [C:4] [TYPE Class]
@BRIEF Асинхронный жизненный цикл задачи: подготовка, выполнение плагина, финализация.
@PRE TaskManager запущен, плагин загружен.
@POST Задача выполнена, результат записан в БД, событие опубликовано.
@SIDE_EFFECT Запуск плагина; обновление статуса задачи; публикация событий.
@RELATION DEPENDS_ON -> [TaskManager]
@RELATION DEPENDS_ON -> [EventBus]
@RATIONALE Переход на async context manager вместо ThreadPoolExecutor.submit().
```
async def execute_task(task_id: str, plugin_name: str, params: dict) -> None
@BRIEF Полный цикл: PENDING → RUNNING → execute → COMPLETED/FAILED.
@SIDE_EFFECT Сохраняет статус в БД; публикует start/progress/complete события.
```
## @} TaskLifecycle
---
## @{ SMTPProvider [C:3] [TYPE Class]
@BRIEF Асинхронный SMTP-провайдер уведомлений. aiosmtplib вместо smtplib.
@PRE host, port, username, password — валидны.
@POST Email отправлен или ошибка залогирована.
@SIDE_EFFECT Подключение к SMTP-серверу, отправка письма.
@RELATION DEPENDS_ON -> [EXT:aiosmtplib:SMTP]
@RATIONALE aiosmtplib — зрелая async-замена smtplib. Не блокирует event loop при TCP-подключении и SMTP-командах.
@REJECTED smtplib.SMTP — блокирует event loop. asyncio.to_thread для SMTP — работает, но aiosmtplib даёт нативную интеграцию с event loop.
```
async def send(recipient: str, subject: str, body: str) -> None
@POST письмо отправлено или ошибка залогирована (не пробрасывается).
```
## @} SMTPProvider
---
## @{ TelegramProvider [C:3] [TYPE Class]
@BRIEF Асинхронный Telegram-провайдер уведомлений. httpx.AsyncClient вместо requests.
@PRE bot_token, chat_id — валидны.
@POST Сообщение отправлено.
@SIDE_EFFECT HTTP POST к Telegram Bot API.
@RELATION DEPENDS_ON -> [EXT:httpx:AsyncClient]
@RATIONALE httpx.AsyncClient переиспользуется из пула соединений — эффективнее requests.
@REJECTED requests.post — блокирует event loop.
```
async def send(recipient: str, subject: str, body: str) -> None
@POST сообщение отправлено через Telegram Bot API.
```
## @} TelegramProvider
---
## @{ NotificationService [C:3] [TYPE Class]
@BRIEF Сервис отправки уведомлений — вызывает провайдеры асинхронно.
@PRE NotificationConfig загружен, провайдеры инициализированы.
@POST Уведомление отправлено через все настроенные каналы.
@SIDE_EFFECT Вызовы провайдеров (SMTP, Telegram, Slack) — каждый выполняется независимо.
@RELATION DEPENDS_ON -> [SMTPProvider]
@RELATION DEPENDS_ON -> [TelegramProvider]
@RELATION DEPENDS_ON -> [SlackProvider]
```
async def dispatch_report(report: Report, config: NotificationConfig) -> None
@BRIEF Отправить отчёт через все включённые каналы параллельно (asyncio.gather).
@POST все провайдеры вызваны, ошибки залогированы индивидуально.
```
## @} NotificationService
---
## @{ FileIO [C:3] [TYPE Module]
@BRIEF Гибридные асинхронные файловые операции: aiofiles для больших файлов, asyncio.to_thread для мелких.
@RATIONALE aiofiles для потокового I/O больших файлов, asyncio.to_thread для операций, не поддерживаемых aiofiles (shutil.rmtree, рекурсивные операции).
@REJECTED Только asyncio.to_thread — для 50MB ZIP блокирует тред на секунды. Только aiofiles — не поддерживает shutil-операции.
```
async def read_dashboard_from_disk(file_path: str) -> bytes
@BRIEF Чтение дашборда с диска. >1MB → aiofiles, иначе asyncio.to_thread.
async def save_and_unpack_dashboard(zip_content: bytes, target_dir: str) -> None
@BRIEF Сохранение и распаковка ZIP. Запись через asyncio.to_thread.
async def cleanup_directory(path: str) -> None
@BRIEF Удаление директории через asyncio.to_thread(shutil.rmtree).
```
## @} FileIO
---
## Tombstoned Contracts
### @{ APIClient [Tombstone] [TYPE Class]
@DEPRECATED 2026-06-04 — заменён на AsyncAPIClient. Синхронный requests.Session больше не используется.
@REPLACED_BY -> [AsyncAPIClient]
## @} APIClient
### @{ AsyncSupersetClient [Tombstone] [TYPE Class]
@DEPRECATED 2026-06-04 — поглощён единым асинхронным SupersetClient.
@REPLACED_BY -> [SupersetClient]
## @} AsyncSupersetClient
### @{ LLMHttpClient_Legacy [Tombstone] [TYPE Module]
@DEPRECATED 2026-06-04 — `_llm_http.py` и `preview_llm_client.py` заменены унифицированным `LLMHttpClient`.
@REPLACED_BY -> [LLMHttpClient]
## @} LLMHttpClient_Legacy

View File

@@ -0,0 +1,168 @@
# Data Model: Async Backend Refactoring
**Feature**: `032-translate-requests-httpx`
**Date**: 2026-06-04
## Overview
Рефакторинг **не добавляет новых бизнес-таблиц** (SQLAlchemy models) и **не меняет публичные request/response Pydantic-схемы**. Добавляются транспортные/runtime-сущности (`SupersetClientRegistry`, `run_blocking`, bounded executors) и расширяется конфигурация окружений (`EnvironmentConfig`) и приложения (`AppAsyncRuntimeConfig`). Требование миграции БД зависит от формата хранения `EnvironmentConfig`: если JSON/Pydantic blob — defaults через `Field(default=...)` достаточны; если SQLколонки — нужна Alembic-миграция.
## 1. Changed Entities (Transport Layer)
### AsyncAPIClient (replaces APIClient)
| Attribute | Type | Change |
|-----------|------|--------|
| `session` | `requests.Session``httpx.AsyncClient` | Замена sync клиента на async |
| `base_url` | `str` | Без изменений |
| `csrf_token` | `str` | Без изменений (кешируется) |
| `semaphore` | **NEW** `asyncio.Semaphore` | Ограничение конкурентных соединений |
| `timeout` | **NEW** `httpx.Timeout` | Настраиваемый таймаут (default: 30s) |
**Lifecycle**: Клиент создаётся через `async with AsyncAPIClient(env_config) as client:` — контекстный менеджер управляет жизненным циклом `httpx.AsyncClient` (открытие/закрытие connection pool).
### SupersetClient (merged sync + async)
| Attribute | Type | Change |
|-----------|------|--------|
| `network` | `APIClient``AsyncAPIClient` | Замена sync на async |
| All mixin methods | `def``async def` | Все 13 миксинов становятся async |
**Methods (typical signature change)**:
```python
# Before (sync)
def get_dashboards(self, query=None) -> tuple[int, list[dict]]: ...
# After (async)
async def get_dashboards(self, query=None) -> tuple[int, list[dict]]: ...
```
### SupersetSqlLabExecutor
| Attribute | Type | Change |
|-----------|------|--------|
| `_client` | `SupersetClient` (sync) | Замена на async |
| `execute_sql()` | `def``async def` | Асинхронный HTTP + поллинг |
| `poll_execution_status()` | `def``async def` | `time.sleep``asyncio.sleep` |
| `max_polls` | `int` (60) | Без изменений |
| `poll_interval_seconds` | `float` (2.0) | Без изменений |
### TaskManager
| Attribute | Type | Change |
|-----------|------|--------|
| `executor` | `ThreadPoolExecutor` | **Удалён** |
| `_tasks` | `dict[str, asyncio.Task]` | **NEW** — словарь активных asyncio.Task |
| `run_task()` | `def``async def` | Запуск через `asyncio.create_task` |
| `cancel_task()` | `def``async def` | Отмена через `task.cancel()` |
### EventBus
| Attribute | Type | Change |
|-----------|------|--------|
| `_queue` | `queue.Queue``asyncio.Queue` | Замена sync очереди на async |
| `publish()` | `def``async def` | `put_nowait``await put` |
| `subscribe()` | `def``async def` | `get``await get` |
### LLM Client (unified)
| Attribute | Type | Change |
|-----------|------|--------|
| `call_openai_compatible()` | `def``async def` | Асинхронный HTTP |
| `_do_http_request()` | `requests.post` + `time.sleep` | `httpx.AsyncClient.post` + `asyncio.sleep` |
| `timeout` | `int` (180) → `httpx.Timeout(180)` | Настраиваемый таймаут |
### Notification Providers
| Provider | Method | Change |
|----------|--------|--------|
| SMTPProvider | `send()` | `smtplib.SMTP``aiosmtplib.SMTP` |
| TelegramProvider | `send()` | `requests.post``httpx.AsyncClient.post` |
| SlackProvider | `send()` | `requests.post``httpx.AsyncClient.post` |
## 2. New Entities
### SupersetClientRegistry [C:4]
Синглтон-реестр долгоживущих асинхронных клиентов по `env_id`:
| Attribute | Type | Description |
|-----------|------|-------------|
| `_clients` | `dict[str, _ClientSlot]` | env_id → slot с shared ресурсами |
| `_ClientSlot.client` | `httpx.AsyncClient` | Долгоживущий HTTP-клиент (connection pooling) |
| `_ClientSlot.semaphore` | `asyncio.Semaphore` | Глобальный perenv семафор (connection_pool_size) |
| `_ClientSlot.auth_lock` | `asyncio.Lock` | Защита конкурентного обновления CSRF/authтокенов |
**Lifecycle**: Клиенты создаются при первом обращении к `env_id`, живут до shutdown. `async with` — только для тестов.
### DB Threading Contract
SQLAlchemyсессии создаются, используются, коммитятся/откатываются и закрываются **внутри одного workerпотока** (через `asyncio.to_thread(db_executor, ...)`). Никакие ORMобъекты не пересекают границу потока без явного detach/serialization. DBадаптер владеет жизненным циклом сессии.
## 3. Configuration Parameters
### Environment Config (per Superset instance — `EnvironmentConfig`)
```json
{
"connection_pool_size": 20,
"connection_pool_timeout": 30,
"request_timeout": 30,
"llm_timeout": 120
}
```
### App-Level Async Runtime Config (global — отдельная секция конфигурации приложения, не perenv)
```json
{
"db_executor_workers": 10,
"file_executor_workers": 10,
"git_executor_workers": 5,
"graceful_shutdown_timeout": 30
}
```
Эти параметры добавляются в модель `EnvironmentConfig` (Pydantic) и хранятся в БД как часть конфигурации окружения.
## 3. Unchanged Entities
Следующие сущности **не меняют** свою структуру, только способ взаимодействия с внешними сервисами:
| Entity | Type | Notes |
|--------|------|-------|
| `TranslationJob` | SQLAlchemy Model | Без изменений |
| `TranslationPreviewSession` | SQLAlchemy Model | Без изменений |
| `TranslationPreviewRecord` | SQLAlchemy Model | Без изменений |
| `TranslationPreviewLanguage` | SQLAlchemy Model | Без изменений |
| `Dictionary` | SQLAlchemy Model | Без изменений |
| `LLMProvider` | SQLAlchemy Model | Без изменений |
| `EnvironmentConfig` | SQLAlchemy Model | Добавлены pool-параметры (см. выше) |
| Все Pydantic request/response schemas | Pydantic | Без изменений (FR-018) |
| Все API routes (URL, method, status codes) | FastAPI Router | Без изменений |
| `PluginBase.execute()` contract | ABC | Уже `async def`, без изменений |
## 4. Task State Transitions (Unchanged)
```
PENDING → RUNNING → COMPLETED
→ FAILED
→ CANCELLED
```
Переходы состояний не меняются. Меняется только механизм запуска: `ThreadPoolExecutor.submit()``asyncio.create_task()`.
## 5. WebSocket Message Format (Unchanged)
```json
{
"type": "log",
"task_id": "uuid",
"timestamp": "ISO-8601",
"level": "INFO|WARNING|ERROR",
"message": "...",
"trace_id": "uuid"
}
```
Формат сообщений WebSocket не меняется. Меняется только транспорт отправки: `queue.Queue` + `call_soon_threadsafe``asyncio.Queue` + `await`.

View File

@@ -0,0 +1,96 @@
# Implementation Plan: Async Backend Refactoring
**Branch**: `032-translate-requests-httpx` | **Date**: 2026-06-04 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/032-translate-requests-httpx/spec.md`
## Summary
Полный рефакторинг backend на асинхронный неблокирующий ввод-вывод. Big-bang замена синхронного `requests.Session` на `httpx.AsyncClient` в центральном `APIClient`, слияние sync `SupersetClient` и `AsyncSupersetClient` в единый асинхронный клиент, перевод всех 13 миксинов SupersetClient на async, замена `time.sleep` на `asyncio.sleep` во всех поллинг-циклах, обёртка блокирующих операций БД и файлового I/O в `run_blocking` (bounded executors), полный переход Task Manager'а с `ThreadPoolExecutor` на `asyncio.create_task`. Фронтенд не меняется.
## Technical Context
**Language/Version**: Python 3.9+ (backend, canonical — ADR-0001 note: Python 3.13+ preferred but 3.9+ is the minimum supported), TypeScript (frontend Svelte 5 runes-only)
**Primary Dependencies**: FastAPI 0.126, SQLAlchemy, APScheduler 3.11, httpx 0.28 (already present), anyio 4.12 (already present)
**New Dependencies**: pytest-httpx (test mocking), aiofiles (optional — file I/O), aiosmtplib (optional — SMTP)
**Storage**: PostgreSQL 16 (unchanged — DB operations via asyncio.to_thread)
**Testing**: pytest (backend), pytest-httpx (new), pytest-asyncio (existing)
**Target Platform**: Linux server (Docker), single uvicorn worker
**Project Type**: web application backend (FastAPI REST + WebSocket)
**Performance Goals**: <100ms p95 для лёгких запросов во время тяжёлых операций; конкурентность 3+ тяжёлых запросов при 1 worker
**Constraints**: API-контракты неизменны; все тесты проходят; нулевые утечки соединений; обратная совместимость WebSocket-логирования
**Scale/Scope**: ~16 файлов, ~45 блокирующих вызовов, 1 uvicorn worker, 20 одновременных соединений к Superset (семафор)
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| # | Principle | Status | Justification |
|---|-----------|--------|---------------|
| I | Semantic Contract First | PASS | Все изменённые функции получат/обновят C3-C4 контракты. Новый `AsyncAPIClient` C4 с @PRE/@POST/@SIDE_EFFECT. |
| II | Decision Memory | PASS | ADR-0011 будет создан с записью rejected-путей (aiohttp, multiprocessing, async SQLAlchemy). @RATIONALE/@REJECTED в каждом C4+ контракте. |
| III | External Orchestrator | PASS | Топология не меняется. ss-tools остаётся внешним оркестратором над Superset. |
| IV | Module Discipline | PASS | `network.py` (542 строки) Tombstone. `async_network.py` (<400 строк) canonical async client, расширяется семафором. `SupersetAuthCache` уже выделен. |
| V | RBAC Enforcement | PASS | Проверки прав не затрагиваются. Ручки сохраняют `Depends(has_permission(...))`. |
| VI | Frontend Svelte 5 Runes Only | N/A | Фронтенд не меняется. |
| VII | Test-Driven for C3+ | PASS | Тесты на конкурентность (asyncio.gather + pytest_httpx) написаны для всех C3+ контрактов. |
## Project Structure
### Documentation (this feature)
```text
specs/032-translate-requests-httpx/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
│ └── modules.md # GRACE contracts for new/changed modules
└── tasks.md # Phase 2 output (/speckit.tasks)
```
### Source Code (repository root — files touched)
```text
backend/
├── src/
│ ├── core/
│ │ ├── utils/
│ │ │ ├── network.py → Tombstone (sync APIClient replaced)
│ │ │ └── async_network.py → extended: semaphore + pool config added
│ │ │ ├── fileio.py → async wrappers for file operations
│ │ │ └── superset_compilation_adapter.py → async migration
│ │ ├── superset_client/
│ │ │ ├── _base.py → merged with AsyncSupersetClient
│ │ │ ├── async_superset_client.py → absorbed into _base.py
│ │ │ └── _mixins/*.py → all 13 mixins become async
│ │ └── task_manager/
│ │ ├── manager.py → ThreadPoolExecutor → asyncio.create_task
│ │ ├── lifecycle.py → async lifecycle
│ │ └── event_bus.py → async WebSocket logging
│ ├── api/routes/
│ │ ├── dashboards/_detail_routes.py → async SupersetClient
│ │ └── translate/*.py → already async def, now truly non-blocking
│ ├── plugins/
│ │ ├── translate/
│ │ │ ├── _llm_http.py → httpx.AsyncClient
│ │ │ ├── preview_llm_client.py → httpx.AsyncClient
│ │ │ ├── superset_executor.py → async execute + asyncio.sleep
│ │ │ ├── _run_source.py → async chart data fetch
│ │ │ └── preview_executor.py → async sample row fetch
│ │ ├── backup.py → async SupersetClient + file I/O
│ │ ├── git_plugin.py → asyncio.to_thread for shutil + git
│ │ ├── llm_analysis/plugin.py → async dashboard logs fetch
│ │ └── storage/plugin.py → asyncio.to_thread for file ops
│ └── services/
│ ├── notifications/providers.py → aiosmtplib + httpx
│ └── git/_base.py → asyncio.to_thread for git/file ops
└── tests/ → pytest_httpx replaces requests_mock
```
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| `network.py` > 400 строк (542) | Центральный HTTP-клиент со всей логикой аутентификации, пагинации, загрузки файлов | `network.py` → Tombstone. `async_network.py` (уже существует, <400 строк) canonical async client. `SupersetAuthCache` уже выделен и переиспользуется обоими клиентами. |
| Task Manager рефакторинг | Переход с ThreadPoolExecutor на asyncio.create_task требует изменения lifecycle и event_bus | Оставить ThreadPoolExecutor rejected, т.к. задачи должны разделять event loop для конкурентности |

View File

@@ -0,0 +1,122 @@
# Quickstart: Async Backend Refactoring
**Feature**: `032-translate-requests-httpx`
**Date**: 2026-06-04
## Development Setup
```bash
# 1. Убедиться, что ветка активна
git branch --show-current # должен быть 032-translate-requests-httpx
# 2. Активировать venv и установить новые зависимости
cd backend
source .venv/bin/activate
pip install -r requirements.txt
# 3. Новые dev-зависимости для тестирования
pip install pytest-httpx pytest-asyncio
# 4. Проверить текущее состояние тестов (до рефакторинга)
python -m pytest -v --tb=short 2>&1 | tail -20
```
## Verification Commands
### Backend Tests (после каждого этапа рефакторинга)
```bash
cd backend && source .venv/bin/activate
# Все тесты
python -m pytest -v
# Только тесты затронутых модулей
python -m pytest src/plugins/translate/ src/core/ src/api/routes/ -v
# С coverage
python -m pytest --cov=src --cov-report=term-missing
# Конкретный тестовый файл
python -m pytest src/plugins/translate/__tests__/test_executor.py -v
python -m pytest src/core/utils/__tests__/test_network.py -v 2>/dev/null || echo "No tests yet"
```
### Линтинг
```bash
cd backend
python -m ruff check .
```
### Конкурентность (ручная проверка)
```bash
# Терминал 1: запустить долгую операцию
curl -X POST http://localhost:8000/translate/jobs/{job_id}/preview \
-H "Content-Type: application/json" \
-d '{"sample_size": 10}'
# Терминал 2 (немедленно, пока preview выполняется): проверить схему
curl -X POST http://localhost:8000/translate/check-target-schema \
-H "Content-Type: application/json" \
-d '{"environment_id": "...", "target_database_id": "...", "target_table": "..."}'
# Терминал 3 (немедленно): легковесный запрос
curl http://localhost:8000/health
```
### Проверка на утечки соединений
```bash
# После 500 запросов — проверить CLOSE_WAIT/TIME_WAIT:
ss -tnp | grep -c CLOSE_WAIT # должно быть 0
lsof -i -P -n | grep python | wc -l # должно быть стабильно
```
### Семантический аудит (после рефакторинга)
```bash
# Перестроить индекс
# (выполняется через Axiom MCP)
axiom_semantic_index rebuild rebuild_mode="full"
# Проверить контракты
axiom_semantic_validation audit_contracts
# Проверить belief-протокол
axiom_semantic_validation audit_belief_protocol
```
## Docker
```bash
# Полный стек
docker compose up --build
# Только backend (для быстрой итерации)
docker compose up --build backend db
```
## New Dependencies Checklist
- [ ] `pytest-httpx` добавлен в `requirements-dev.txt` или `pyproject.toml [dev]`
- [ ] `aiofiles` добавлен в `requirements.txt`
- [ ] `aiosmtplib` добавлен в `requirements.txt`
- [ ] `httpx` — уже есть (0.28.1), проверить совместимость версии
- [ ] Старые зависимости НЕ удаляются до завершения миграции всех тестов
## Порядок рефакторинга (Big-Bang)
Рекомендуемый порядок изменений в рамках одного PR:
1. **`core/utils/`** — AsyncAPIClient + SupersetClientAuth + SupersetClientPaginated (новые файлы)
2. **`core/superset_client/`** — миграция _base.py + 13 миксинов на async
3. **`plugins/translate/`** — LLMHttpClient (новый), миграция executor, preview, run source
4. **`api/routes/`** — замена sync SupersetClient на async в detail_routes, translate routes
5. **`core/task_manager/`** — TaskManager, EventBus, TaskLifecycle async
6. **`plugins/backup.py`, `plugins/git_plugin.py`, `plugins/storage/`** — миграция на async
7. **`services/notifications/`** — провайдеры async
8. **`services/git/`** — Git-операции через asyncio.to_thread
9. **Тесты** — замена requests_mock на pytest_httpx во всех затронутых тестовых файлах
10. **Удаление** — Tombstone старых контрактов, удаление неиспользуемого sync-кода

View File

@@ -0,0 +1,291 @@
# Research: Async Backend Refactoring
**Feature**: `032-translate-requests-httpx`
**Date**: 2026-06-04
**Status**: Complete
## 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` с authcache и 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` для мониторинга (postMVP).
### R4: Утечки ресурсов при `asyncio.to_thread`
**Вероятность**: Низкая | **Влияние**: Высокое
Поток в `to_thread` может зависнуть (сетевой вызов внутри). Приведёт к утечке потока из boundedexecutor.
**Митигация**: Boundedexecutor'ы с `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 |

View File

@@ -0,0 +1,211 @@
# 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` будет объединён с 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`-ручек, содержащих синхронные блокирующие вызовы, равно нулю (верифицируется статическим анализом).

View File

@@ -0,0 +1,260 @@
# Tasks: Async Backend Refactoring
**Input**: Design documents from `specs/032-translate-requests-httpx/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/modules.md ✅
**Tests**: Все C3+ контракты требуют тестов на конкурентное выполнение и rejected-path регрессии. Тесты пишутся первыми (fail before implementation).
**Organization**: Группировка по user stories (P1→P4), каждая story независимо реализуема и тестируема.
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Новые зависимости и конфигурация — подготовка среды перед рефакторингом.
- [ ] T001 Добавить `pytest-httpx` в dev-зависимости в `backend/pyproject.toml` (или `requirements-dev.txt`) для замены `requests_mock`
- [ ] T002 [P] Добавить `aiofiles` в `backend/requirements.txt` для асинхронного файлового I/O больших файлов
- [ ] T003 [P] Добавить `aiosmtplib` в `backend/requirements.txt` для асинхронной SMTP-отправки
- [ ] T004 Добавить поля `connection_pool_size` (default: 20), `connection_pool_timeout` (default: 30), `request_timeout` (default: 30), `llm_timeout` (default: 120) в Pydantic-схему `EnvironmentConfig` в `backend/src/schemas/environment.py`. Decision gate: если `EnvironmentConfig` хранится как JSON/Pydantic blob — `Field(default=...)` достаточен (defaults применяются при parse). Если поля — SQLколонки — создать Alembic-миграцию с `server_default`. Applevel конфигурация (`db_executor_workers` и т.д.) выносится отдельно — не в `EnvironmentConfig`.
**Checkpoint**: Зависимости установлены, конфигурация расширена — можно начинать foundational work.
---
## Phase 2: Foundational — AsyncAPIClient (extend) + SupersetClient merge (US1 foundation) ⚠️ BLOCKS ALL STORIES
**Purpose**: Расширение существующего `AsyncAPIClient` в `async_network.py` (добавление семафора, connection pool config) и слияние sync/async `SupersetClient` в единый асинхронный клиент. Без этого невозможна миграция плагинов.
**⚠️ CRITICAL**: Ни одна user story не может стартовать до завершения этой фазы.
**Факт из кодовой базы**: `AsyncAPIClient` [C:3] уже существует в `backend/src/core/utils/async_network.py` как часть `AsyncNetworkModule` [C:5]. Он использует `httpx.AsyncClient` с `SupersetAuthCache`. `SupersetAuthCache` (общий для sync/async клиентов) и `SupersetClientPaginated` уже выделены. Задача — **расширить**, а не создать с нуля.
### Tests for Foundational
- [ ] T005 [P] [US1] Написать контрактный тест для расширенного `AsyncAPIClient.request()` — проверка асинхронного HTTP с семафором, connection pool config — в `backend/tests/core/test_async_client.py` (RATIONALE: httpx.AsyncClient вместо requests.Session; REJECTED: requests_mock — не работает с httpx)
- [ ] T006 [P] [US1] Написать тест семафора: `test_semaphore_limits_concurrent_requests` и `test_semaphore_timeout_returns_503` — в `backend/tests/core/test_async_client.py` (@PRE: семафор создан; @POST: лимит соблюдён, таймаут возвращает ошибку)
### Implementation for Foundational
- [ ] T007 [US1] Расширить существующий `AsyncAPIClient` в `backend/src/core/utils/async_network.py`: добавить `asyncio.Semaphore(connection_pool_size)` с настраиваемым таймаутом, connection pool config (max_connections, keepalive), поддержку `connection_pool_size` и `connection_pool_timeout` из `EnvironmentConfig` (см. T004). (Факт: класс уже существует с auth-cache, httpx.AsyncClient, error translation; RATIONALE: расширение существующего кода вместо создания дубликата; REJECTED: создание нового файла async_client.py — дублирует существующий async_network.py)
- [ ] T008 [US1] Создать объединённый асинхронный `SupersetClient` в `backend/src/core/superset_client/_base.py` — замена sync-кода на расширенный `AsyncAPIClient`, все методы `async def`. Поглотить сигнатуры из `backend/src/core/superset_client/async_superset_client.py`. (RATIONALE: big-bang merge sync+async; REJECTED: dual-stack — отвергнут на clarification)
- [ ] T008a [US1] Реализовать `SupersetClientRegistry` в `backend/src/core/utils/client_registry.py` — синглтон-реестр долгоживущих клиентов по `env_id`. Хранит shared `httpx.AsyncClient` + shared `asyncio.Semaphore` + `asyncio.Lock` для auth refresh. Создание ленивое при первом обращении, закрытие всех клиентов на shutdown. (RATIONALE: глобальный perenv семафор невозможен без синглтона; REJECTED: perrequest клиенты — теряют connection pooling)
- [ ] T008b [P] [US1] Создать helper `run_blocking(kind, fn, *args, timeout)` в `backend/src/core/utils/executors.py` — обёртка над `loop.run_in_executor(named_executor, fn)`. Именованные boundedexecutor'ы: `db_executor`, `file_executor`, `git_executor` (max_workers из конфига). Все блокирующие операции в productionпутях используют `run_blocking(kind=..., fn=...)` вместо нативного `asyncio.to_thread`. (RATIONALE: `asyncio.to_thread` не принимает параметр executor — нужен `loop.run_in_executor`; REJECTED: asyncio.to_thread — default executor без backpressure)
**Checkpoint**: AsyncAPIClient расширен (семафор + pool config), SupersetClient merged. Тесты семафора проходят. Можно мигрировать миксины и плагины.
---
## Phase 3: User Story 1 — Миграция 13 миксинов SupersetClient и API-ручек (Priority: P1) 🎯
**Goal**: Все вызовы Superset API из любых ручек и плагинов становятся неблокирующими. Event loop не блокируется при запросах к Superset.
**Independent Test**: Три параллельных вызова `get_dashboards()`, `get_dataset_detail()`, `export_dashboard()` через систему — общее время ≈ max(t1, t2, t3).
### Implementation for US1 — Mixins
- [ ] T011 [P] [US1] Мигрировать `_dashboards.py` миксин в `backend/src/core/superset_client/_dashboards.py`: все методы → `async def`, @RELATION DEPENDS_ON -> [SupersetClient], @SIDE_EFFECT обновлён
- [ ] T012 [P] [US1] Мигрировать `_datasets.py` миксин в `backend/src/core/superset_client/_datasets.py`: все методы → `async def`
- [ ] T013 [P] [US1] Мигрировать `_charts.py` миксин в `backend/src/core/superset_client/_charts.py`: все методы → `async def`
- [ ] T014 [P] [US1] Мигрировать `_sql_lab.py` миксин в `backend/src/core/superset_client/_sql_lab.py`: все методы → `async def`
- [ ] T015 [P] [US1] Мигрировать `_database.py` миксин в `backend/src/core/superset_client/_database.py`: все методы → `async def`
- [ ] T016 [P] [US1] Мигрировать `_import_export.py` миксин в `backend/src/core/superset_client/_import_export.py`: все методы → `async def`
- [ ] T017 [P] [US1] Мигрировать оставшиеся 6 миксинов (`_thumbnail.py`, `_logs.py`, `_css.py`, `_annotation.py`, `_filter.py`, `_query_context.py`) в `backend/src/core/superset_client/_*.py`: `def``async def`. (Примечание: `_charts.py` уже мигрирован в T013)
### Implementation for US1 — API Routes
- [ ] T018 [US1] Обновить `backend/src/api/routes/dashboards/_detail_routes.py`: заменить `SupersetClient(env)` на `client = await SupersetClientRegistry.get_client(env_id)` в `get_dashboard_thumbnail()` и `get_dashboard_detail()`. Удалить синхронные `client.network.request()` вызовы. (Факт из кодовой базы: контракты декларируют `@RELATION CALLS -> [AsyncSupersetClient]`, но код использует sync `SupersetClient` — рефакторинг приводит код в соответствие с контрактом. RATIONALE: создание sync-клиента в async def запрещено FR-003; REJECTED: оставить sync — блокирует event loop)
- [ ] T019 [US1] Обновить все остальные ручки, использующие `SupersetClient`, в `backend/src/api/routes/`: найти через grep `SupersetClient(` в `async def`-ручках, заменить на асинхронный вызов. Включить: `_detail_routes.py`, routes миграций, dataset_review, clean_release
### Tests for US1
- [ ] T020 [US1] Конкурентный тест: `test_concurrent_superset_calls` — три параллельных `await asyncio.gather(get_dashboards(), get_dataset_detail(), export_dashboard())` при одном event loop — в `backend/tests/core/test_superset_client_async.py` (SC-001: время ≈ max, не sum)
- [ ] T021 [P] [US1] Тест rejected-path: `test_sync_superset_client_raises_in_async_context` — проверка, что создание sync `SupersetClient(env)` в async def вызывает ошибку/предупреждение — в `backend/tests/api/test_async_guard.py`
**Checkpoint**: Superset API полностью асинхронный. Все 13 миксинов и API-ручки мигрированы.
---
## Phase 4: User Story 2 — Translate Plugin Async (Priority: P2)
**Goal**: Предпросмотр перевода и проверка схемы выполняются конкурентно без взаимной блокировки. LLM-вызовы и поллинг SQL Lab не блокируют event loop.
**Independent Test**: POST `/translate/jobs/A/preview` одновременно с POST `/translate/check-target-schema`. Проверка схемы возвращает ответ до завершения предпросмотра (SC-001: < 3s).
### Implementation for US2
- [ ] T022 [US2] Создать унифицированный `LLMHttpClient` в `backend/src/plugins/translate/_llm_async_http.py` модуль [C:4] с `async def call_openai_compatible()` на `httpx.AsyncClient`, rate-limit backoff через `asyncio.sleep` (RATIONALE: объединение дублирующихся _llm_http.py и preview_llm_client.py; REJECTED: AsyncOpenAI SDK не поддерживает кастомные base_url для OpenRouter/LiteLLM/Kilo)
- [ ] T023 [US2] Обновить `backend/src/plugins/translate/_llm_call.py`: `call_llm_for_batch()` `async def`, вызов `LLMHttpClient.call_openai_compatible()` с `await`, `time.sleep` `asyncio.sleep` (RATIONALE: LLM-вызовы не должны блокировать другие запросы)
- [ ] T024 [US2] Мигрировать `backend/src/plugins/translate/preview_executor.py`: `fetch_sample_rows()` `async def` через `await superset_client.get_chart_data()`, `call_llm()` `async def` через `await LLMHttpClient.call_openai_compatible()` (RATIONALE: preview использует Superset chart data + LLM оба должны быть асинхронными)
- [ ] T025 [US2] Мигрировать `backend/src/plugins/translate/superset_executor.py`: `execute_sql()` и `poll_execution_status()` `async def`. `time.sleep(poll_interval)` `await asyncio.sleep(poll_interval)`. `client.network.request()` `await async_client.request()`. (Примечание: общий поллинг до 60×2s=120s; проверка схемы `check_target_schema` использует max_polls=15 параметры настраиваются вызывающим кодом) (RATIONALE: поллинг до 120s блокировал event loop; REJECTED: time.sleep блокирует)
- [ ] T026 [US2] Мигрировать `backend/src/plugins/translate/_run_source.py`: `fetch_source_rows()` `async def` через асинхронный `SupersetClient`
- [ ] T027 [US2] Обновить `backend/src/plugins/translate/preview.py`: `TranslationPreview.preview_rows()` `async def`, все внутренние вызовы с `await` (RATIONALE: preview C4 оркестратор, должен быть полностью асинхронным)
- [ ] T028 [US2] Обновить `backend/src/plugins/translate/executor.py`: `TranslationExecutor` все методы `async def`, `_load_preview_edits()` и `_run_service` `await`
### Tests for US2
- [ ] T029 [US2] Конкурентный тест: `test_preview_and_schema_check_concurrent` `asyncio.gather(preview, schema_check)`, проверить что schema_check завершается до preview в `backend/tests/plugins/translate/test_async_concurrency.py` (SC-001)
- [ ] T030 [US2] Тест rejected-path: `test_polling_uses_asyncio_sleep_not_time_sleep` статическая проверка, что `time.sleep` не вызывается в `superset_executor.py` в `backend/tests/plugins/translate/test_async_concurrency.py`
- [ ] T031 [P] [US2] Тест LLM rate-limit backoff: `test_llm_rate_limit_uses_asyncio_sleep` проверить, что 429 backoff не блокирует другие запросы в `backend/tests/plugins/translate/test_llm_async.py`
**Checkpoint**: Translate plugin полностью асинхронный. Preview + schema check конкурентны.
---
## Phase 5: User Story 3 — Все плагины Async (Priority: P3)
**Goal**: Backup, Git, LLM Analysis, Storage, Compilation Adapter все операции не блокируют event loop.
**Independent Test**: Бэкап 50 дашбордов и деплой из Git выполняются конкурентно.
### Implementation for US3
- [ ] T032 [P] [US3] Мигрировать `backend/src/plugins/backup.py`: `BackupPlugin.execute()` заменить `SupersetClient(env)` на `client = await SupersetClientRegistry.get_client(env_id)`, все вызовы `client.get_dashboards()`, `client.export_dashboard()` с `await`. Файловый I/O через `run_blocking(kind='file', fn=shutil.copy, ...)` для мелких файлов, `aiofiles` для больших ZIP (>1MB). (RATIONALE: гибридный подход; REJECTED: asyncio.to_thread — default executor без backpressure)
- [ ] T033 [P] [US3] Мигрировать `backend/src/plugins/git_plugin.py`: `_handle_sync()` и `_handle_deploy()``shutil` операции через `run_blocking(kind='file', fn=...)`, GitPython-клонирование через `run_blocking(kind='git', fn=...)`, SupersetClient → `SupersetClientRegistry.get_client(env_id)`. (RATIONALE: Gitклонирование до 30s — в bounded git_executor; REJECTED: оставить в event loop — блокирует)
- [ ] T034 [P] [US3] Мигрировать `backend/src/plugins/llm_analysis/plugin.py`: `_fetch_dashboard_logs()` — заменить `client.network.request(method="GET", endpoint="/log/")` на `await async_client.request(...)` (RATIONALE: sync-вызов внутри async def — прямой блокировщик event loop)
- [ ] T035 [P] [US3] Мигрировать `backend/src/plugins/storage/plugin.py`: `save_file()` — заменить `shutil.copyfileobj()` на `run_blocking(kind='file', fn=...)`; `delete_file()``shutil.rmtree` через `run_blocking(kind='file', fn=...)`
- [ ] T036 [US3] Мигрировать `backend/src/core/utils/superset_compilation_adapter.py`: `_request_superset_preview()` и `_request_sql_lab_session()` — заменить `client.network.request()` на асинхронные вызовы
- [ ] T037 [US3] Обновить `backend/src/core/utils/fileio.py`: добавить асинхронные обёртки `async def aread_dashboard_from_disk()`, `async def asave_and_unpack_dashboard()`, `async def acleanup_directory()` — гибрид `aiofiles` + `run_blocking(kind='file', fn=...)` (RATIONALE: см. R8 в research.md)
### Tests for US3
- [ ] T038 [US3] Конкурентный тест плагинов: `test_backup_and_deploy_concurrent``asyncio.gather(backup.execute(), git_deploy.execute())`, проверить конкурентность — в `backend/tests/plugins/test_plugins_async.py`
- [ ] T039 [P] [US3] Тест fileio: `test_async_file_operations_dont_block_event_loop` — запись/чтение больших файлов во время легковесного запроса — в `backend/tests/core/test_fileio_async.py`
**Checkpoint**: Все плагины асинхронные. Бэкап и деплой конкурентны.
---
## Phase 6: User Story 4 — Notifications + Task Manager + Services (Priority: P4)
**Goal**: Уведомления (SMTP, Telegram, Slack), Task Manager, EventBus, Git-сервисы — полностью асинхронные. ThreadPoolExecutor заменён на asyncio.create_task.
**Independent Test**: SMTP-отправка одновременно с API-запросом — запрос не задерживается.
### Implementation for US4
- [ ] T040 [US4] Мигрировать `backend/src/services/notifications/providers.py`: `SMTPProvider.send()``async def` с `aiosmtplib.SMTP` вместо `smtplib.SMTP`; `TelegramProvider.send()``async def` с `httpx.AsyncClient.post` вместо `requests.post`; `SlackProvider.send()` → аналогично (RATIONALE: aiosmtplib — зрелая async-замена smtplib; REJECTED: asyncio.to_thread для SMTP — работает, но aiosmtplib даёт нативную интеграцию)
- [ ] T041 [US4] Обновить `backend/src/services/notifications/service.py`: `NotificationService.dispatch_report()` — использовать `asyncio.gather` для параллельной отправки через все каналы
- [ ] T042 [US4] Мигрировать `backend/src/core/task_manager/manager.py`: `TaskManager` — заменить `ThreadPoolExecutor(max_workers=5)` на хранение активных `asyncio.Task` в `dict[str, asyncio.Task]`. `run_task()``async def`, запуск через `asyncio.create_task()`. `cancel_task()``async def` через `task.cancel()` (RATIONALE: задачи разделяют event loop; REJECTED: ThreadPoolExecutor — изоляция задач, усложняет отмену)
- [ ] T043 [US4] Мигрировать `backend/src/core/task_manager/event_bus.py`: `EventBus` — заменить `queue.Queue` + `call_soon_threadsafe` на `asyncio.Queue`. `publish()``async def`, `subscribe()``async def` async generator (RATIONALE: asyncio.Queue интегрируется с event loop без тредовой синхронизации; REJECTED: queue.Queue + call_soon_threadsafe — избыточно)
- [ ] T044 [US4] Мигрировать `backend/src/core/task_manager/lifecycle.py`: `TaskLifecycle.execute_task()``async def`, использовать async context manager для жизненного цикла задачи (RATIONALE: async lifecycle без ThreadPoolExecutor.submit())
- [ ] T045 [US4] Мигрировать `backend/src/services/git/_base.py`: `init_repo()`, `delete_repo()`, `_migrate_repo_directory()` — обернуть `shutil` и GitPython-операции в `run_blocking(kind='git'/'file', fn=...)`
### Tests for US4
- [ ] T046 [US4] Конкурентный тест Task Manager: `test_concurrent_tasks_dont_block` — запустить две задачи через asyncio.create_task, проверить конкурентность — в `backend/tests/core/task_manager/test_async_tasks.py`
- [ ] T047 [P] [US4] Тест уведомлений: `test_smtp_timeout_doesnt_block_api` — SMTP-таймаут не блокирует API-запрос — в `backend/tests/services/test_notifications_async.py`
- [ ] T048 [P] [US4] Тест EventBus: `test_async_eventbus_publish_subscribe` — проверить asyncio.Queue доставку — в `backend/tests/core/task_manager/test_eventbus_async.py`
**Checkpoint**: Task Manager и уведомления асинхронные. ThreadPoolExecutor удалён.
---
## Phase 7: Polish — Test Migration, Cleanup, Tombstones, ADR
**Purpose**: Замена requests_mock на pytest_httpx, удаление sync-кода, Tombstone старых контрактов, создание ADR, финальная верификация.
### Test Migration
- [ ] T049 [P] Заменить `requests_mock` на `httpx_mock` (pytest-httpx) во всех тестах модуля translate: `backend/src/plugins/translate/__tests__/test_executor.py`, `test_preview.py`, `test_target_schema.py`, `test_dialect_detection_orthogonal.py` — замена моков, сохранение assertions
- [ ] T050 [P] Заменить `requests_mock` на `httpx_mock` в тестах плагинов: `backend/src/plugins/__tests__/test_backup.py`, `test_git_plugin.py`, `test_storage_plugin.py` (или соответствующие пути)
- [ ] T051 [P] Заменить `requests_mock` на `httpx_mock` в тестах API: `backend/src/api/routes/__tests__/test_dashboard_routes.py`, `test_git_status_route.py` (или соответствующие пути)
- [ ] T052 [P] Заменить `requests_mock` на `httpx_mock` в тестах сервисов: `backend/src/services/__tests__/test_notifications.py`, `test_git_service.py` (или соответствующие пути)
- [ ] T053 [P] Заменить `requests_mock` на `httpx_mock` в тестах core: `backend/tests/core/test_superset_client.py` (или соответствующие пути)
- [ ] T054 Обновить `backend/conftest.py`: добавить глобальную фикстуру `httpx_mock` для pytest-httpx
### Cleanup & Tombstones
- [ ] T055 Пометить `APIClient` в `backend/src/core/utils/network.py` как Tombstone: @DEPRECATED 2026-06-04, @REPLACED_BY -> [AsyncAPIClient]. Удалить тело класса, оставить только заглушку-импорт для обратной совместимости (INV_6: не удалять контракт с входящими @RELATION)
- [ ] T056 Пометить `AsyncSupersetClient` в `backend/src/core/superset_client/async_superset_client.py` как Tombstone: @DEPRECATED, @REPLACED_BY -> [SupersetClient]. Удалить тело класса (INV_6)
- [ ] T057 Пометить `_llm_http.py` и `preview_llm_client.py` как Tombstone: @DEPRECATED, @REPLACED_BY -> [LLMHttpClient]. Удалить тела функций (INV_6)
- [ ] T058 Удалить неиспользуемый sync-код: `import requests` где больше не нужен; удалить `time.sleep` где заменён на `asyncio.sleep`; удалить `ThreadPoolExecutor` импорт из TaskManager
### ADR
- [ ] T059 Создать `docs/adr/ADR-0011-async-backend.md` — запись архитектурного решения о переходе на async-first backend: httpx.AsyncClient, big-bang миграция, asyncio.Semaphore, asyncio.create_task, rejected-пути (aiohttp, multiprocessing, async SQLAlchemy, dual-stack) (Constitution II: Decision Memory)
### Final Verification
- [ ] T060 Запустить все backend-тесты: `cd backend && source .venv/bin/activate && python -m pytest -v` — все тесты должны проходить (SC-005)
- [ ] T061 Запустить линтер: `cd backend && python -m ruff check .` — ноль ошибок
- [ ] T062 Проверить конкурентность вручную по quickstart.md: preview + schema check параллельно, backup + deploy параллельно
- [ ] T063 Проверить на утечки соединений: 500 последовательных запросов → `ss -tnp | grep CLOSE_WAIT` должен быть 0 (SC-006)
- [ ] T064 [P] Проверить cascade-отмену: `test_client_disconnect_cancels_external_calls` — HTTP-запросы к LLM и Superset отменяются при разрыве соединения клиентом (FR-017, SC-008) — ручная проверка или pytest с `asyncio.wait_for` + `CancelledError`
- [ ] T065 [P] Проверить CoT-логирование на всех асинхронных путях: `rg "logger\.(reason|reflect|explore)" backend/src/plugins/translate/ backend/src/core/task_manager/` — убедиться, что все C4/C5 контракты имеют belief-маркеры (FR-020)
- [ ] T066 [P] Статическая проверка SC-008: (a) `rg "SupersetClient\(" backend/src/api/routes/ --files-with-matches | xargs rg "async def"` — ни одна `async def` ручка не создаёт sync `SupersetClient`; (b) `rg "time\.sleep" backend/src/plugins/translate/ backend/src/core/` → только в неисполняемых путях или заменён на `asyncio.sleep`; (c) `rg "^[ ]*def execute\(self," backend/src/plugins/ --files-with-matches` → все плагины реализуют `async def execute`; (d) `rg "import requests\b" backend/src/api/ backend/src/plugins/ backend/src/services/ backend/src/core/` → ноль совпадений (кроме legacy/tombstone файлов)
- [ ] T066a [P] Инвентаризация DB-вызовов: найти все `session.query()`, `session.execute()`, `db.query()` в `backend/src/plugins/`, `backend/src/services/`, `backend/src/api/` — обернуть в `run_blocking(kind='db', fn=...)`. Проверить, что SQLAlchemy-сессии не пересекают границу потоков (FR-025)
- [ ] T066b [P] Найти и заменить все прямые вызовы `requests.post/get/put/delete` вне `APIClient`/`network.py` во всём `backend/src/` (кроме testфайлов): `rg "requests\.(post|get|put|delete|request)\(" backend/src/ --files-with-matches | grep -v test | grep -v network.py` — все совпадения должны быть заменены на `httpx.AsyncClient` или `asyncio.to_thread`
- [ ] T066c [P] Проверить интеграцию с APScheduler: все вызовы `TaskManager.run_task()` из scheduled jobs должны использовать `asyncio.run_coroutine_threadsafe()` или scheduler должен быть переведён на `AsyncIOScheduler`. Проверить `backend/src/core/scheduler.py`
- [ ] T067 Семантический аудит: `axiom_semantic_validation audit_contracts` — все контракты валидны, нет сиротских @RELATION
- [ ] T068 Семантический аудит belief-протокола: `axiom_semantic_validation audit_belief_protocol` — все C4/C5 контракты имеют @RATIONALE/@REJECTED
- [ ] T069 Перестроить семантический индекс: `axiom_semantic_index rebuild rebuild_mode="full"` — индекс актуален
---
## Dependencies & Execution Order
### Phase Dependencies
| Phase | Depends On | Blocks |
|-------|-----------|--------|
| Phase 1: Setup | — | — |
| Phase 2: Foundational (US1 foundation) | Phase 1 | **ВСЕ user stories** |
| Phase 3: US1 — SupersetClient | Phase 2 | — |
| Phase 4: US2 — Translate | Phase 2 + Phase 3 | — |
| Phase 5: US3 — Plugins | Phase 2 + Phase 3 | — |
| Phase 6: US4 — Notifications + TM | Phase 2 | — |
| Phase 7: Polish | Phase 3 + 4 + 5 + 6 | — |
### User Story Independence
- **US1 (P1)**: Зависит от Phase 2. Не зависит от US2/US3/US4. Может быть задеплоена независимо — все плагины уже могут использовать асинхронный SupersetClient.
- **US2 (P2)**: Зависит от US1 (SupersetClient должен быть готов). Не зависит от US3/US4.
- **US3 (P3)**: Зависит от US1. Не зависит от US2/US4. Плагины мигрируются независимо друг от друга (T032-T035 — параллельны).
- **US4 (P4)**: Зависит от Phase 2 (AsyncAPIClient). Не зависит от US1/US2/US3. Task Manager и уведомления независимы от плагинов.
### Within Each Phase
- **Все Txxx [P]** — могут выполняться параллельно (разные файлы)
- **Тесты пишутся ПЕРВЫМИ** в каждой фазе — должны FAIL до реализации
- **Implementation after tests** — последовательно или параллельно (по маркировке [P])
### Parallel Opportunities
- Phase 2: T005, T006 (тесты) — параллельно (разные test functions в одном файле)
- Phase 3: T011-T017 (13 миксинов) — все параллельно
- Phase 5: T032-T035 (4 плагина) — все параллельно
- Phase 6: T040 (notifications), T042 (TaskManager), T045 (git services) — параллельно
- Phase 7: T049-T053 (test migration) — все параллельно
---
## Implementation Strategy
### Internal Validation Checkpoint (US1 + US2)
1. Phase 1: Setup
2. Phase 2: Foundational (AsyncAPIClient + SupersetClient)
3. Phase 3: US1 — все миксины и ручки
4. Phase 4: US2 — Translate plugin
5. **STOP AND VALIDATE**: Preview + schema check конкурентны (не deployable milestone — internal checkpoint)
### Big-Bang Strategy (All Stories)
1. Phase 1 → Phase 2 → Phase 3 (US1) → Phase 4 (US2) → Phase 5 (US3) → Phase 6 (US4) → Phase 7
2. Все изменения в одном PR (big-bang)
3. Тесты проходят до и после каждого этапа
---
## Notes
- [P] tasks = разные файлы, без зависимостей — можно параллельно
- [USx] label = привязка к user story для трассируемости
- Каждая story проверяется независимо (T020, T029, T038, T046)
- Ручная проверка конкурентности обязательна (T062) — автоматические тесты могут не поймать все race conditions
- Big-bang: sync-код НЕ сохраняется для обратной совместимости
- Tombstone по INV_6: не удалять контракты с входящими @RELATION — помечать @DEPRECATED
- REJECTED пути защищены тестами: T021 (sync client в async def), T030 (time.sleep в поллинге)
- Для C4 контрактов: @RATIONALE и @REJECTED обязательны — проверяется T068

View File

@@ -0,0 +1,180 @@
# UX Reference: Async Backend Refactoring
**Feature Branch**: `032-translate-requests-httpx`
**Created**: 2026-06-04
**Status**: Draft
## 1. User Persona & Context
- **Who is the user?**:
- **Оператор ss-tools** — настраивает и запускает задачи (переводы, бэкапы, деплои, миграции). Может быть несколько операторов одновременно.
- **Администратор** — мониторит состояние системы, просматривает дашборды, управляет окружениями.
- **API-клиент** — внешние системы, вызывающие API ss-tools (например, CI/CD pipeline для деплоя).
- **What is their goal?**: Выполнять несколько операций параллельно без блокировок. Видеть отзывчивый интерфейс независимо от того, какие фоновые задачи выполняются.
- **Context**: Работа через веб-интерфейс SvelteKit SPA или напрямую через REST API. Один экземпляр сервера обслуживает нескольких пользователей.
## 2. The «Happy Path» Narratives
### Narrative A: Translate Setup (основная жалоба)
Оператор открывает переводческую задачу. На вкладке Preview нажимает «Run Preview» (sample_size=10). Видит спиннер. **Не дожидаясь**, переключается на Target Config → жмёт «Check Target Schema». Через 2 секунды — зелёная галочка. Возвращается на Preview — переводы уже загрузились. Всё заняло ~8 секунд параллельно вместо 11 последовательно.
### Narrative B: Multi-Operator (командная работа)
Оператор А запускает бэкап 100 дашбордов из продакшен-Superset. Оператор Б в это же время настраивает переводческую задачу — нажимает предпросмотр, проверяет схему. Администратор открывает список джоб. Все три пользователя работают без задержек — их запросы не стоят в очереди за бэкапом.
### Narrative C: CI/CD Deploy + Monitoring (API-клиент)
CI/CD пайплайн дёргает `POST /git/deploy` для деплоя дашборда в стейджинг. Одновременно администратор через UI проверяет статус дашбордов в LLM Analysis. Деплой (15 секунд на экспорт/импорт + Git-операции) и анализ (скриншоты + LLM) выполняются параллельно.
## 3. Interface Mockups
### API Interaction Flow — Multi-Plugin Concurrency
```
Клиент A (Backup) Сервер (1 worker) Клиент B (Translate) Клиент C (Dashboard)
│ │ │ │
│ POST /backup/run ───────►│ │ │
│ │ ┌─ Superset export ──┐ │ │
│ │ │ get_dashboards() │ │ │
│ │ │ (async, не блок.) │ │ │
│ │ └─────────────────────┘ │ │
│ │ │ │
│ │◄── POST /preview ────────│ │
│ │ │ │
│ │ ┌─ Superset chart data ┐│ │
│ │ │ LLM call (async) ││ │
│ │ └──────────────────────┘│ │
│ │ │ │
│ │ │◄── GET /dashboards ─────│
│ │ │ │
│ │ ┌─ Все три выполняются ┐│ │
│ │ │ конкурентно, ни один ││ │
│ │ │ не ждёт другой ││ │
│ │ └──────────────────────┘│ │
│ │ │ │
│◄── backup result ────────│ │ │
│ (100 dashboards OK) │ │ │
│ │◄── preview result ───────│ │
│ │ (10 rows + cost) │ │
│ │ │◄── dashboard list ──────│
```
### До vs После (тайминг при 1 worker)
```
ДО рефакторинга (sync, 1 worker):
┌────────────┬──────────────────┬───────────────┬──────────┐
│ Backup 45s │ Preview 8s │ SchemaCheck 3s│ GET /jobs│ = 56.5s total
└────────────┴──────────────────┴───────────────┴──────────┘
ПОСЛЕ рефакторинга (async, 1 worker):
┌──────────────────────────────────────────────────────────┐
│ Backup 45s (экспорт дашбордов, файловый I/O) │
│ ┌──────────────────┐ │
│ │ Preview 8s │ │
│ │ ┌──────────────┐ │
│ │ │SchemaCheck 3s│ │
│ │ └──────────────┘ │
│ └──────────────────┘ │
│ ┌── GET /jobs 50ms│
└────────────────────────────────────────┴─────────────────┘ = 45s total
```
### Существующие UI-компоненты (без изменений)
Контракты компонентов не меняются. Меняется только временная характеристика — компоненты больше не ждут друг друга:
| Компонент | Файл | Состояния (без изменений) | Что изменилось |
|-----------|------|---------------------------|----------------|
| TranslationPreview | `TranslationPreview.svelte` | idle → loading → preview_loaded / error | Может быть в loading одновременно с TargetSchemaHint в checking |
| TargetSchemaHint | `TargetSchemaHint.svelte` | idle → checking → complete / error | Не ждёт окончания preview |
| DashboardGrid | `DashboardGrid.svelte` | Загрузка списка | Не ждёт backup/git/deploy |
| BackupConfig | `BackupConfig.svelte` | Запуск бэкапа | Не блокирует другие вкладки |
| GitDeployPanel | Компонент деплоя | Деплой дашборда | Не блокирует просмотр дашбордов |
| StorageUpload | Загрузка файлов | Upload progress | Не блокирует фоновые задачи |
## 4. The «Error» Experience
### Scenario A: Таймаут Superset API при бэкапе
- **User Action**: Запущен бэкап 100 дашбордов. Superset начинает отвечать медленно.
- **System Response**:
- Конкретный вызов `export_dashboard(id)` получает таймаут (30 секунд).
- В отчёте бэкапа этот дашборд помечается как FAILED с причиной «timeout».
- Бэкап продолжается со следующим дашбордом.
- **Другие запросы не затронуты** — админ всё ещё может смотреть список дашбордов.
- **Recovery**: Повторный запуск бэкапа только для упавших дашбордов.
### Scenario B: Отмена запроса (браузер закрыт)
- **User Action**: Оператор запустил предпросмотр, закрыл вкладку.
- **System Response**:
- FastAPI обнаруживает `asyncio.CancelledError`.
- Активный HTTP-вызов к LLM отменяется (`httpx.AsyncClient` закрывает соединение).
- Поллинг SQL Lab прекращается.
- Если БД-транзакция не закоммичена — откатывается.
- **Recovery**: Ноль утечек. При следующем открытии страницы — состояние «idle».
### Scenario C: Сетевая ошибка LLM-провайдера
- **User Action**: Запущен предпросмотр, LLM-провайдер недоступен (DNS failure).
- **System Response**:
- Исключение только в запросе предпросмотра.
- UI показывает `preview_error`: «Preview failed: connection to LLM provider failed».
- Параллельный запрос проверки схемы (к Superset) выполняется нормально.
- **Recovery**: Кнопка «Retry Preview».
### Scenario D: Конкурентный deploy + backup одного дашборда
- **User Action**: CI/CD запустил деплой дашборда X. Оператор запустил бэкап всех дашбордов (включая X).
- **System Response**: Бэкап и деплой работают с разными версиями дашборда. Бэкап экспортирует текущую версию из Superset, деплой импортирует новую. Конфликта данных не возникает — каждый работает со своим снимком.
- **Recovery**: Не требуется. Операции идемпотентны.
### Scenario E: SMTP-сервер недоступен при отправке отчёта
- **User Action**: Задача завершилась, scheduler пытается отправить email-отчёт.
- **System Response**:
- `aiosmtplib` получает connection timeout (10 секунд).
- Уведомление не отправлено, ошибка залогирована.
- Основной API продолжает работать — пользователи не замечают проблему.
- **Recovery**: Отчёт доступен в UI. Администратор проверяет логи, чинит SMTP-сервер.
## 5. Tone & Voice
- **Style**: Технический, лаконичный. Аудитория — DevOps-инженеры и операторы данных.
- **Terminology**: «Предпросмотр» (Preview), «Бэкап» (Backup), «Деплой» (Deploy), «Поллинг» (Polling), «Event loop», «Конкурентность» (Concurrency), «Таймаут» (Timeout).
## 6. Backward Compatibility Contract
**Все публичные API-эндпоинты сохраняют обратную совместимость.** Меняется только транспортный слой и временные характеристики.
| Модуль | Endpoint | Method | Тело запроса | Тело ответа | Коды |
|--------|----------|--------|-------------|-------------|------|
| Translate | `/jobs/{id}/preview` | POST | Без изменений | Без изменений | 201, 400, 502 |
| Translate | `/check-target-schema` | POST | Без изменений | Без изменений | 200, 502, 503, 504 |
| Notifications | (scheduler, no public API) | — | Без изменений | Без изменений | — |
**Error Mapping (единый для всех эндпоинтов, взаимодействующих с Superset):**
| Условие | HTTP Status |
|---------|-------------|
| Pool acquire timeout (семафор исчерпан) | 503 Service Unavailable |
| Superset network unreachable / connection error | 502 Bad Gateway |
| Superset request timeout (upstream) | 504 Gateway Timeout |
| Superset auth failure после retry | 502 Bad Gateway |
| Superset rate-limit (429 от Superset) | 502 Bad Gateway (не раскрываем upstream 429) |
Единственное изменение — **временные характеристики**: лёгкие запросы больше не ждут тяжёлых.
## 7. Файловые операции (File I/O Contract)
Файловые операции, вызываемые из `async def`-ручек, должны быть неблокирующими:
| Операция | До рефакторинга | После рефакторинга |
|----------|-----------------|---------------------|
| Экспорт дашборда (ZIP до 50MB) | `shutil.copy` + `open(wb)` | `asyncio.to_thread(…)` или `aiofiles` |
| Сохранение загруженного файла | `shutil.copyfileobj` | `asyncio.to_thread(…)` или `aiofiles` |
| Чтение дашборда с диска | `path.read_bytes()` | `asyncio.to_thread(…)` |
| Git-клонирование репозитория | GitPython (sync) | `asyncio.to_thread(…)` |
| Удаление директории | `shutil.rmtree` | `asyncio.to_thread(…)` |