fix(ptica): защита таймаутом теперь реально ограничивает время (#2464-C) #2927

Merged
bot-backend merged 1 commit from fix/2464-timeout-guard-blocks into main 2026-08-19 10:27:59 +00:00
Collaborator

Дефект

with ThreadPoolExecutor(max_workers=1) as pool:
    pool.submit(_fetch).result(timeout=_BASEMAP_TIMEOUT_S)

__exit__ зовёт shutdown(wait=True) — он ждёт, пока рабочий поток закончит сам.
Поэтому result(timeout=T) ограничивал только момент, когда мы перестаём ждать
значение; функция всё равно не возвращалась, пока висел внешний вызов.

Это защита, которая выглядит рабочей и ничего не защищает: except TimeoutError на месте,
лог пишется, fallback отрабатывает — и всё это после того, как зависание уже случилось
целиком.

Два места, один класс

Где Что обещано в докстроке Что было
report_maps._add_basemap «недоступный tile-сервер не подвесит воркер» генерация отчёта стояла столько, сколько стояло зависание
admin_scrape.queue_status «worst-case latency ≈ 600 ms even if no worker is reachable» ручка, которую фронт опрашивает по таймеру, висела вместе с брокером

Во втором случае особенно обидно: inspect уносили в поток ровно ради этого обещания.

Честная цена, названная в коде

shutdown(wait=False) оставляет зависший поток дорабатывать в фоне. Ограничивается
запрос, но не процесс: потоки ThreadPoolExecutor не-демоны и джойнятся в
atexit, так что остановка воркера всё ещё может подождать зависший фетч.

Это размен «висит генерация отчёта» → «висит один поток в фоне», а не полное устранение.
cancel_futures=True снимает только ещё не начатые задачи: начатый поток Python
прервать не умеет. Написал это прямо в комментариях у обоих мест, чтобы следующий читатель
не принял правку за большее, чем она есть.

Тесты меряют время, а не наличие обработчика

Обработчик был и раньше — поэтому проверять его наличие бессмысленно. Тесты подсовывают
виснущий внешний вызов и меряют, когда функция вернулась.

Против кода из main:

FAILED test_basemap_returns_within_its_own_timeout
FAILED test_queue_status_returns_when_broker_hangs
passed test_basemap_success_path_still_works      ← контроль
после правки: 3 passed

Зависание в тестах ограничено 3 секундами: тест обязан завершаться и на сломанном коде,
иначе красный прогон превращается в зависший.

  • pytest tests/test_2464c_timeout_guard_returns.py — 3 passed
  • pytest tests/services/exporters tests/api/v1 -k "map or report or scrape or admin"
    249 passed, 7 skipped
  • ruff check — clean

Что осталось из кластера C

photos.py:110 — сессия БД из пула держится весь синхронный внешний HTTP-фетч. Это
другой дефект (не про executor), и лечится он иначе — вынести фетч за пределы
владения сессией. Отдельным заходом, чтобы не смешивать два класса в одном PR.

Refs #2464

## Дефект ```python with ThreadPoolExecutor(max_workers=1) as pool: pool.submit(_fetch).result(timeout=_BASEMAP_TIMEOUT_S) ``` `__exit__` зовёт `shutdown(wait=True)` — он ждёт, пока рабочий поток закончит **сам**. Поэтому `result(timeout=T)` ограничивал только момент, когда мы перестаём ждать **значение**; функция всё равно не возвращалась, пока висел внешний вызов. Это защита, которая выглядит рабочей и ничего не защищает: `except TimeoutError` на месте, лог пишется, fallback отрабатывает — и всё это после того, как зависание уже случилось целиком. ## Два места, один класс | Где | Что обещано в докстроке | Что было | |---|---|---| | `report_maps._add_basemap` | «недоступный tile-сервер не подвесит воркер» | генерация отчёта стояла столько, сколько стояло зависание | | `admin_scrape.queue_status` | «worst-case latency ≈ 600 ms even if no worker is reachable» | ручка, которую фронт опрашивает по таймеру, висела вместе с брокером | Во втором случае особенно обидно: `inspect` уносили в поток **ровно ради** этого обещания. ## Честная цена, названная в коде `shutdown(wait=False)` оставляет зависший поток дорабатывать в фоне. Ограничивается **запрос**, но **не процесс**: потоки `ThreadPoolExecutor` не-демоны и джойнятся в `atexit`, так что остановка воркера всё ещё может подождать зависший фетч. Это размен «висит генерация отчёта» → «висит один поток в фоне», а не полное устранение. `cancel_futures=True` снимает только **ещё не начатые** задачи: начатый поток Python прервать не умеет. Написал это прямо в комментариях у обоих мест, чтобы следующий читатель не принял правку за большее, чем она есть. ## Тесты меряют время, а не наличие обработчика Обработчик был и раньше — поэтому проверять его наличие бессмысленно. Тесты подсовывают **виснущий** внешний вызов и меряют, когда функция вернулась. Против кода из main: ``` FAILED test_basemap_returns_within_its_own_timeout FAILED test_queue_status_returns_when_broker_hangs passed test_basemap_success_path_still_works ← контроль после правки: 3 passed ``` Зависание в тестах ограничено 3 секундами: тест обязан завершаться и на сломанном коде, иначе красный прогон превращается в зависший. - [x] `pytest tests/test_2464c_timeout_guard_returns.py` — 3 passed - [x] `pytest tests/services/exporters tests/api/v1 -k "map or report or scrape or admin"` — 249 passed, 7 skipped - [x] `ruff check` — clean ## Что осталось из кластера C `photos.py:110` — сессия БД из пула держится весь синхронный внешний HTTP-фетч. Это **другой** дефект (не про executor), и лечится он иначе — вынести фетч за пределы владения сессией. Отдельным заходом, чтобы не смешивать два класса в одном PR. Refs #2464
bot-backend added 1 commit 2026-08-19 10:07:51 +00:00
fix(ptica): защита таймаутом теперь реально ограничивает время
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m4s
CI / backend-tests (pull_request) Successful in 16m20s
fb52fc095c
`with ThreadPoolExecutor(...) as pool:` на выходе зовёт shutdown(wait=True) —
он ждёт, пока рабочий поток закончит сам. Поэтому future.result(timeout=T)
ограничивал только момент, когда мы перестаём ждать ЗНАЧЕНИЕ, а функция всё
равно не возвращалась, пока висел внешний вызов.

Два места, один класс:

- report_maps._add_basemap — докстрока обещает «недоступный tile-сервер не
  подвесит воркер», а генерация отчёта стояла столько, сколько стояло
  зависание;
- admin_scrape.queue_status — докстрока обещает «worst-case latency ≈ 600 ms
  even if no worker is reachable». Ради этого inspect и уносили в поток; ручку
  фронт опрашивает по таймеру, и висела она вместе с брокером.

В обоих случаях except-ветка существовала и выглядела рабочей — она просто
ничего не ограничивала. Поэтому тесты меряют ВРЕМЯ ВОЗВРАТА, а не наличие
обработчика.

ЧЕСТНАЯ ЦЕНА, названная в коде: shutdown(wait=False) оставляет зависший поток
дорабатывать в фоне. Ограничивается ЗАПРОС, но не процесс — потоки пула
не-демоны и джойнятся в atexit, так что остановка воркера всё ещё может
подождать зависший фетч. Это размен «висит генерация отчёта» → «висит один
поток в фоне», а не полное устранение. cancel_futures=True снимает только
ещё не начатые задачи: начатый поток Python прервать не умеет.

Тесты двусторонние: против main падают обе временные проверки, третья —
контроль «успешный путь по-прежнему даёт True» — зелёная с обеих сторон.
Зависание в тестах ограничено 3 секундами: тест обязан завершаться и на
сломанном коде, иначе красный прогон превращается в зависший.

Хунк форматирования — не мой: pre-commit ruff v0.7.4 против 0.15.12 (#2864).

Refs #2464
bot-backend merged commit e266f29d65 into main 2026-08-19 10:27:59 +00:00
bot-backend deleted branch fix/2464-timeout-guard-blocks 2026-08-19 10:28:00 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2927
No description provided.