# [DEF:Doc.Adr.ADR0020:ADR] # @STATUS ACCEPTED # @BRIEF Обнаружение виртуальных (SQL) датасетов в maintenance discovery: серверный фильтр `sql is_not_null` с клиентским fallback, и подъём не-2xx ответов в AsyncAPIClient.request. # @RELATION BINDS_TO -> [Services.DashboardScanner.MaintenanceDashboardScanner] # @RELATION BINDS_TO -> [Core.AsyncNetwork.AsyncNetworkModule] # @RATIONALE Maintenance discovery возвращал 0 затронутых дашбордов в реальном окружении, хотя дашборды существовали. Причина — виртуальные (SQL) датасеты не находились: фильтр по `is_sqllab_view` отклоняется Superset (колонка не входит в `search_columns`), а ошибка 400 молча превращалась в «Found 0 datasets», ломая весь fallback-механизм. ADR фиксирует корректный механизм обнаружения и исправление обработки HTTP-ошибок. # @REJECTED Серверный фильтр `is_sqllab_view eq True` для виртуальных датасетов — колонка отсутствует в `DatasetRestApi.search_columns` (superset/datasets/api.py), фильтр отклоняется HTTP 400; Superset использует для виртуальности фильтруемую колонку `sql`, а не `is_sqllab_view`. # @REJECTED Возвращать 4xx/5xx тело как обычные данные в AsyncAPIClient.request — маскирует ошибки как пустые результаты и отключает откат фильтрованный→полный скан. ## Контекст `services/maintenance/_dashboard_scanner.find_affected_dashboards()` ищет дашборды, чьи датасеты ссылаются на целевые таблицы (для наложения баннера техобслуживания). Существуют два типа датасетов Superset: - **Физические (table-based):** совпадают по `{schema}.{table_name}`. - **Виртуальные (SQL/SQL Lab):** задаются SQL-запросом, а не таблицей; целевые таблицы упоминаются внутри текста `sql`. Именно такие датасеты были затронуты в реальном сбое (например, `SELECT ... FROM dm_view.counterparty_td ...`). Сбоя симптом: `get_datasets` логирует `Found 0 datasets.` для обоих запросов, затем `Scanning 0 datasets` → `Found 0 affected dashboards` → `No matching dashboards found`, хотя дашборды и виртуальные датасеты существуют. ### Корневые причины 1. **`is_sqllab_view` не фильтруется.** В `superset/datasets/api.py` фильтруемые колонки (`search_columns`) содержат `id, uuid, database, editors, catalog, schema, sql, table_name, created_by, changed_by`. `is_sqllab_view` там нет — это вычисляемая/модельная колонка. Фильтр `{"col": "is_sqllab_view", ...}` отклоняется Superset с HTTP 400. 2. **`AsyncAPIClient.request` не вызывал `raise_for_status()`.** Для любых 4xx/5xx возвращалось тело ошибки как обычный dict без ключа `result`. `fetch_paginated_data` видел отсутствие `result` и возвращал `[]`, поэтому `get_datasets` сообщал «Found 0 datasets» вместо исключения. Из-за этого ветки `except Exception` (fallback на полный скан) были мёртвым кодом, а `_handle_http_error`/`_handle_network_error` не срабатывали. 3. Физический запрос `table_name in [...]` корректно возвращал 0 — целевые таблицы живут **внутри** виртуальных датасетов, а не как физические датасеты с такими `table_name`. --- ## Decision 1: Виртуальные датасеты — серверный фильтр `sql is_not_null` с клиентским fallback ### Как работает механизм Виртуальные датасеты Superset имеют непустой `sql` (физические — `NULL`). Колонка `sql` входит в `search_columns` и `list_columns`, поэтому она фильтруется и возвращается. `find_affected_dashboards()` ищет виртуальные датасеты в два тира: ``` Tier 1 (предпочтительно, маленький результат): GET /dataset/?q={"columns":["id","table_name","schema","sql"], "filters":[{"col":"sql","opr":"is_not_null","value":""}]} Tier 2 (fallback, клиентский скан), если Tier 1 отклонён/упал: GET /dataset/?q={"columns":["id","table_name","schema","sql"]} → оставить только ds с непустым (ds.get("sql") or "").strip() если и Tier 2 падает (например, pagination cap) → виртуальное совпадение пропускается, остаются только физические. ``` Затем: - физические + виртуальные датасеты дедуплицируются по `id`; - для каждого виртуального датасета таблицы извлекаются из `sql` (`sql_table_extractor.extract_tables_from_sql`) и сравниваются с целевыми `schema.table` (case-insensitive); - для совпавших датасетов затронутые дашборды получаются через `get_dataset_detail(...)["linked_dashboards"]`. ### Почему серверный фильтр, а не только клиентский скан Полный клиентский скан всех датасетов упирается в защиту пагинации (`MAX_PAGINATION_PAGES = 500`, `page_size = 100` → кап ~50k датасетов) в больших окружениях. Серверный `sql is_not_null` держит результат маленьким и сохраняет цель «scalable discovery». Клиентский скан остаётся безопасным fallback, когда оператор фильтра не поддержан (точная строка оператора FAB не гарантирована). ### Примечание по `is_sqllab_view` Хотя `is_sqllab_view` присутствует в `list_columns` (и, таким образом, возвращается в ответе list-эндпоинта в текущей версии Superset), он **не фильтруется**. Поэтому обнаружение виртуальности строится на `sql`, а не на флаге. Клиентская проверка `is_virtual` в цикле сопоставления уже опирается на непустой `sql`. ### Примечание по `value` в фильтре `value: None` в rison-фильтре невалидно (схема требует number/string/boolean/array), Superset отклоняет его HTTP 400. Для `is_not_null` значение игнорируется оператором, поэтому используется `value: ""` — валидная строка, которую `col.isnot(None)` не использует. Это позволяет Tier 1 работать (маленький серверный фильтр), а не каждый раз откатываться к полному клиентскому скану. --- ## Decision 2: AsyncAPIClient.request поднимает не-2xx ответы В `AsyncAPIClient.request()` (после блоков 401-retry и 502/503/504 → NetworkError, и после раннего возврата `raw_response=True`) добавлен вызов `response.raise_for_status()` перед `response.json()`. - 4xx/5xx теперь порождают `httpx.HTTPStatusError`, который перехватывается существующим `except httpx.HTTPStatusError → _handle_http_error` и маппится в `SupersetAPIError` / `PermissionDeniedError` / `AuthenticationError` / `DashboardNotFoundError` / `NetworkError`. - Это восстанавливает задуманные fallback-цепочки (фильтрованный → полный скан), которые ранее были неэффективны из-за тихого возврата тел ошибок. - Путь `raw_response=True` (экспорт ZIP) не затронут. --- ## Decision 3: sqlparse token-limit fallback при извлечении таблиц из SQL ### Проблема После починки обнаружения виртуальных датасетов runtime-сбой сместился в извлечение таблиц. `sql_table_extractor.extract_tables_from_sql_span()` вызывает `sqlparse.parse(sql_text)`; для SQL, у которого больше `MAX_GROUPING_TOKENS = 10000` токенов, sqlparse поднимает `SQLParseError: "Maximum number of tokens exceeded (10000)"`. В `find_affected_dashboards` вызов `extract_tables_from_sql(ds_sql)` в цикле по всем виртуальным датасетам **не был обёрнут в try/except**, поэтому один гигантский виртуальный датасет срывал всё обнаружение (`preview-dashboards` → 502, `start_maintenance` → discovery failed). Это и есть «капа слишком маленькая» из логов. ### Решение `extract_tables_from_sql_span()` оборачивает `sqlparse.parse` + обход токенов в `try/except`. При ошибке (в т.ч. token-limit) список строковых литералов остаётся пустым → `is_in_string()` возвращает False → сохраняются **все** regex-совпадения `schema.table`. Это best-effort сопоставление (допускает возможные false positives из строковых литералов) вместо жёсткого отказа всего скана. **Оптимизация:** для SQL длиннее `_SQLPARSE_SKIP_THRESHOLD = 30_000` символов sqlparse пропускается сразу — замеры показывают, что после ~25КБ типичного SQL 10000 токенов превышаются гарантированно, а неудачная попытка `sqlparse.parse` стоит ~1с на датасет. Пропуск экономит это время (для 212КБ датасета падение времени извлечения с ~1.2с до ~0.08с), не меняя результат (regex-fallback всё равно используется). На SQL до порога литеральное фильтрование сохраняется. ### Отклонённая альтернатива Поднимать `MAX_GROUPING_TOKENS` в `sqlparse` (монакий-патч или правка константы) — хрупко и влияет на производительность парсинга для всех датасетов. Обход только конкретного сбоя безопаснее. --- ## Последствия 1. **Виртуальные датасеты теперь обнаруживаются.** Физические + виртуальные совпадения объединяются и дедуплицируются по `id`; дашборды из `linked_dashboards` корректно попадают в результат. 2. **Fallback-механизм снова работает.** Отклонённый фильтр (400) теперь пробрасывается как исключение, а не как пустой результат, поэтому код может корректно перейти на полный/клиентский скан. Все callers через `AsyncAPIClient.request` больше не получают молча 4xx/5xx тело как данные. 3. **Большие окружения защищены.** Предпочтительный серверный фильтр `sql is_not_null` мал; при отсутствии поддержки оператора выполняется клиентский скан с защитой от pagination cap (best-effort: при капе виртуальное совпадение пропускается, физическое сохраняется). 4. **Гигантские виртуальные датасеты не валят discovery.** `extract_tables_from_sql_span` при token-limit sqlparse (10000) откатывается к regex-only, а не бросает исключение. Один большой виртуальный датасет больше не срывает `preview-dashboards`/`start_maintenance`. Покрыто тестами: `tests/test_sql_table_extractor.py::test_huge_sql_falls_back_to_regex_instead_of_raising` и `tests/services/maintenance/test_dashboard_scanner.py::test_huge_virtual_dataset_sql_does_not_fail_discovery`. 5. **Проверяемость.** Корневая причина подтверждена по исходникам Superset (`superset/datasets/api.py: search_columns` и `list_columns`; `is_sqllab_view` — модельная колонка в `connectors/sqla/models.py`) и по `sqlparse` (`MAX_GROUPING_TOKENS`). Механизм покрыт тестами в `backend/tests/services/maintenance/test_dashboard_scanner.py` (включая end-to-end с реальным `sql_table_extractor` и реальным SQL из продакшн-сценария), `backend/tests/test_sql_table_extractor.py` и `backend/tests/test_core/test_async_network.py`. # [/DEF:Doc.Adr.ADR0020:ADR]