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.
10 KiB
[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,
хотя дашборды и виртуальные датасеты существуют.
Корневые причины
-
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. -
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не срабатывали. -
Физический запрос
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) не затронут.
Последствия
-
Виртуальные датасеты теперь обнаруживаются. Физические + виртуальные совпадения объединяются и дедуплицируются по
id; дашборды изlinked_dashboardsкорректно попадают в результат. -
Fallback-механизм снова работает. Отклонённый фильтр (400) теперь пробрасывается как исключение, а не как пустой результат, поэтому код может корректно перейти на полный/клиентский скан. Все callers через
AsyncAPIClient.requestбольше не получают молча 4xx/5xx тело как данные. -
Большие окружения защищены. Предпочтительный серверный фильтр
sql is_not_nullмал; при отсутствии поддержки оператора выполняется клиентский скан с защитой от pagination cap (best-effort: при капе виртуальное совпадение пропускается, физическое сохраняется). -
Проверяемость. Корневая причина подтверждена по исходникам 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.