Files
ss-tools/docs/adr/ADR-0020-maintenance-virtual-dataset-discovery.md
busya 52e909a2da 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.
2026-08-03 22:21:00 +07:00

10 KiB
Raw Blame History

[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 datasetsFound 0 affected dashboardsNo 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]