Discovery of virtual datasets now works, but a runtime blocker remained: any virtual dataset whose SQL exceeds sqlparse's MAX_GROUPING_TOKENS (10000 tokens) raised SQLParseError 'Maximum number of tokens exceeded (10000)' from extract_tables_from_sql_span, which is called unguarded in the scan loop — one oversized virtual dataset aborted the whole maintenance preview/start. - extract_tables_from_sql_span now wraps sqlparse.parse + token walk in try/except and falls back to regex-only extraction (keeping all schema.table matches) instead of raising, so huge SQL no longer fails the scan. - Tier-1 virtual filter uses value "" (not None) so the sql is_not_null filter passes Superset's rison schema instead of always falling back to a full scan. Tests: huge-SQL fallback (extractor) and huge-virtual-dataset scan resilience (scanner). ADR-0020 updated with Decision 3.
13 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":""}]}
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 из
строковых литералов) вместо жёсткого отказа всего скана.
Отклонённая альтернатива
Поднимать MAX_GROUPING_TOKENS в sqlparse (монакий-патч или правка константы) —
хрупко и влияет на производительность парсинга для всех датасетов. Обход только
конкретного сбоя безопаснее.
Последствия
-
Виртуальные датасеты теперь обнаруживаются. Физические + виртуальные совпадения объединяются и дедуплицируются по
id; дашборды изlinked_dashboardsкорректно попадают в результат. -
Fallback-механизм снова работает. Отклонённый фильтр (400) теперь пробрасывается как исключение, а не как пустой результат, поэтому код может корректно перейти на полный/клиентский скан. Все callers через
AsyncAPIClient.requestбольше не получают молча 4xx/5xx тело как данные. -
Большие окружения защищены. Предпочтительный серверный фильтр
sql is_not_nullмал; при отсутствии поддержки оператора выполняется клиентский скан с защитой от pagination cap (best-effort: при капе виртуальное совпадение пропускается, физическое сохраняется). -
Гигантские виртуальные датасеты не валят 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. -
Проверяемость. Корневая причина подтверждена по исходникам 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.