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

180 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# [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`,
хотя дашборды и виртуальные датасеты существуют.
### Корневые причины
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]