Files
ss-tools/docs/adr/ADR-0020-maintenance-virtual-dataset-discovery.md
busya 4d282b43e2 perf(maintenance): skip sqlparse on oversized virtual-dataset SQL
sqlparse raises SQLParseError above MAX_GROUPING_TOKENS=10000 tokens
(~25KB of typical SQL). The try/except fallback already handled it, but paid
~1s per oversized SQL for a parse doomed to fail. Add _SQLPARSE_SKIP_THRESHOLD
(30k chars) to bypass sqlparse for oversized text (~15x faster, 1.2s->0.08s for
a 212KB SQL) while keeping literal filtering for SQL under the threshold.

Tests: oversized-SQL skip-threshold behavior.
2026-08-03 23:52:03 +07:00

14 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 из строковых литералов) вместо жёсткого отказа всего скана.

Оптимизация: для SQL длиннее _SQLPARSE_SKIP_THRESHOLD = 30_000 символов sqlparse пропускается сразу — замеры показывают, что после ~25КБ типичного SQL 10000 токенов превышаются гарантированно, а неудачная попытка sqlparse.parse стоит ~1с на датасет. Пропуск экономит это время (для 212КБ датасета падение времени извлечения с ~1.2с до ~0.08с), не меняя результат (regex-fallback всё равно используется). На SQL до порога литеральное фильтрование сохраняется.

Отклонённая альтернатива

Поднимать 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]