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:
129
docs/adr/ADR-0020-maintenance-virtual-dataset-discovery.md
Normal file
129
docs/adr/ADR-0020-maintenance-virtual-dataset-discovery.md
Normal 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]
|
||||
Reference in New Issue
Block a user