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.
180 lines
14 KiB
Markdown
180 lines
14 KiB
Markdown
# [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]
|