fix(maintenance): discover virtual (SQL) datasets in dashboard scanner

Virtual (SQL) datasets were never matched, so maintenance discovery returned
0 affected dashboards. Two defects fixed:

- find_affected_dashboards filtered by is_sqllab_view, which is NOT a
  filterable column in Superset's dataset list API (absent from search_columns),
  so the query was rejected. Now discover virtual datasets via the filterable
  sql column: primary server-side 'sql is_not_null' filter with a client-side
  non-empty-sql scan as fallback (best-effort vs pagination cap), dedupe by id.

- AsyncAPIClient.request never called raise_for_status(), so rejected filters
  (HTTP 400) were returned as bodies without a 'result' key and surfaced as
  'Found 0 datasets', dead-coding the filtered->full-scan fallback. request()
  now raises on non-2xx via the existing error mapper.

Tests cover both virtual-scan tiers, all fallback paths, the raise behavior,
and an end-to-end match with the real sql_table_extractor on production SQL.
Documented in ADR-0020.
This commit is contained in:
2026-08-03 22:21:00 +07:00
parent 1e0dacaf1b
commit 52e909a2da
6 changed files with 352 additions and 23 deletions

View File

@@ -0,0 +1,129 @@
# [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":None}]}
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`.
---
## 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) не затронут.
---
## Последствия
1. **Виртуальные датасеты теперь обнаруживаются.** Физические + виртуальные совпадения
объединяются и дедуплицируются по `id`; дашборды из `linked_dashboards` корректно
попадают в результат.
2. **Fallback-механизм снова работает.** Отклонённый фильтр (400) теперь пробрасывается
как исключение, а не как пустой результат, поэтому код может корректно перейти на
полный/клиентский скан. Все callers через `AsyncAPIClient.request` больше не получают
молча 4xx/5xx тело как данные.
3. **Большие окружения защищены.** Предпочтительный серверный фильтр `sql is_not_null`
мал; при отсутствии поддержки оператора выполняется клиентский скан с защитой от
pagination cap (best-effort: при капе виртуальное совпадение пропускается, физическое
сохраняется).
4. **Проверяемость.** Корневая причина подтверждена по исходникам Superset
(`superset/datasets/api.py: search_columns` и `list_columns`; `is_sqllab_view`
модельная колонка в `connectors/sqla/models.py`). Механизм покрыт тестами в
`backend/tests/services/maintenance/test_dashboard_scanner.py` (включая end-to-end с
реальным `sql_table_extractor` и реальным SQL из продакшн-сценария) и
`backend/tests/test_core/test_async_network.py`.
# [/DEF:Doc.Adr.ADR0020:ADR]