Files
ss-tools/docs/adr/ADR-0020-maintenance-virtual-dataset-discovery.md
busya 02a97bfc9c fix(maintenance): survive sqlparse token cap on huge virtual dataset SQL
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.
2026-08-03 23:48:01 +07:00

13 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":""}]}

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 (монакий-патч или правка константы) — хрупко и влияет на производительность парсинга для всех датасетов. Обход только конкретного сбоя безопаснее.


Последствия

  1. Виртуальные датасеты теперь обнаруживаются. Физические + виртуальные совпадения объединяются и дедуплицируются по id; дашборды из linked_dashboards корректно попадают в результат.

  2. Fallback-механизм снова работает. Отклонённый фильтр (400) теперь пробрасывается как исключение, а не как пустой результат, поэтому код может корректно перейти на полный/клиентский скан. Все callers через AsyncAPIClient.request больше не получают молча 4xx/5xx тело как данные.

  3. Большие окружения защищены. Предпочтительный серверный фильтр sql is_not_null мал; при отсутствии поддержки оператора выполняется клиентский скан с защитой от pagination cap (best-effort: при капе виртуальное совпадение пропускается, физическое сохраняется).

  4. Гигантские виртуальные датасеты не валят 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.

  5. Проверяемость. Корневая причина подтверждена по исходникам 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.

[/DEF:Doc.Adr.ADR0020:ADR]