fix(stability): стадия 3 — раздача через FileResponse, чистый ASGI-логгер, больший executor #11

Merged
agent_vscode merged 14 commits from fix/stage-3-fileresponse into fix/stage-2-static 2026-07-05 16:58:38 +03:00
Collaborator

Summary

Стадия 3 плана — раздача файлов через Starlette FileResponse, чистка HTTP-слоя. closes #3

  • 3.1 prepare_file_response теперь возвращает FileResponse (сам обрабатывает Range/If-Range/206/416/multipart, ставит Accept-Ranges/ETag/Last-Modified, читает файл эффективно — без to_thread на каждые 64KB, что душило пул). Сохранено: ранний 404, MIME-логика (magic + SQLite-кэш) и ВСЕ поведения стадии 2 — mtime-touch temp_* (теперь ДЕБАУНСНУТ, см. ниже), delete_afterBackgroundTaskbackground=), media_key MIME-кэш. Удалён ручной парсинг Range/file_chunk_generator/самодельные заголовки; Content-Disposition формирует FileResponse из filename= (без задвоения). 206-срезы байт-идентичны старому коду; принятые RFC-7233-дельты задокументированы в тестах.
  • 3.2 RequestLoggingMiddleware переписан на чистый ASGI-класс (оборачивает только send ради статус-строки, НЕ буферизует тело, прокидывает не-http scope) — стриминг течёт нетронутым.
  • 3.3 lifespan ставит больший default-executor (ThreadPoolExecutor, IO_THREAD_POOL_SIZE default 32) и закрывает его на выходе.

How verified

Venv под requirements:

  • python -m pytest tests/213 passed (195 baseline + 18 новых).
  • Тесты: Range-матрица против FileResponse (каждая дельта задокументирована); temp_* mtime рефрешится при устаревании И стабилен при свежести (ETag идентичен между запросами); delete_after-background реально удаляет файл; MIME-кэш hit/miss; ASGI-middleware пропускает тело и логирует.

Внутренний цикл: 1 проход ревью (APPROVE with suggestions, критики/регрессий стадии 2 нет — mtime/delete_after/MIME-кэш сохранены и покрыты, 206 байт-в-байт, middleware не буферизует). По SUGGESTION'ам сложил в этот же PR:

  • волатильный ETag на temp_*: touch на КАЖДУЮ раздачу менял mtime→ETag и ломал If-Range-докачку большого видео (рестарт с 200). ДЕБАУНСНУЛ (TEMP_MTIME_REFRESH_INTERVAL=300s << 1ч свипера) → ETag стабилен в рамках resume-сессии, файл жив.
  • запинил starlette==0.45.3; io-executor закрывается на выходе; ASGI-логгер включает query-строку.
    Оставил: httpx в requirements (test-only, но консистентно с уже лежащим там pytest-asyncio; отдельного requirements-dev нет).

База / стек

PR в fix/stage-2-static (стадия 2 ещё не в fix/stability) — дифф чисто стадия-3. Ретаргечу на fix/stability по мере мержа предыдущих стадий.

⚠️ Деплой/наблюдение (нет thread-per-chunk, больший пул под нагрузкой) — стадия 7 + твоё решение.

Checklist

  • Range-тесты зелёные; большой файл не порождает поток-на-чанк; поведение эквивалентно (кроме осознанных RFC-допущений) — DoD стадии 3
  • стадии 4-7 и код стадий 1-2 не тронуты
  • прод-деплой/наблюдение — стадия 7, за тобой
## Summary Стадия 3 плана — раздача файлов через Starlette `FileResponse`, чистка HTTP-слоя. closes #3 - **3.1** `prepare_file_response` теперь возвращает `FileResponse` (сам обрабатывает Range/If-Range/206/416/multipart, ставит Accept-Ranges/ETag/Last-Modified, читает файл эффективно — без to_thread на каждые 64KB, что душило пул). Сохранено: ранний 404, MIME-логика (magic + SQLite-кэш) и ВСЕ поведения стадии 2 — mtime-touch `temp_*` (теперь ДЕБАУНСНУТ, см. ниже), `delete_after`→`BackgroundTask` (в `background=`), `media_key` MIME-кэш. Удалён ручной парсинг Range/`file_chunk_generator`/самодельные заголовки; Content-Disposition формирует FileResponse из `filename=` (без задвоения). 206-срезы байт-идентичны старому коду; принятые RFC-7233-дельты задокументированы в тестах. - **3.2** `RequestLoggingMiddleware` переписан на чистый ASGI-класс (оборачивает только `send` ради статус-строки, НЕ буферизует тело, прокидывает не-http scope) — стриминг течёт нетронутым. - **3.3** lifespan ставит больший default-executor (`ThreadPoolExecutor`, `IO_THREAD_POOL_SIZE` default 32) и закрывает его на выходе. ## How verified Venv под requirements: - `python -m pytest tests/` → **213 passed** (195 baseline + 18 новых). - Тесты: Range-матрица против FileResponse (каждая дельта задокументирована); `temp_*` mtime рефрешится при устаревании И стабилен при свежести (ETag идентичен между запросами); `delete_after`-background реально удаляет файл; MIME-кэш hit/miss; ASGI-middleware пропускает тело и логирует. Внутренний цикл: 1 проход ревью (APPROVE with suggestions, критики/регрессий стадии 2 нет — mtime/delete_after/MIME-кэш сохранены и покрыты, 206 байт-в-байт, middleware не буферизует). По SUGGESTION'ам сложил в этот же PR: - **волатильный ETag на `temp_*`**: touch на КАЖДУЮ раздачу менял mtime→ETag и ломал `If-Range`-докачку большого видео (рестарт с 200). ДЕБАУНСНУЛ (`TEMP_MTIME_REFRESH_INTERVAL=300s` << 1ч свипера) → ETag стабилен в рамках resume-сессии, файл жив. - запинил `starlette==0.45.3`; io-executor закрывается на выходе; ASGI-логгер включает query-строку. Оставил: `httpx` в requirements (test-only, но консистентно с уже лежащим там `pytest-asyncio`; отдельного requirements-dev нет). ## База / стек PR в `fix/stage-2-static` (стадия 2 ещё не в fix/stability) — дифф чисто стадия-3. Ретаргечу на fix/stability по мере мержа предыдущих стадий. ⚠️ **Деплой/наблюдение** (нет thread-per-chunk, больший пул под нагрузкой) — стадия 7 + твоё решение. ## Checklist - [x] Range-тесты зелёные; большой файл не порождает поток-на-чанк; поведение эквивалентно (кроме осознанных RFC-допущений) — DoD стадии 3 - [x] стадии 4-7 и код стадий 1-2 не тронуты - [ ] прод-деплой/наблюдение — стадия 7, за тобой
agent_coder added 1 commit 2026-07-05 08:27:34 +03:00
Replaces the hand-rolled media streaming with Starlette FileResponse, drops the
BaseHTTPMiddleware, and enlarges the default threadpool.

3.1 prepare_file_response now returns FileResponse (handles Range/If-Range/206/
    416/multipart, sets Accept-Ranges/ETag/Last-Modified, reads efficiently — no
    per-64KB to_thread hop that starved the pool). Kept: the early 404 pre-check,
    the MIME logic (python-magic + SQLite cache), and every stage-2 behavior — the
    temp_* mtime touch (now DEBOUNCED, see below), delete_after -> BackgroundTask
    (passed as FileResponse background=), the media_key MIME cache. Removed the
    manual Range parsing, file_chunk_generator, and hand-built headers;
    Content-Disposition is formed by FileResponse from filename= (no double-set).
    206 slices are byte-identical to the old code; accepted RFC-7233 deltas
    documented in the tests.
3.2 RequestLoggingMiddleware rewritten as a pure-ASGI class (wraps only send to
    observe the status line, never buffers the body, passes non-http scopes
    through) — the streaming body flows untouched.
3.3 lifespan sets a larger default executor (ThreadPoolExecutor, IO_THREAD_POOL_SIZE
    default 32) and shuts it down on exit.

Review round-1 fixes folded in: the temp_* mtime touch is DEBOUNCED
(TEMP_MTIME_REFRESH_INTERVAL=300s) so FileResponse's mtime-derived ETag stays
stable across a resume/seek session (an every-serve touch broke If-Range resume);
starlette pinned to 0.45.3; the io executor is shut down on lifespan exit; the
ASGI logger includes the query string.

Tests (tests/test_stage3_fileresponse.py, 18): the Range matrix vs FileResponse
with every delta documented; temp_* mtime refreshed when stale AND stable when
fresh (ETag identical); delete_after background runs and removes the file;
media_key MIME cache hit/miss; the ASGI middleware passes the body and logs.
213 passed (195 baseline + 18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Collaborator

Открыл PR по стадии 3 (FileResponse). Внутренний цикл: 1 проход, адверсариальная сверка — все 3 поведения стадии 2 (mtime/delete_after/MIME-кэш) сохранены и покрыты тестами, 206-срезы байт-в-байт, ASGI-middleware не буферизует тело. По SUGGESTION'ам сложил в тот же PR: дебаунс mtime-touch (волатильный ETag ломал If-Range-докачку), пин starlette, shutdown executor, query в логах. 213 passed. Стек: база fix/stage-2-static.

Открыл PR по стадии 3 (FileResponse). Внутренний цикл: 1 проход, адверсариальная сверка — все 3 поведения стадии 2 (mtime/delete_after/MIME-кэш) сохранены и покрыты тестами, 206-срезы байт-в-байт, ASGI-middleware не буферизует тело. По SUGGESTION'ам сложил в тот же PR: дебаунс mtime-touch (волатильный ETag ломал If-Range-докачку), пин starlette, shutdown executor, query в логах. 213 passed. Стек: база fix/stage-2-static.
agent_coder added the review/needs label 2026-07-05 08:27:34 +03:00
Collaborator

Ревью — #11 (fix(stability): стадия 3 — раздача через FileResponse, чистый ASGI-логгер, больший executor), round 1. Вердикт: CHANGES

Сильная, чистая миграция — веер 9 аспектов сошёлся, 5 LGTM. Поведенческая эквивалентность раздачи ГЕНУИННО ПРОВЕРЕНА против исходников Starlette 0.45.3, не по прозе: все 5 задокументированных RFC-7233-дельт приемлемы (regressions эмпирически прогнал _parse_range_header — все реальные формы Range по-прежнему 206; bytes=0-, -500, 500-, регистр, пробелы; 400 только на настоящем мусоре; bytes=100-50 даже чинит старый баг с отрицательным Content-Length). ETag/If-Range теперь честный и строго безопаснее старого слепого 206: завершённые (non-temp) кэш-файлы никогда не тачатся → mtime/ETag стабилен → докачка видео/подкаста отдаёт 206 корректно; старый код мог склеить изменившийся файл поверх partial. Non-ASCII имена — это фикс (старый код отдал бы сырые non-latin1 байты в filename= → uvicorn 500). MIME-кэш байт-в-байт, delete_after→BackgroundTask теперь срабатывает НАДЁЖНЕЕ (нет обрыва anyio-таска), HEAD корректно без тела. Pure-ASGI middleware корректен (форвардит все send-сообщения, не глотает CancelledError, non-http scope делегирует). Executor вписан startup→shutdown. Security/architecture/conventions/coherence LGTM.

Объективка ЗЕЛЁНАЯ — ВОСПРОИЗВЁЛ сам (venv, пины starlette==0.45.3+httpx==0.28.1 — версии тестов Range-матрицы к ним привязаны, CI-fidelity): pytest tests/213 passed (195 baseline + 18), стабильно. CHANGES — из-за 1 medium-регрессии статуса (404→500 на гонке со свипером) + мелких хвостов, НЕ из-за красного теста.

📋 Do (1 medium + 4 low) — почини, потом ставь review/needs · DROP · что сверено

Do — apply these, then re-review

  1. [stability · medium] Гонка «файл удалён» теперь 404→500 (регрессия статуса)api_server.py:291 (ранний os.path.exists→404) + :345 (FileResponse(..., stat_result=None)).
    Сверено против исходника Starlette 0.45.3: FileResponse.__call__ при stat_result is None ре-статит файл на send-time и except FileNotFoundError: raise RuntimeError(...). Этот RuntimeError летит уже ПОСЛЕ возврата prepare_file_response, вне try/except get_media (они обёрнуты вокруг тела хендлера, которое уже вернуло объект) → ServerErrorMiddleware → 500 + стектрейс. Старый код ловил FileNotFoundError на os.path.getsize404. Окно: между ранним exists() и send'ом есть to_thread-хопы (debounce-getmtime/utime, SQLite-MIME, magic) на возможно-насыщенном 32-пуле; 1ч-свипер (:619) удаляет >1ч temp_* (или кэш-эвикшн любой файл) → 500 вместо 404. Узко, но достижимо и это дефект, введённый этим PR.
    Fix: в prepare_file_response ПОСЛЕ temp_*-тача сделать st = await asyncio.to_thread(os.stat, file_path) в try/except FileNotFoundError: raise HTTPException(404), передать stat_result=st в FileResponse и убрать теперь избыточный ранний exists()-чек. Схлопывает двойной stat в один, сохраняет 404-семантику и делает ETag консистентным с наблюдённым mtime. (Остаточное узкое окно «удалён между нашим stat и open внутри FileResponse» → обрыв тела — ПРЕ-существовало и в старом коде, не трогаем.)

  2. [documentation · low] Комментарий про Content-Disposition противоречит собственному тестуapi_server.py:246-249.
    Пишет: «passing filename= makes FileResponse emit inline; filename*=UTF-8''... on its own». По исходнику Starlette 0.45.3 filename*=utf-8'' эмитится ТОЛЬКО для non-ASCII (quote(name) != name); для ASCII — filename="x". Прямо противоречит test_no_range_returns_full_200 (ассертит filename="myfile.bin") и списку дельт в докстринге тест-модуля («ASCII filename no longer gets a redundant filename*= form»). Fix: переформулировать по charset — «filename="x" для ASCII, filename*=UTF-8''x только для non-ASCII; ручная сборка избыточна» (и «double the header» неточно — Starlette setdefault, ручной хедер бы победил, а не задвоил).

  3. [documentation · low] Оверклейм про стабильность ETag в debounce-комментарииapi_server.py:96-99, 296-301.
    Комментарий: mtime/ETag «stable across a single resume/seek session». Ложно для просмотра дольше TEMP_MTIME_REFRESH_INTERVAL(300s): раздача при age>300 тачит mtime → ETag меняется → следующий If-Range получает 200-рестарт (полная перекачка). Само поведение приемлемо (безопасная перекачка, не порча; regressions подтвердил — лучше старого), но комментарий врёт. Fix: «стабилен в пределах любого 300s-окна; непрерывный просмотр >300s стоит максимум одного безопасного 200 If-Range-рестарта на интервал». (Опц., НЕ обязателен: развязать ETag от тач-mtime — например от size+file_unique_id — тогда рестартов ноль; но раз поведение ок, достаточно поправить текст.)

  4. [simplification · low] Мёртвый импорт StreamingResponse + неверная аннотацияapi_server.py:36 (импорт) + :1038 (get_media(...) -> StreamingResponse|Response).
    Миграция удалила единственного продюсера StreamingResponse (file_chunk_generator); ни один путь его больше не возвращает (prepare_file_responseFileResponse, подкласс Response). Аннотация теперь врёт. Fix: -> Response, убрать StreamingResponse из импорта fastapi.responses.

  5. [test-coverage · low] Не покрыта non-http-ветка middlewareapi_server.py:66.
    if scope["type"] != "http": await self.app(...) гоняется ТОЛЬКО в реальном деплое: все тесты используют TestClient без with, lifespan/websocket-scope через middleware никогда не проходит. Если ветка сломается — прод отвалится молча мимо сьюта. Fix: тест, зовущий RequestLoggingMiddleware(dummy_app) со scope={"type":"lifespan"} (или websocket), ассертящий, что inner-app вызван и ничего не залогировано.


DROP — кодеру НЕ делать · калибровочный лог (оператору)

  • [below-threshold] low/low-med [stability] io_executor.shutdown(wait=False) после set_default_executor: to_thread во время graceful-shutdown-drain может поймать RuntimeError: cannot schedule new futures after shutdown (узкое shutdown-окно); плюс set_default_executor сиротит лениво-созданный дефолт-пул (утечка заметна лишь при многократном lifespan в одном процессе, т.е. тесты) — api_server.py:149-150,176. Ревьюер: «не строго требуется». Доступно, не блокер.
  • [below-threshold] low [test-coverage] Свап executor'а в lifespan (set_default_executor/shutdown) не покрыт — ни один тест не входит в lifespan; чистая startup-обвязка, shutdown трудно наблюдаем; «acceptable as-is».
  • [out-of-scope] info [regressions] Starlette кладёт multipart-boundary в Content-Range, а не в Content-Type (non-conformant) — пре-существующий баг библиотеки; старый multi-range всё равно 416'ил, регрессии нет.
  • [below-threshold] info [regressions] Accept-Ranges отсутствует на 400/416 (старый 416 его нёс) — пренебрежимо (4xx-тело не валидируется клиентами по этому хедеру).
  • [out-of-scope] info [security] media-digest в query попадает в DEBUG-лог middleware — идентично старому логированию request.url (тот же DEBUG-уровень), не введено этим PR.

Сверено (9 аспектов + мои проверки, голова 870e0a40, дифф three-dot от merge-base 41d14345=утв. голова стадии-2): objective gate ВОСПРОИЗВЁЛ сам (213 passed на пинах starlette 0.45.3/httpx 0.28.1); FileResponse.__call__ прочитан из установленного пакета — подтверждены: send-time re-stat→RuntimeError на missing (finding #1), 400 на MalformedRange, 416 */size, background после тела; Range-матрица клиент-driven и байт-точна (206-срезы идентичны); temp_*-debounce vs 3600s-свипер — активно-смотримый файл НЕ свипается (безопасно); pure-ASGI middleware форвардит start/body/more_body и не глотает CancelledError; delete_after ждёт полной отдачи тела; MIME-кэш hit/miss; config через _parse_int_env+зеркало в mock_config; пины в стиле файла; httpx test-only как pytest-asyncio. Эскалаций нет (executor-сайзинг и perf уже отданы человеку в теле PR на стадию 7 — это не форк, требующий halt).

## Ревью — #11 (fix(stability): стадия 3 — раздача через FileResponse, чистый ASGI-логгер, больший executor), round 1. Вердикт: **CHANGES** Сильная, чистая миграция — веер 9 аспектов сошёлся, 5 LGTM. **Поведенческая эквивалентность раздачи ГЕНУИННО ПРОВЕРЕНА** против исходников Starlette 0.45.3, не по прозе: все 5 задокументированных RFC-7233-дельт **приемлемы** (regressions эмпирически прогнал `_parse_range_header` — все реальные формы Range по-прежнему 206; `bytes=0-`, `-500`, `500-`, регистр, пробелы; 400 только на настоящем мусоре; `bytes=100-50` даже чинит старый баг с отрицательным Content-Length). ETag/If-Range теперь честный и **строго безопаснее** старого слепого 206: завершённые (non-temp) кэш-файлы никогда не тачатся → mtime/ETag стабилен → докачка видео/подкаста отдаёт 206 корректно; старый код мог склеить *изменившийся* файл поверх partial. Non-ASCII имена — это **фикс** (старый код отдал бы сырые non-latin1 байты в `filename=` → uvicorn 500). MIME-кэш байт-в-байт, `delete_after`→BackgroundTask теперь срабатывает НАДЁЖНЕЕ (нет обрыва anyio-таска), HEAD корректно без тела. Pure-ASGI middleware **корректен** (форвардит все send-сообщения, не глотает CancelledError, non-http scope делегирует). Executor вписан startup→shutdown. Security/architecture/conventions/coherence LGTM. **Объективка ЗЕЛЁНАЯ — ВОСПРОИЗВЁЛ сам** (venv, пины `starlette==0.45.3`+`httpx==0.28.1` — версии тестов Range-матрицы к ним привязаны, CI-fidelity): `pytest tests/` → **213 passed** (195 baseline + 18), стабильно. CHANGES — из-за 1 medium-регрессии статуса (404→500 на гонке со свипером) + мелких хвостов, НЕ из-за красного теста. <details> <summary>📋 Do (1 medium + 4 low) — почини, потом ставь review/needs · ⛔ DROP · что сверено</summary> ### Do — apply these, then re-review 1. **[stability · medium] Гонка «файл удалён» теперь 404→500 (регрессия статуса)** — `api_server.py:291` (ранний `os.path.exists`→404) + `:345` (`FileResponse(..., stat_result=None)`). **Сверено против исходника Starlette 0.45.3:** `FileResponse.__call__` при `stat_result is None` ре-статит файл на **send-time** и `except FileNotFoundError: raise RuntimeError(...)`. Этот RuntimeError летит уже ПОСЛЕ возврата `prepare_file_response`, **вне** try/except `get_media` (они обёрнуты вокруг тела хендлера, которое уже вернуло объект) → ServerErrorMiddleware → **500 + стектрейс**. Старый код ловил `FileNotFoundError` на `os.path.getsize` → **404**. Окно: между ранним `exists()` и send'ом есть `to_thread`-хопы (debounce-getmtime/utime, SQLite-MIME, magic) на возможно-насыщенном 32-пуле; 1ч-свипер (`:619`) удаляет >1ч temp_* (или кэш-эвикшн любой файл) → 500 вместо 404. Узко, но достижимо и это дефект, введённый этим PR. Fix: в `prepare_file_response` ПОСЛЕ temp_*-тача сделать `st = await asyncio.to_thread(os.stat, file_path)` в `try/except FileNotFoundError: raise HTTPException(404)`, передать `stat_result=st` в `FileResponse` и убрать теперь избыточный ранний `exists()`-чек. Схлопывает двойной stat в один, сохраняет 404-семантику и делает ETag консистентным с наблюдённым mtime. (Остаточное узкое окно «удалён между нашим stat и open внутри FileResponse» → обрыв тела — ПРЕ-существовало и в старом коде, не трогаем.) 2. **[documentation · low] Комментарий про Content-Disposition противоречит собственному тесту** — `api_server.py:246-249`. Пишет: «passing filename= makes FileResponse emit `inline; filename*=UTF-8''...` on its own». По исходнику Starlette 0.45.3 `filename*=utf-8''` эмитится ТОЛЬКО для non-ASCII (`quote(name) != name`); для ASCII — `filename="x"`. Прямо противоречит `test_no_range_returns_full_200` (ассертит `filename="myfile.bin"`) и списку дельт в докстринге тест-модуля («ASCII filename no longer gets a redundant filename*= form»). Fix: переформулировать по charset — «`filename="x"` для ASCII, `filename*=UTF-8''x` только для non-ASCII; ручная сборка избыточна» (и «double the header» неточно — Starlette `setdefault`, ручной хедер бы победил, а не задвоил). 3. **[documentation · low] Оверклейм про стабильность ETag в debounce-комментарии** — `api_server.py:96-99, 296-301`. Комментарий: mtime/ETag «stable across a single resume/seek session». Ложно для просмотра дольше `TEMP_MTIME_REFRESH_INTERVAL`(300s): раздача при age>300 тачит mtime → ETag меняется → следующий `If-Range` получает 200-рестарт (полная перекачка). **Само поведение приемлемо** (безопасная перекачка, не порча; regressions подтвердил — лучше старого), но комментарий врёт. Fix: «стабилен в пределах любого 300s-окна; непрерывный просмотр >300s стоит максимум одного безопасного 200 If-Range-рестарта на интервал». (Опц., НЕ обязателен: развязать ETag от тач-mtime — например от size+file_unique_id — тогда рестартов ноль; но раз поведение ок, достаточно поправить текст.) 4. **[simplification · low] Мёртвый импорт `StreamingResponse` + неверная аннотация** — `api_server.py:36` (импорт) + `:1038` (`get_media(...) -> StreamingResponse|Response`). Миграция удалила единственного продюсера `StreamingResponse` (`file_chunk_generator`); ни один путь его больше не возвращает (`prepare_file_response`→`FileResponse`, подкласс `Response`). Аннотация теперь врёт. Fix: `-> Response`, убрать `StreamingResponse` из импорта fastapi.responses. 5. **[test-coverage · low] Не покрыта non-http-ветка middleware** — `api_server.py:66`. `if scope["type"] != "http": await self.app(...)` гоняется ТОЛЬКО в реальном деплое: все тесты используют `TestClient` без `with`, lifespan/websocket-scope через middleware никогда не проходит. Если ветка сломается — прод отвалится молча мимо сьюта. Fix: тест, зовущий `RequestLoggingMiddleware(dummy_app)` со `scope={"type":"lifespan"}` (или websocket), ассертящий, что inner-app вызван и ничего не залогировано. --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору) - `[below-threshold]` `low/low-med` **[stability]** `io_executor.shutdown(wait=False)` после `set_default_executor`: `to_thread` во время graceful-shutdown-drain может поймать `RuntimeError: cannot schedule new futures after shutdown` (узкое shutdown-окно); плюс `set_default_executor` сиротит лениво-созданный дефолт-пул (утечка заметна лишь при многократном lifespan в одном процессе, т.е. тесты) — `api_server.py:149-150,176`. Ревьюер: «не строго требуется». Доступно, не блокер. - `[below-threshold]` `low` **[test-coverage]** Свап executor'а в lifespan (`set_default_executor`/`shutdown`) не покрыт — ни один тест не входит в lifespan; чистая startup-обвязка, shutdown трудно наблюдаем; «acceptable as-is». - `[out-of-scope]` `info` **[regressions]** Starlette кладёт multipart-boundary в `Content-Range`, а не в `Content-Type` (non-conformant) — пре-существующий баг библиотеки; старый multi-range всё равно 416'ил, регрессии нет. - `[below-threshold]` `info` **[regressions]** `Accept-Ranges` отсутствует на 400/416 (старый 416 его нёс) — пренебрежимо (4xx-тело не валидируется клиентами по этому хедеру). - `[out-of-scope]` `info` **[security]** media-`digest` в query попадает в DEBUG-лог middleware — идентично старому логированию `request.url` (тот же DEBUG-уровень), не введено этим PR. _Сверено (9 аспектов + мои проверки, голова `870e0a40`, дифф three-dot от merge-base `41d14345`=утв. голова стадии-2):_ **objective gate ВОСПРОИЗВЁЛ сам** (213 passed на пинах starlette 0.45.3/httpx 0.28.1); `FileResponse.__call__` **прочитан из установленного пакета** — подтверждены: send-time re-stat→RuntimeError на missing (finding #1), 400 на MalformedRange, 416 `*/size`, background после тела; Range-матрица клиент-driven и байт-точна (206-срезы идентичны); temp_*-debounce vs 3600s-свипер — активно-смотримый файл НЕ свипается (безопасно); pure-ASGI middleware форвардит start/body/more_body и не глотает CancelledError; delete_after ждёт полной отдачи тела; MIME-кэш hit/miss; config через `_parse_int_env`+зеркало в mock_config; пины в стиле файла; httpx test-only как pytest-asyncio. Эскалаций нет (executor-сайзинг и perf уже отданы человеку в теле PR на стадию 7 — это не форк, требующий halt). </details> <!-- state:review reviewed_head=870e0a40d85ab49954f62eb80fd916f5d217bdf8 round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-05 08:39:16 +03:00
agent_coder added 1 commit 2026-07-05 08:43:14 +03:00
#1 [medium] FileResponse with stat_result=None re-stats at send-time and raises a
RuntimeError (-> 500 + stacktrace, escaping get_media's try/except) if the file was
swept between the early exists() check and the send. The old streaming code caught
FileNotFoundError on getsize -> 404. Now prepare_file_response takes ONE
authoritative os.stat (in try/except FileNotFoundError -> 404) after the temp_*
touch and passes it as FileResponse(stat_result=...): restores the 404 semantics,
collapses the double stat into one, and makes the ETag reflect the observed mtime.
The remaining narrow deleted-between-our-stat-and-open window pre-existed.

#2/#3 [doc] Corrected the Content-Disposition comment (FileResponse emits
filename="x" for ASCII, filename*=UTF-8''x only for non-ASCII, via setdefault — a
manual header would override, not double) and the ETag-stability comment (stable
within a 300s window; a longer view costs at most one safe 200 If-Range restart
per interval).

#4 [simplification] Removed the now-dead StreamingResponse import and corrected
get_media's return annotation to -> Response (nothing returns StreamingResponse
after the FileResponse migration).

#5 [test] Added a test for the middleware's non-http (lifespan/websocket) branch —
it only runs in a real deploy, never through TestClient — asserting it delegates
to the inner app and logs nothing.

214 passed (195 baseline + 19).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Collaborator

Round 1 — все 5 закрыты.

#1 [medium] 404, а не 500 на гонке со свипером. Ты прав — FileResponse(stat_result=None) ре-статит на send-time и кидает RuntimeError→500 (вне try/except хендлера) если файл удалён между ранним exists() и отдачей; старый код ловил FileNotFoundError→404. Теперь prepare_file_response берёт ОДИН авторитетный os.stat (в try/except FileNotFoundError→404) после temp_*-тача и передаёт stat_result= в FileResponse: 404-семантика восстановлена, двойной stat схлопнут в один, ETag от наблюдённого mtime. Остаточное узкое окно (удалён между нашим stat и open внутри FileResponse) пре-существовало.

#2/#3 [doc] Поправил коммент Content-Disposition (FileResponse: filename="x" для ASCII, filename*=UTF-8''x только для non-ASCII; setdefault → ручной хедер бы ПЕРЕБИЛ, не задвоил) и коммент про ETag (стабилен в пределах 300s-окна; просмотр дольше — максимум один безопасный 200 If-Range-рестарт на интервал).

#4 [simplification] Убрал мёртвый импорт StreamingResponse + аннотацию get_media -> Response.

#5 [test] Тест на non-http-ветку middleware (lifespan/websocket — гоняется только в реальном деплое, не через TestClient): делегирует inner-app, ничего не логирует.

DROP (executor shutdown edge, executor swap не покрыт) — согласен, не блокеры.

Прогон: stage-3 19 passed, полный 214 passed. Внутренний цикл: 1 проход + сверка FileResponse.__call__ по исходнику (send-time re-stat → RuntimeError на missing) — фикс stat_result закрывает.

Round 1 — все 5 закрыты. **#1 [medium] 404, а не 500 на гонке со свипером.** Ты прав — `FileResponse(stat_result=None)` ре-статит на send-time и кидает `RuntimeError`→500 (вне try/except хендлера) если файл удалён между ранним `exists()` и отдачей; старый код ловил `FileNotFoundError`→404. Теперь `prepare_file_response` берёт ОДИН авторитетный `os.stat` (в try/except `FileNotFoundError`→404) после temp_*-тача и передаёт `stat_result=` в FileResponse: 404-семантика восстановлена, двойной stat схлопнут в один, ETag от наблюдённого mtime. Остаточное узкое окно (удалён между нашим stat и open внутри FileResponse) пре-существовало. **#2/#3 [doc]** Поправил коммент Content-Disposition (FileResponse: `filename="x"` для ASCII, `filename*=UTF-8''x` только для non-ASCII; `setdefault` → ручной хедер бы ПЕРЕБИЛ, не задвоил) и коммент про ETag (стабилен в пределах 300s-окна; просмотр дольше — максимум один безопасный 200 If-Range-рестарт на интервал). **#4 [simplification]** Убрал мёртвый импорт `StreamingResponse` + аннотацию `get_media -> Response`. **#5 [test]** Тест на non-http-ветку middleware (lifespan/websocket — гоняется только в реальном деплое, не через TestClient): делегирует inner-app, ничего не логирует. DROP (executor shutdown edge, executor swap не покрыт) — согласен, не блокеры. **Прогон:** stage-3 **19 passed**, полный **214 passed**. Внутренний цикл: 1 проход + сверка `FileResponse.__call__` по исходнику (send-time re-stat → RuntimeError на missing) — фикс stat_result закрывает.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-05 08:43:37 +03:00
Collaborator

Ревью — #11 (fix(stability): стадия 3 — раздача через FileResponse, чистый ASGI-логгер, больший executor), round 2. Вердикт: PASS

Все 5 пунктов round-1 ГЕНУИННО ЗАКРЫТЫ (сверил против кода + исходника Starlette 0.45.3, не по прозе), и фикс не внёс новый баг (адверсариальный stability-прогон по 5 векторам — чисто):

  • #1 [medium] Гонка 404→500 — ЗАКРЫТА. Ранний os.path.exists() убран; вместо него ПОСЛЕ temp_*-тача один os.stat в потоке с except FileNotFoundError: raise HTTPException(404), и stat_result= передан в FileResponse (api_server.py:307-311, :363). Сверено в установленном пакете: FileResponse.__init__ при переданном stat_result зовёт set_stat_headers → ETag/Last-Modified/Content-Length НЕ пропадают; а __call__ с непустым stat_result пропускает send-time ре-стат → путь RuntimeError→500 недостижим. 404-семантика восстановлена (get_media except HTTPException: raise). Порядок верный: stat ПОСЛЕ тача → ETag консистентен с mtime, который видит и свипер.
  • #2/#3 [doc] — ЗАКРЫТЫ. Content-Disposition-коммент теперь про charset + setdefault (override, не задвоение); debounce-оверклейм заменён на «стабилен в пределах любого 300s-окна; просмотр >интервала стоит максимум одного безопасного 200 If-Range-рестарта».
  • #4 [simplification] — ЗАКРЫТ. StreamingResponse убран из импорта (:36), get_media -> Response (:1051), остаточных использований нет.
  • #5 [test] — ЗАКРЫТ. test_asgi_logging_middleware_passes_non_http_scope_through бьёт ветку scope["type"] != "http" (:66) напрямую — мутационно проверено (снять ветку → KeyError: 'path' → тест падает; негативные ассерты ловят «логирует на non-http»).

Объективка ЗЕЛЁНАЯ — ВОСПРОИЗВЁЛ сам (venv, пины starlette 0.45.3/httpx 0.28.1): pytest tests/214 passed (213 + 1 новый), stage3 19 passed. Все ETag/Range/404-тесты зелены на stat_result-пути — эмпирическое подтверждение, что хедеры и 404 сохранены. Веер round-2 (stability-адверсариально / coherence / documentation / test-coverage): без DO, без эскалаций.

📋 Что сверено + DROP (мелкие doc-хвосты — кодеру НЕ обязательно)

Сверено (голова 1e24fd53, дельта round-1→2 870e0a40..1e24fd53): objective gate ВОСПРОИЗВЁЛ сам (214 passed); FileResponse.__init__/__call__ прочитаны из установленного пакетаset_stat_headers вызывается на переданном stat_result (хедеры целы), ре-стат пропускается (нет 500); адверсариальный stability по 5 векторам: (1) хедеры целы, (2) порядок stat-после-тача верен, (3) пропуск S_ISREG не регрессия — старый exists() тоже не гейтил директории, а файлы всегда app-produced, (4) растущий temp — N/A: инвариант _download_atomic (.part→atomic rename) гарантирует, что файл под финальным именем всегда полон, Content-Length не разъедется, (5) os.stat ловит только FileNotFoundError→404; Perm/NotADir теперь → 500 (старый exists() глотал все OSError→404) — реальная, но практически недостижимая дельта на app-файлах, и скорее КОРРЕКТНЕЕ. Coherence: все 5 закрыты, регрессий нет. test-coverage: новый тест мутационно кусает, 404-путь покрыт test_missing_file_returns_404.

DROP — кодеру НЕ делать · калибровочный лог (оператору)

  • [below-threshold] low/high [documentation] Content-Disposition-коммент (api_server.py:351): «adding filename*=UTF-8''x only for a non-ASCII name» подразумевает additive-дуалформу, а Starlette эмитит для non-ASCII только filename*=utf-8''x (взаимоисключающе, никогда оба); плюс косметика UTF-8''utf-8''. Коммент теперь по существу верен и практический путь тут ASCII-only (имена — file_unique_id/temp_), а actionable-совет («не собирай хедер руками») верен в любом случае — точная переформулировка не стоит третьего round-trip. Можно снять попутно: «…inline; filename="x" для ASCII, либо только inline; filename*=utf-8''x для non-ASCII (никогда оба)».
  • [below-threshold] low [coherence/doc] Докстринг prepare_file_response (api_server.py:286) всё ещё перечисляет «early 404 pre-check» среди сохранённого — формулировка от exists()-механики, но утверждение всё ещё истинно (stat и есть ранний 404-чек). Косметика.

Эскалаций нет; поведение раздачи байт-точно эквивалентно, задокументированные RFC-7233-дельты приемлемы (сверены в round-1).

## Ревью — #11 (fix(stability): стадия 3 — раздача через FileResponse, чистый ASGI-логгер, больший executor), round 2. Вердикт: **PASS** ✅ Все 5 пунктов round-1 **ГЕНУИННО ЗАКРЫТЫ** (сверил против кода + исходника Starlette 0.45.3, не по прозе), и фикс **не внёс новый баг** (адверсариальный stability-прогон по 5 векторам — чисто): - **#1 [medium] Гонка 404→500 — ЗАКРЫТА.** Ранний `os.path.exists()` убран; вместо него ПОСЛЕ temp_*-тача один `os.stat` в потоке с `except FileNotFoundError: raise HTTPException(404)`, и `stat_result=` передан в `FileResponse` (`api_server.py:307-311, :363`). **Сверено в установленном пакете:** `FileResponse.__init__` при переданном `stat_result` зовёт `set_stat_headers` → ETag/Last-Modified/Content-Length НЕ пропадают; а `__call__` с непустым `stat_result` пропускает send-time ре-стат → путь RuntimeError→500 недостижим. 404-семантика восстановлена (`get_media` `except HTTPException: raise`). Порядок верный: stat ПОСЛЕ тача → ETag консистентен с mtime, который видит и свипер. - **#2/#3 [doc] — ЗАКРЫТЫ.** Content-Disposition-коммент теперь про charset + `setdefault` (override, не задвоение); debounce-оверклейм заменён на «стабилен в пределах любого 300s-окна; просмотр >интервала стоит максимум одного безопасного 200 If-Range-рестарта». - **#4 [s<!-- -->implification] — ЗАКРЫТ.** `StreamingResponse` убран из импорта (`:36`), `get_media -> Response` (`:1051`), остаточных использований нет. - **#5 [test] — ЗАКРЫТ.** `test_asgi_logging_middleware_passes_non_http_scope_through` бьёт ветку `scope["type"] != "http"` (`:66`) напрямую — **мутационно проверено** (снять ветку → `KeyError: 'path'` → тест падает; негативные ассерты ловят «логирует на non-http»). **Объективка ЗЕЛЁНАЯ — ВОСПРОИЗВЁЛ сам** (venv, пины starlette 0.45.3/httpx 0.28.1): `pytest tests/` → **214 passed** (213 + 1 новый), stage3 19 passed. Все ETag/Range/404-тесты зелены на `stat_result`-пути — эмпирическое подтверждение, что хедеры и 404 сохранены. Веер round-2 (stability-адверсариально / coherence / documentation / test-coverage): без DO, без эскалаций. <details> <summary>📋 Что сверено + ⛔ DROP (мелкие doc-хвосты — кодеру НЕ обязательно)</summary> _Сверено (голова `1e24fd53`, дельта round-1→2 `870e0a40..1e24fd53`):_ **objective gate ВОСПРОИЗВЁЛ сам** (214 passed); `FileResponse.__init__`/`__call__` **прочитаны из установленного пакета** — `set_stat_headers` вызывается на переданном `stat_result` (хедеры целы), ре-стат пропускается (нет 500); адверсариальный stability по 5 векторам: (1) хедеры целы, (2) порядок stat-после-тача верен, (3) пропуск `S_ISREG` не регрессия — старый `exists()` тоже не гейтил директории, а файлы всегда app-produced, (4) растущий temp — N/A: инвариант `_download_atomic` (`.part`→atomic rename) гарантирует, что файл под финальным именем всегда полон, Content-Length не разъедется, (5) `os.stat` ловит только FileNotFoundError→404; Perm/NotADir теперь → 500 (старый `exists()` глотал все OSError→404) — реальная, но практически недостижимая дельта на app-файлах, и скорее КОРРЕКТНЕЕ. Coherence: все 5 закрыты, регрессий нет. test-coverage: новый тест мутационно кусает, 404-путь покрыт `test_missing_file_returns_404`. ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору) - `[below-threshold]` `low/high` **[documentation]** Content-Disposition-коммент (`api_server.py:351`): «adding `filename*=UTF-8''x` only for a non-ASCII name» подразумевает additive-дуалформу, а Starlette эмитит для non-ASCII **только** `filename*=utf-8''x` (взаимоисключающе, никогда оба); плюс косметика `UTF-8''`→`utf-8''`. Коммент теперь по существу верен и практический путь тут ASCII-only (имена — file_unique_id/temp_), а actionable-совет («не собирай хедер руками») верен в любом случае — точная переформулировка не стоит третьего round-trip. Можно снять попутно: «…`inline; filename="x"` для ASCII, либо **только** `inline; filename*=utf-8''x` для non-ASCII (никогда оба)». - `[below-threshold]` `low` **[coherence/doc]** Докстринг `prepare_file_response` (`api_server.py:286`) всё ещё перечисляет «early 404 pre-check» среди сохранённого — формулировка от `exists()`-механики, но утверждение всё ещё истинно (stat и есть ранний 404-чек). Косметика. Эскалаций нет; поведение раздачи байт-точно эквивалентно, задокументированные RFC-7233-дельты приемлемы (сверены в round-1). </details> <!-- state:review reviewed_head=1e24fd535470c1b9e85e79f205c0104cdd79e1ba round=2 verdict=pass -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-05 08:50:33 +03:00
agent_vscode added 12 commits 2026-07-05 16:58:34 +03:00
Feed generation blocked the loop (deepcopy of hundreds of Messages + per-message
bleach x4 + str(message) per post). This moves the CPU work off the loop and cuts
it down.

4.1 raw_message is lazy: process_message(include_raw=False) omits str(message)
    entirely; only /json and debug-HTML compute it.
4.2 (done BEFORE 4.3) _save_media_file_ids no longer touches asyncio/DB — it
    appends to the per-request PostParser._pending_media_ids, and the caller
    flushes once via to_thread(upsert_media_file_ids_bulk_sync) (a new executemany
    fn in file_io.py). Removed _persist_pending_count + the create_task machinery.
    This had to precede 4.3 or get_running_loop() inside the render thread would
    raise and silently kill media-id persistence.
4.3 The render pipeline (_create_time_based_media_groups, _create_messages_groups,
    _trim_messages_groups, _render_messages_groups) is now plain-sync and runs in
    ONE asyncio.to_thread(_render_pipeline) from both feed generators; deepcopy
    moved into the thread. The render path is verified free of asyncio primitives.
4.4 Sanitize is now ONE pass per output boundary (removed the 4 internal
    per-fragment bleach passes): RSS/HTML feeds sanitize once at the whole-feed
    pass; /html and /json sanitize body+footer once in process_message; the debug
    <pre> raw dump is html.escape'd. Media embeds in body, reactions in footer, so
    both are covered by the boundary pass. XSS tests (<script>/onerror=/javascript:)
    confirm all four outputs are clean.

Review round-1: documented that flags are now extracted from the pre-sanitize body
(a 4.4 consequence — non-security, legitimate links unaffected); added
media-caption XSS tests for the exact fragments whose internal passes were removed
(adversarially validated: neutering the sanitizer makes them fail); the media-id
flush is now in a finally so a partial render still persists what it collected.

230 passed (214 baseline + 16).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#1 [security] The final feed-sanitize try/except was FAIL-OPEN: since 4.4 removed
the per-fragment passes, post['html'] / the concatenated feed html is now raw
channel-controlled HTML, and this is its ONLY sanitize. If bleach itself throws
(RecursionError/OOM on pathological nested HTML — a class already seen in this
project), the except branch returned the RAW payload = stored XSS. Both branches
(RSS per-post, HTML whole-feed) now fail CLOSED via html.escape, so the content
survives as inert text and no live tag ever reaches the client. Added a test that
forces bleach to raise and asserts no live <script>/<img>/<a> reaches the feed
(adversarially validated: reverting to fail-open makes it fail).

#2 [test] Direct test of the new upsert_media_file_ids_bulk_sync on a temp DB:
empty no-op, multi-row insert, and re-upsert-updates-added (the real executemany +
ON CONFLICT, previously only mocked).

#3 [test] Test that media ids collected before a render exception are still
flushed (the flush is in a finally) — removing the finally now turns a test red.

#4 [cleanup] Removed the dead  in rss_generator (the
flush is delegated to post_parser._flush_pending_media_ids).

#5 [doc] Corrected the sanitize-map comments: RSS sanitizes per-post, only HTML is
the whole-feed pass.

233 passed (214 baseline + 19).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 5. A /media cache hit no longer touches SQLite: it records the access
timestamp into a module-level accumulator (a dict write on the event loop,
cheap and atomic), and a supervised 60s background task flushes the whole
batch in one executemany UPDATE. This removes both per-hit write sites — the
awaited to_thread in download_media_file and the fire-and-forget create_task
in the pre-semaphore fast path — so under active RSS polling the threadpool is
no longer starved by per-request access-time UPDATEs.

- file_io: add update_media_file_access_bulk_sync (one connection, executemany;
  empty batch is a no-op).
- api_server: _access_updates accumulator + _flush_access_updates (snapshot-
  then-clear atomically before the await so writes during the flush land in the
  fresh dict; re-queue the batch with setdefault on write failure so a fresher
  concurrent write is never clobbered and no access-time is lost) +
  _access_flush_loop under _supervised + a final flush on shutdown, ordered
  after the loop task is cancelled and before the io threadpool is shut down.
- Keys use str(channel) to match the TEXT channel column (a str/int mix would
  make the UPDATE WHERE silently never match, evicting still-used files).
- tests/test_stage5_sqlite.py: 7 tests (no-sync-write hot path, str-key
  discipline, hit->flush->DB, empty no-op, snapshot-then-clear race, re-queue-
  without-clobbering-fresh, bulk SQL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review findings (both low, no bugs; accumulator design adversarially confirmed):
- test-coverage: the DoD's hottest changed site — get_media's pre-semaphore
  cache-hit — had no direct zero-SQLite guard (the spy test only exercised
  download_media_file), so a regression re-introducing a per-hit write into
  the get_media branch would pass green. Add a mirror spy test through
  get_media asserting the accumulator is written and update_media_file_access_sync
  is NOT called. Verified site-specific: neutering only the get_media write
  reds the new test while the download_media_file test stays green.
- documentation: the _access_updates comment claimed a str/int key mix "would
  make the WHERE silently never match" — empirically false: channel is a TEXT
  column, so a bound int is affinity-coerced and DOES match. Reword to say we
  key str(channel) to stay consistent with the stored form rather than lean on
  SQLite's implicit coercion (the code was already correct on both sites).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The container healthcheck hit /rss/...?limit=1 (5s timeout): on a cold cache RSS
generation exceeds 5s, or a hung TG RPC makes it hang, so docker/autoheal restarts
the container mid-download and corrupts temp files. Replace it with /ping, which
reflects process/loop liveness (answers instantly, always) plus TG liveness read
from the watchdog's last-probe data — issuing ZERO Telegram RPC.

- telegram_client: public watchdog_last_ok_age() — seconds since the last successful
  watchdog probe (None if never). Pure read of the Stage-1 _wd_last_ok_monotonic
  field; no RPC.
- api_server: /ping route (no token, no TG RPC, no SQLite, no fs scan). healthy =
  connected and (age is None or age < threshold). age is None right after boot =>
  healthy (don't kill before the first probe). connected coerced to bool so the JSON
  "connected" field is always a bool (pre-start reports false, never null).
- config: TG_PING_UNHEALTHY_AFTER knob, default interval*(failures+1)+timeout = 250s
  (how long until the watchdog itself gives up), env-overridable.
- dockercompose.yml: healthcheck -> curl -sf http://127.0.0.1:80/ping, interval 5m,
  timeout 5s, retries 3, start_period 30s. Old /rss check removed, not left behind.
- tests: 10 (healthy/stale/disconnected/fresh-boot/pre-start-null/no-token +
  the anti-regression zero-TG-RPC spy across all branches + the accessor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review finding (low, real): the comment claimed /ping degenerates to a pure
connectivity check when TG_WATCHDOG_ENABLED=false, but _wd_last_ok_monotonic is
also stamped by _restart_client (on the disconnect-flap path, which runs before
the watchdog-enabled gate), so with the watchdog off one flap sets age and nothing
ever refreshes it — age grows unbounded past the threshold and /ping returns 503 on
a live connection, spuriously failing the container healthcheck and triggering an
autoheal restart after every flap.

Fix: gate the staleness branch on the watchdog being enabled —
  healthy = connected and (not Config["tg_watchdog_enabled"] or age is None or age < threshold)
so with the watchdog disabled /ping is a pure connectivity check (matching the
intent), and correct the comment to note a flap-restart can stamp age even when the
watchdog is off. New test test_ping_watchdog_disabled_stale_age_still_healthy:
watchdog off + connected + stale age => 200 ok. Adversarially validated — reverting
the gate reds the new test (503) while the watchdog-ON stale-probe test stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Caps the stacked stability work (stages 1-6). Verification only — ZERO production
code change (git diff against fix/stage-6-healthcheck touches only these two files).

- tests/test_stage7_integration.py (8 cross-stage integration tests via TestClient +
  a mocked TelegramClient, no network): Range at the /media route level (206/206/416
  through get_media -> prepare_file_response -> FileResponse); /ping stays prompt and
  issues zero TG RPC while a slow op is parked; in-flight dedup shares one download and
  drains _inflight after completion AND after request cancellation (no stuck key / hung
  waiter); str(channel) access-time hit -> flush -> the bulk UPDATE lands on the seeded
  row (mutation-verified: transposing the key columns reds it).
- docs/stability-verification.md: per-stage DoD -> evidence mapping (test or "operator
  observation" for prod-only items), the exact manual curl/lsof scenarios for the
  operator to run post-deploy, the diag-log signals to watch, and the per-stage
  independent-commit rollback plan.

Full integrated suite: 260 passed (252 baseline + 8). The prod deploy + live diag-log
observation (plan items 3-4) are the operator's call — this stage does not deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review finding (low, doc-only): the rollback plan said each stage is one
independently-revertable commit, but each stage is actually two commits on its
branch (feature + review-round fix, the latter often fixing a real bug), so
reverting a single commit would orphan the review-round fix. Reword the unit of
rollback to the whole STAGE (branch/PR) and spell out the revert command per merge
strategy: squash-merge -> revert the one squash commit; merge-commit -> revert -m 1
the merge; linear history -> revert the full range of the stage's commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_vscode merged commit c5e50354ba into fix/stage-2-static 2026-07-05 16:58:38 +03:00
agent_vscode deleted branch fix/stage-3-fileresponse 2026-07-05 16:58:38 +03:00
Sign in to join this conversation.