diff --git a/specs/032-translate-requests-httpx/data-model.md b/specs/032-translate-requests-httpx/data-model.md index 3cf314c1..4aabdcbf 100644 --- a/specs/032-translate-requests-httpx/data-model.md +++ b/specs/032-translate-requests-httpx/data-model.md @@ -2,6 +2,7 @@ **Feature**: `032-translate-requests-httpx` **Date**: 2026-06-04 +**Status**: ✅ Implemented ## Overview diff --git a/specs/032-translate-requests-httpx/plan.md b/specs/032-translate-requests-httpx/plan.md index f2b152c3..3ad5856d 100644 --- a/specs/032-translate-requests-httpx/plan.md +++ b/specs/032-translate-requests-httpx/plan.md @@ -1,9 +1,11 @@ # Implementation Plan: Async Backend Refactoring -**Branch**: `032-translate-requests-httpx` | **Date**: 2026-06-04 | **Spec**: [spec.md](./spec.md) +**Branch**: `032-translate-requests-httpx` | **Date**: 2026-06-04 | **Spec**: [spec.md](./spec.md) | **Status**: ✅ Implemented **Input**: Feature specification from `/specs/032-translate-requests-httpx/spec.md` -## Summary +## Status + +**Реализовано**. Полный рефакторинг backend на асинхронный неблокирующий ввод-вывод. Полный рефакторинг 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`. Фронтенд не меняется. diff --git a/specs/032-translate-requests-httpx/quickstart.md b/specs/032-translate-requests-httpx/quickstart.md index d262c329..5aa7ecc8 100644 --- a/specs/032-translate-requests-httpx/quickstart.md +++ b/specs/032-translate-requests-httpx/quickstart.md @@ -2,6 +2,7 @@ **Feature**: `032-translate-requests-httpx` **Date**: 2026-06-04 +**Status**: ✅ Implemented ## Development Setup diff --git a/specs/032-translate-requests-httpx/research.md b/specs/032-translate-requests-httpx/research.md index 1ee87693..86b63bb8 100644 --- a/specs/032-translate-requests-httpx/research.md +++ b/specs/032-translate-requests-httpx/research.md @@ -2,7 +2,7 @@ **Feature**: `032-translate-requests-httpx` **Date**: 2026-06-04 -**Status**: Complete +**Status**: ✅ Implemented — all decisions applied ## R1: APIClient Migration — requests.Session → httpx.AsyncClient diff --git a/specs/032-translate-requests-httpx/spec.md b/specs/032-translate-requests-httpx/spec.md index 25463c6d..70e01ce0 100644 --- a/specs/032-translate-requests-httpx/spec.md +++ b/specs/032-translate-requests-httpx/spec.md @@ -2,7 +2,7 @@ **Feature Branch**: `032-translate-requests-httpx` **Created**: 2026-06-04 -**Status**: Draft +**Status**: Implemented **Input**: Полный рефакторинг всех модулей backend на асинхронный неблокирующий ввод-вывод: замена синхронного `requests` на `httpx.AsyncClient`, асинхронное взаимодействие с БД, отказ от `time.sleep` в пользу `asyncio.sleep`, полная ликвидация блокировки event loop'а FastAPI при любых внешних вызовах. ## Problem Statement diff --git a/specs/032-translate-requests-httpx/tasks.md b/specs/032-translate-requests-httpx/tasks.md index b777ccfa..826cef0b 100644 --- a/specs/032-translate-requests-httpx/tasks.md +++ b/specs/032-translate-requests-httpx/tasks.md @@ -13,10 +13,10 @@ **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`. App‑level конфигурация (`db_executor_workers` и т.д.) выносится отдельно — не в `EnvironmentConfig`. +- [x] T001 Добавить `pytest-httpx` в dev-зависимости в `backend/pyproject.toml` (или `requirements-dev.txt`) для замены `requests_mock` +- [x] T002 [P] Добавить `aiofiles` в `backend/requirements.txt` для асинхронного файлового I/O больших файлов +- [x] T003 [P] Добавить `aiosmtplib` в `backend/requirements.txt` для асинхронной SMTP-отправки +- [x] 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`. App‑level конфигурация (`db_executor_workers` и т.д.) выносится отдельно — не в `EnvironmentConfig`. **Checkpoint**: Зависимости установлены, конфигурация расширена — можно начинать foundational work. @@ -32,15 +32,15 @@ ### 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: лимит соблюдён, таймаут возвращает ошибку) +- [x] 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) +- [x] 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: глобальный per‑env семафор невозможен без синглтона; REJECTED: per‑request клиенты — теряют 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)`. Именованные bounded‑executor'ы: `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) +- [x] 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) +- [x] 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) +- [x] T008a [US1] Реализовать `SupersetClientRegistry` в `backend/src/core/utils/client_registry.py` — синглтон-реестр долгоживущих клиентов по `env_id`. Хранит shared `httpx.AsyncClient` + shared `asyncio.Semaphore` + `asyncio.Lock` для auth refresh. Создание ленивое при первом обращении, закрытие всех клиентов на shutdown. (RATIONALE: глобальный per‑env семафор невозможен без синглтона; REJECTED: per‑request клиенты — теряют connection pooling) +- [x] T008b [P] [US1] Создать helper `run_blocking(kind, fn, *args, timeout)` в `backend/src/core/utils/executors.py` — обёртка над `loop.run_in_executor(named_executor, fn)`. Именованные bounded‑executor'ы: `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. Тесты семафора проходят. Можно мигрировать миксины и плагины. @@ -54,23 +54,23 @@ ### 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) +- [x] T011 [P] [US1] Мигрировать `_dashboards.py` миксин в `backend/src/core/superset_client/_dashboards.py`: все методы → `async def`, @RELATION DEPENDS_ON -> [SupersetClient], @SIDE_EFFECT обновлён +- [x] T012 [P] [US1] Мигрировать `_datasets.py` миксин в `backend/src/core/superset_client/_datasets.py`: все методы → `async def` +- [x] T013 [P] [US1] Мигрировать `_charts.py` миксин в `backend/src/core/superset_client/_charts.py`: все методы → `async def` +- [x] T014 [P] [US1] Мигрировать `_sql_lab.py` миксин в `backend/src/core/superset_client/_sql_lab.py`: все методы → `async def` +- [x] T015 [P] [US1] Мигрировать `_database.py` миксин в `backend/src/core/superset_client/_database.py`: все методы → `async def` +- [x] T016 [P] [US1] Мигрировать `_import_export.py` миксин в `backend/src/core/superset_client/_import_export.py`: все методы → `async def` +- [x] 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 +- [x] 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) +- [x] 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` +- [x] 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) +- [x] 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-ручки мигрированы. @@ -84,19 +84,19 @@ ### 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` +- [x] 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) +- [x] 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-вызовы не должны блокировать другие запросы) +- [x] 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 — оба должны быть асинхронными) +- [x] 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 — блокирует) +- [x] T026 [US2] Мигрировать `backend/src/plugins/translate/_run_source.py`: `fetch_source_rows()` → `async def` через асинхронный `SupersetClient` +- [x] T027 [US2] Обновить `backend/src/plugins/translate/preview.py`: `TranslationPreview.preview_rows()` → `async def`, все внутренние вызовы с `await` (RATIONALE: preview — C4 оркестратор, должен быть полностью асинхронным) +- [x] 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` +- [x] 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) +- [x] 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` +- [x] 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 конкурентны. @@ -110,17 +110,17 @@ ### 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) +- [x] 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) +- [x] 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 — блокирует) +- [x] 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) +- [x] 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=...)` +- [x] T036 [US3] Мигрировать `backend/src/core/utils/superset_compilation_adapter.py`: `_request_superset_preview()` и `_request_sql_lab_session()` — заменить `client.network.request()` на асинхронные вызовы +- [x] 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` +- [x] T038 [US3] Конкурентный тест плагинов: `test_backup_and_deploy_concurrent` — `asyncio.gather(backup.execute(), git_deploy.execute())`, проверить конкурентность — в `backend/tests/plugins/test_plugins_async.py` +- [x] T039 [P] [US3] Тест fileio: `test_async_file_operations_dont_block_event_loop` — запись/чтение больших файлов во время легковесного запроса — в `backend/tests/core/test_fileio_async.py` **Checkpoint**: Все плагины асинхронные. Бэкап и деплой конкурентны. @@ -134,18 +134,18 @@ ### 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=...)` +- [x] 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 даёт нативную интеграцию) +- [x] T041 [US4] Обновить `backend/src/services/notifications/service.py`: `NotificationService.dispatch_report()` — использовать `asyncio.gather` для параллельной отправки через все каналы +- [x] 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 — изоляция задач, усложняет отмену) +- [x] 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 — избыточно) +- [x] T044 [US4] Мигрировать `backend/src/core/task_manager/lifecycle.py`: `TaskLifecycle.execute_task()` → `async def`, использовать async context manager для жизненного цикла задачи (RATIONALE: async lifecycle без ThreadPoolExecutor.submit()) +- [x] 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` +- [x] T046 [US4] Конкурентный тест Task Manager: `test_concurrent_tasks_dont_block` — запустить две задачи через asyncio.create_task, проверить конкурентность — в `backend/tests/core/task_manager/test_async_tasks.py` +- [x] T047 [P] [US4] Тест уведомлений: `test_smtp_timeout_doesnt_block_api` — SMTP-таймаут не блокирует API-запрос — в `backend/tests/services/test_notifications_async.py` +- [x] T048 [P] [US4] Тест EventBus: `test_async_eventbus_publish_subscribe` — проверить asyncio.Queue доставку — в `backend/tests/core/task_manager/test_eventbus_async.py` **Checkpoint**: Task Manager и уведомления асинхронные. ThreadPoolExecutor удалён. @@ -157,39 +157,39 @@ ### 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 +- [x] 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 +- [x] T050 [P] Заменить `requests_mock` на `httpx_mock` в тестах плагинов: `backend/src/plugins/__tests__/test_backup.py`, `test_git_plugin.py`, `test_storage_plugin.py` (или соответствующие пути) +- [x] T051 [P] Заменить `requests_mock` на `httpx_mock` в тестах API: `backend/src/api/routes/__tests__/test_dashboard_routes.py`, `test_git_status_route.py` (или соответствующие пути) +- [x] T052 [P] Заменить `requests_mock` на `httpx_mock` в тестах сервисов: `backend/src/services/__tests__/test_notifications.py`, `test_git_service.py` (или соответствующие пути) +- [x] T053 [P] Заменить `requests_mock` на `httpx_mock` в тестах core: `backend/tests/core/test_superset_client.py` (или соответствующие пути) +- [x] 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 +- [x] T055 Пометить `APIClient` в `backend/src/core/utils/network.py` как Tombstone: @DEPRECATED 2026-06-04, @REPLACED_BY -> [AsyncAPIClient]. Удалить тело класса, оставить только заглушку-импорт для обратной совместимости (INV_6: не удалять контракт с входящими @RELATION) +- [x] T056 Пометить `AsyncSupersetClient` в `backend/src/core/superset_client/async_superset_client.py` как Tombstone: @DEPRECATED, @REPLACED_BY -> [SupersetClient]. Удалить тело класса (INV_6) +- [x] T057 Пометить `_llm_http.py` и `preview_llm_client.py` как Tombstone: @DEPRECATED, @REPLACED_BY -> [LLMHttpClient]. Удалить тела функций (INV_6) +- [x] 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) +- [x] 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"` — индекс актуален +- [x] T060 Запустить все backend-тесты: `cd backend && source .venv/bin/activate && python -m pytest -v` — все тесты должны проходить (SC-005) +- [x] T061 Запустить линтер: `cd backend && python -m ruff check .` — ноль ошибок +- [x] T062 Проверить конкурентность вручную по quickstart.md: preview + schema check параллельно, backup + deploy параллельно +- [x] T063 Проверить на утечки соединений: 500 последовательных запросов → `ss -tnp | grep CLOSE_WAIT` должен быть 0 (SC-006) +- [x] T064 [P] Проверить cascade-отмену: `test_client_disconnect_cancels_external_calls` — HTTP-запросы к LLM и Superset отменяются при разрыве соединения клиентом (FR-017, SC-008) — ручная проверка или pytest с `asyncio.wait_for` + `CancelledError` +- [x] T065 [P] Проверить CoT-логирование на всех асинхронных путях: `rg "logger\.(reason|reflect|explore)" backend/src/plugins/translate/ backend/src/core/task_manager/` — убедиться, что все C4/C5 контракты имеют belief-маркеры (FR-020) +- [x] 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 файлов) +- [x] 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) +- [x] 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` +- [x] T066c [P] Проверить интеграцию с APScheduler: все вызовы `TaskManager.run_task()` из scheduled jobs должны использовать `asyncio.run_coroutine_threadsafe()` или scheduler должен быть переведён на `AsyncIOScheduler`. Проверить `backend/src/core/scheduler.py` +- [x] T067 Семантический аудит: `axiom_semantic_validation audit_contracts` — все контракты валидны, нет сиротских @RELATION +- [x] T068 Семантический аудит belief-протокола: `axiom_semantic_validation audit_belief_protocol` — все C4/C5 контракты имеют @RATIONALE/@REJECTED +- [x] T069 Перестроить семантический индекс: `axiom_semantic_index rebuild rebuild_mode="full"` — индекс актуален --- diff --git a/specs/032-translate-requests-httpx/ux_reference.md b/specs/032-translate-requests-httpx/ux_reference.md index d2a9bf08..aa4ef652 100644 --- a/specs/032-translate-requests-httpx/ux_reference.md +++ b/specs/032-translate-requests-httpx/ux_reference.md @@ -2,7 +2,7 @@ **Feature Branch**: `032-translate-requests-httpx` **Created**: 2026-06-04 -**Status**: Draft +**Status**: ✅ Implemented — all scenarios verified ## 1. User Persona & Context