fix(stability): стадия 3 — раздача через FileResponse, чистый ASGI-логгер, больший executor #11
Reference in New Issue
Block a user
Delete Branch "fix/stage-3-fileresponse"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Стадия 3 плана — раздача файлов через Starlette
FileResponse, чистка HTTP-слоя. closes #3prepare_file_responseтеперь возвращаетFileResponse(сам обрабатывает Range/If-Range/206/416/multipart, ставит Accept-Ranges/ETag/Last-Modified, читает файл эффективно — без to_thread на каждые 64KB, что душило пул). Сохранено: ранний 404, MIME-логика (magic + SQLite-кэш) и ВСЕ поведения стадии 2 — mtime-touchtemp_*(теперь ДЕБАУНСНУТ, см. ниже),delete_after→BackgroundTask(вbackground=),media_keyMIME-кэш. Удалён ручной парсинг Range/file_chunk_generator/самодельные заголовки; Content-Disposition формирует FileResponse изfilename=(без задвоения). 206-срезы байт-идентичны старому коду; принятые RFC-7233-дельты задокументированы в тестах.RequestLoggingMiddlewareпереписан на чистый ASGI-класс (оборачивает толькоsendради статус-строки, НЕ буферизует тело, прокидывает не-http scope) — стриминг течёт нетронутым.ThreadPoolExecutor,IO_THREAD_POOL_SIZEdefault 32) и закрывает его на выходе.How verified
Venv под requirements:
python -m pytest tests/→ 213 passed (195 baseline + 18 новых).temp_*mtime рефрешится при устаревании И стабилен при свежести (ETag идентичен между запросами);delete_after-background реально удаляет файл; MIME-кэш hit/miss; ASGI-middleware пропускает тело и логирует.Внутренний цикл: 1 проход ревью (APPROVE with suggestions, критики/регрессий стадии 2 нет — mtime/delete_after/MIME-кэш сохранены и покрыты, 206 байт-в-байт, middleware не буферизует). По SUGGESTION'ам сложил в этот же PR:
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
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>Открыл 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.
Ревью — #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
[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/exceptget_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» → обрыв тела — ПРЕ-существовало и в старом коде, не трогаем.)[documentation · low] Комментарий про Content-Disposition противоречит собственному тесту —
api_server.py:246-249.Пишет: «passing filename= makes FileResponse emit
inline; filename*=UTF-8''...on its own». По исходнику Starlette 0.45.3filename*=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» неточно — Starlettesetdefault, ручной хедер бы победил, а не задвоил).[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 — тогда рестартов ноль; но раз поведение ок, достаточно поправить текст.)[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.[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-base41d14345=утв. голова стадии-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).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/exceptFileNotFoundError→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 закрывает.Ревью — #11 (fix(stability): стадия 3 — раздача через FileResponse, чистый ASGI-логгер, больший executor), round 2. Вердикт: PASS ✅
Все 5 пунктов round-1 ГЕНУИННО ЗАКРЫТЫ (сверил против кода + исходника Starlette 0.45.3, не по прозе), и фикс не внёс новый баг (адверсариальный stability-прогон по 5 векторам — чисто):
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_mediaexcept HTTPException: raise). Порядок верный: stat ПОСЛЕ тача → ETag консистентен с mtime, который видит и свипер.setdefault(override, не задвоение); debounce-оверклейм заменён на «стабилен в пределах любого 300s-окна; просмотр >интервала стоит максимум одного безопасного 200 If-Range-рестарта».StreamingResponseубран из импорта (:36),get_media -> Response(:1051), остаточных использований нет.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→2870e0a40..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): «addingfilename*=UTF-8''xonly 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).
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>