fix(stability): стадия 1 — таймауты на все TG RPC + устойчивый воркер загрузок + супервизия #8
Reference in New Issue
Block a user
Delete Branch "fix/stage-1-hangs"
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
Стадия 1 плана стабилизации (
plans/2026-07-05-stability-fix-plan.md) — устранение зависаний всего приложения. closes #1Главный источник фризов: один зависший TG RPC под глобальным гейтом
tg_rpc(concurrency=1) держит единственный пермит навсегда → ВСЕ RSS/HTML-запросы виснут. Плюс фоновый воркер загрузок молча умирал.asyncio.wait_for(configtg_rpc_timeout, envTG_RPC_TIMEOUT, default 60).wait_forоборачивает ТОЛЬКО тело RPC, НЕ вход в гейт — очередь-бэкпрешер (47 фидов) остаётся легитимной. Пагинацияget_chat_historyсобирается во внутренней корутине (иначеasync forне завернуть). Таймаут выходит наружу из гейта → пермит освобождается (не течёт)._reply_enrichmentget_messagesтеперь под гейтом+таймаутом;PostParser.get_post— 30с (+ убран лишнийprint);/healthget_me— 10с.background_download_worker:queue.get()вынесен изtry→task_done()вfinallyсбалансирован ровно с одним get (не умирает отValueError);FloodWaitловится ДО genericException(спит, не флудит телеграм);download_new_files→put_nowait(полная очередь больше не блокирует свипер навсегда)._supervised(): краш/неожиданный return логируются CRITICAL и рестартятся (не чаще раза в 60с — hard-fail не спинит луп),CancelledErrorпробрасывается в child для чистого шатдауна.How verified
Venv под requirements (Kurigram 2.2.22, как в проде) + dev-deps (pytest-asyncio, httpx):
python -m pytest tests/→ 180 passed (174 baseline + 6 новых).tests/test_stage1_hangs.py): таймаут гейта освобождает пермит + второй вызов проходит; отмена гейта в spacing не теряет пермит; воркер переживает Exception и FloodWait со сбалансированнымtask_done;_supervisedрестартит крашащийся таск (rate-limited) и неожиданный return, пробрасывает отмену в child.Внутренний цикл: 1 проход ревью (APPROVE with suggestions, критики нет — гейт-пермит не течёт, воркер не умирает молча, FloodWait перед Exception, шатдаун чистый — всё сверено адверсариально по коду). По WARNING добавил тесты на
_supervised(был не покрыт).База / порядок
PR в ветку
fix/stability(в ней план); стадии стекаются последовательно (2 поверх 1 и т.д.), как в плане «коммит на каждую стадию, деплой после 1-2».⚠️ Деплой + наблюдение прод-диагов — Стадия 7 и твоё решение, не в этом PR.
Открытые (из ревью, для Стадии 2 / документирования)
tg_rpc_timeout— на ВСЮ пагинацию истории, не на страницу (приlimit≈20не проявляется; при ростеlimitпересмотреть).wait_for— тест покрываетasyncio.Event; если конкретный MTProto-вызов проглотит cancel,wait_forсам зависнет (свойство Kurigram; watchdog — кандидат в Стадию 2/6).Checklist
Eliminates the app-wide hangs where one stuck Telegram RPC holds the single tg_rpc gate permit forever (freezing every RSS/HTML request) and where the background download worker dies silently. 1.1 Every RPC under the global gate is now bounded by asyncio.wait_for (config tg_rpc_timeout, env TG_RPC_TIMEOUT, default 60). The wait_for wraps ONLY the RPC body, never the gate acquire, so queue backpressure stays legitimate; the paginated get_chat_history is collected in an inner coroutine so the async-for can be bounded. A timeout propagates out of the gate so its permit is released (no leak). 1.2 The other live RPC paths get the same treatment: _reply_enrichment's get_messages now runs under the gate + timeout; PostParser.get_post is bounded at 30s (and a stray print removed); /health's get_me at 10s. 1.3 background_download_worker: queue.get() moved out of the try so task_done() in finally balances exactly one get (no ValueError that killed the worker); FloodWait is caught BEFORE the generic Exception (sleeps, does not flood); download_new_files uses put_nowait so a full queue no longer blocks the sweeper forever. 1.4 Both background tasks run under a _supervised() wrapper: a crash or an unexpected return is logged CRITICAL and restarted (restarts rate-limited to once per 60s so a hard-failing task can't spin), while CancelledError is propagated to the child for a clean shutdown. Tests (tests/test_stage1_hangs.py): gate timeout releases the permit + a second call succeeds; gate cancel mid-spacing releases the permit; the worker survives Exception and FloodWait with balanced task_done; and _supervised restarts a crashing task (rate-limited) and an unexpected return, and propagates cancellation to its child. 180 passed (174 baseline + 6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Открыл PR по стадии 1 (устранение зависаний). Внутренний цикл: 1 проход ревью, адверсариальная сверка гейт-пермита (не течёт при таймауте/отмене), воркера (task_done сбалансирован, FloodWait перед Exception) и супервизора (чистый шатдаун) — критики нет. По WARNING добавил тесты на
_supervised. 180 passed (174 baseline + 6). База — fix/stability (стадии стекаются). Деплой/наблюдение — стадия 7, за тобой.Ревью — #8 (fix(stability): стадия 1 — таймауты на все TG RPC + устойчивый воркер загрузок + супервизия, #1), round 1. Вердикт: CHANGES
Сильная, аккуратная работа — веер 9 аспектов сошёлся, 4 LGTM. Stability LGTM (все 5 concurrency-проб звучны: permit-leak КОРРЕКТЕН —
wait_forвнутриasync with tg_rpc(), наTimeoutErrorпермит освобождается через__aexit__, half-consumed async-genaclose()'ится и лок не пинит; worker task_done сбалансирован (get вне try, ровно один task_done);_supervisedбез tight-loop (throttle 60с), CancelledError пробрасывается в child;put_nowaitдропает при QueueFull но self-heal'ится следующим свипом; ни один GATED RPC не остался без таймаута). Security LGTM (таймаут-парс сminimum=1режет 0/негатив, FloodWait-before-Exception уважает rate-limit, очередь bounded+не attacker-reachable, нет секретов в новых логах). Architecture LGTM (per-RPC-timeout — верный минимальный stage-1-фикс, не band-aid: гейт не причина фриза, а неограниченный hold-time; concurrency уже env-параметр; forward-compatible с планом). Regressions LGTM (медленные-но-успешные RPC не таймаутят спурьезно — download_media НЕ обёрнут; пагинация эквивалентна; put_nowait не теряет — DB re-scan; task_done-move строгое улучшение). Открыто 5 (3 warning + 2 low). Эскалации нет.Открыто: F1 (
pytest-asyncioне в requirements → 6 новых стабилити-тестов ERROR'ят на чистом чекауте, CI только билдит Docker → нулевая регресс-защита); F2 (/raw_jsonget_messages— единственный оставшийся НЕограниченный live-RPC → нарушает собственный DoD стадии 1); F3 (FloodWait-ветка backoff не ассертится → удаление ветки оставит тест зелёным); F4/F5 (TG_RPC_TIMEOUT не задокументирован; дедуп gate+timeout-паттерна).Объективка ЗЕЛЁНАЯ — я ВОСПРОИЗВЁЛ (venv, голова
4daf611f): поднял venv сrequirements.txt(Kurigram 2.2.22 + fastapi/uvloop/…) + pytest-asyncio →pytest tests/test_stage1_hangs.py6 passed,pytest tests/180 passed (ровно как заявил кодер). Т.е. фикс КОРРЕКТЕН, тесты проходят. Прим.:pytest-asyncioя ставил вручную — без него (его нет в requirements) тесты не запускаются (F1).📋 Do (F1–F5) + DROP/out-of-scope + что сверено
Do — почини, потом ставь
review/needsF1 [test-coverage/conventions · warning]
pytest-asyncioне объявлен → стабилити-тесты не запускаются с декларированных зависимостей —requirements.txt(+tests/pytest.ini).6 новых тестов (
tests/test_stage1_hangs.py) —async defс@pytest.mark.asyncio, ноpytest-asyncioнет ни вrequirements.txt, ни в dev-манифесте, аtests/pytest.ini([pytest] addopts=-ra) не задаётasyncio_mode. Существующие тесты — синхронныйunittest.TestCase, так что плагин никогда не подтягивался. Сверил: на чистомpip install -r requirements.txt && pytest tests/эти 6 тестов ERROR'ят («async def functions are not natively supported») → нулевая регресс-защита для timeout/worker/supervision-логики, которую они призваны стеречь. CI только билдит Docker-образ, pytest не гоняет — проходит незамеченным, пока у автора глобально не стоит pytest-asyncio (у меня в venv — только поэтому 180 passed). Fix: добавьpytest-asyncioв манифест зависимостей (рядом сtypes-bleach); опц.asyncio_mode = autoвtests/pytest.ini(тогда per-test-маркеры не нужны — но они уже есть, так что критичное — сам плагин). Проверь, что файл КОЛЛЕКТИТСЯ и БЕЖИТ на чистом venv.F2 [coherence · warning]
/raw_jsonget_messagesне ограничен таймаутом → DoD стадии 1 не выполнен —api_server.py:920.DoD стадии 1 явно: «Ни один путь кода не ждёт Telegram без таймаута», инвариант #2 — «Каждый Telegram RPC ограничен таймаутом». PR оборачивает КАЖДЫЙ live-RPC из перечисления 1.2 (
_reply_enrichment,get_post,/health get_me) + два гейт-кэш-RPC, НО эндпоинт/raw_json/{channel}/{post_id}(api_server.py:920) всё ещё делаетawait client.client.get_messages(channel_id, post_id)БЕЗ таймаута (сверил — единственный оставшийся такой; grep всех get_chat/get_messages/get_me/get_chat_history). Он НЕ под гейтомtg_rpc, так что app-wide-фриз-класс не переоткрывает (blast-radius — один зависший запрос, не всё приложение), НО оставляет собственную планку завершения стадии 1 буквально невыполненной, и enum 1.2 молча пропустил этот путь, а не отложил осознанно. Fix:asyncio.wait_for(client.client.get_messages(channel_id, post_id), timeout=30)— зеркаляpost_parser.py:99.F3 [test-coverage · warning] FloodWait-ветка backoff не ассертится → удаление ветки оставит тест зелёным —
tests/test_stage1_hangs.py:101-131(дляapi_server.py:668-672).Worker-тест верифицит survival + task_done-баланс (
processed == ["boom","flood","ok"], join завершается) — не вакуумно покрывает «воркер не умирает». НО НЕ пиннит ОТЛИЧИТЕЛЬНОЕ поведение выделеннойexcept errors.FloodWait-ветки: backoffmin(int(e.value)+5, 900)и её позицию ПЕРЕД genericexcept Exception.asyncio.sleepглобально застаблен в no-op, аргумент не захватывается → FloodWait-путь неотличим от generic-Exception-пути. Удалиexcept errors.FloodWait(или переставь послеexcept Exception) → FloodWait провалится в generic, воркер выживет,processedтот же,q.join()завершится — тест зелён. А это ровно регресс, ради предотвращения которого ветка есть (не хамить телеграму FLOOD_WAIT'ом). Fix: замени no-op_fast_sleepрекордером delay-аргументов, ассерть, что FloodWait дал backoff6(=min(1+5,900)), отличный от success-пути2.F4 [documentation · low]
TG_RPC_TIMEOUTне задокументирован —dockercompose.yml(environment:-блок).Проект документирует тюнинг-кнобы комментами в
environment:-блокеdockercompose.yml(тамTG_RPC_CONCURRENCY,TG_RPC_MIN_INTERVAL_MS,TG_WATCHDOG_*, …), но новый siblingTG_RPC_TIMEOUT(config.py:117, default 60) отсутствует → оператор не узнает про кноб. Fix: коммент-строка рядом сTG_RPC_CONCURRENCY.F5 [simplification · low] Дедуп «gate + timeout»-паттерна в один context-manager —
tg_cache.py:148-153,225-227+rss_generator.py:508-512.«Держи глобальный gate, ограничь ТОЛЬКО тело RPC через
wait_for(timeout=Config['tg_rpc_timeout'])» open-code'ится в 3 местах, каждое с копипастой 2-3-строчного коммента про хитрый инвариант («wait_for оборачивает тело, НЕ вход в гейт»). Ровно то, что должно жить в одном месте — иначе каждый будущий call-site перевыводит правильную вложенность руками, и ошибка (обернуть вход в гейт) молча переоткрывает hang-under-backpressure. Fix:@asynccontextmanager tg_rpc_bounded()вtg_throttle.py(config-импорт безопасен, цикла нет), 3 сайта →async with tg_rpc_bounded(): …. (Или на runtime 3.11 —asyncio.timeout()вместо inner-_collect()-корутины. Одно из двух.)⛔ DROP / вне scope · калибровочный лог (оператору)
[out-of-scope]risk[security]/healthи/raw_jsonлогируют ОЖИДАЕМЫЙ API-токен в открытом виде при неудачной авторизации:logger.error(f"Invalid token…: {token}, expected: {Config['token']}")(api_server.py:938,883) — пишет реальныйTOKEN-секрет в ERROR-логи. Пре-существует, PR не трогает (диф изменил только get_me-строку) → не в scope этого PR, но реальная secret-in-logs-экспозиция. Кандидат в отдельный issue: убратьexpected: {…}и маскировать{token}. (Оператору — не кодеру этого PR.)Сверено (9 аспектов + мои проверки, голова
4daf611f): objective gate ВОСПРОИЗВЁЛ сам (venv full-deps → 180 passed / stage1 6 passed); permit-leak корректен (wait_for внутри gate, aexit релизит, half-gen aclose); worker task_done баланс; supervisor throttle+CancelledError; put_nowait self-heal; F1 сверил (pytest-asyncio нет в requirements, pytest.ini без asyncio_mode); F2 сверил (/raw_json:920без wait_for — единственный такой); security-парс/FloodWait/логи чисты; architecture forward-compatible с планом; regressions — медленные RPC не таймаутят, пагинация эквивалентна. F1/F2/F3 — warning; F4/F5 — low. token-in-logs — out-of-scope (отдельный issue). Эскалации нет.F1 [WARNING] pytest-asyncio was not declared, so the 6 new async stage-1 tests ERROR on a clean checkout (async def not natively supported) — zero regression protection, masked by a globally-installed plugin. Added pytest-asyncio to requirements.txt + asyncio_mode=auto to tests/pytest.ini. Verified on a fresh venv from requirements alone: the tests collect and run (180 passed). F2 [WARNING] The /raw_json endpoint's get_messages was the one remaining live RPC without a timeout, violating the stage-1 DoD ('every Telegram RPC is bounded'). Wrapped it in asyncio.wait_for(..., 30) mirroring PostParser.get_post. (It is not under the tg_rpc gate, so its blast radius was one request, not the app.) F3 [WARNING] The worker test globally no-op'd asyncio.sleep, so the dedicated except FloodWait branch was indistinguishable from the generic handler — deleting it kept the test green. The sleep stub now records delays and the test asserts the FloodWait backoff of 6 (=min(1+5,900)), distinct from the success path's 2. F5 [low] The tricky 'gate outside, timeout inside' nesting was open-coded at 3 sites (each re-deriving the invariant). Extracted tg_rpc_bounded(timeout) into tg_throttle (using asyncio.timeout()); the 3 sites now use it, so a future call site cannot silently wrap the gate entry and reopen the hang-under-backpressure. F4 [low] Documented TG_RPC_TIMEOUT in the dockercompose.yml environment block next to the other TG_RPC_* knobs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Round 1 — все 5 закрыты. И спасибо за F1: моё «180 passed» держалось на глобально стоявшем pytest-asyncio — ровно тот прогретый-энв, что ты и ловишь.
F1 [WARNING] pytest-asyncio задекларирован. Добавил в
requirements.txt+asyncio_mode=autoвtests/pytest.ini. Проверил faithful: свежий venv из ОДНОГОrequirements.txt→ 6 async-тестов КОЛЛЕКТЯТСЯ и БЕГУТ (не ERROR), полныйpytest tests/→ 180 passed.F2 [WARNING]
/raw_jsonпод таймаутом.get_messagesтеперьwait_for(..., 30)(зеркаляget_post). Последний неограниченный live-RPC — DoD стадии 1 выполнен.F3 [WARNING] FloodWait-backoff ассертится.
_fast_sleepтеперь рекордер delay'ев; тест проверяет, что FloodWait(value=1) дал backoff 6 (=min(1+5,900)), отличный от success-пути 2. Удалениеexcept FloodWaitтеперь красит тест.F5 [low] Дедуп в
tg_rpc_bounded(timeout). Вынес «гейт снаружи, таймаут внутри» в один@asynccontextmanagerвtg_throttle(наasyncio.timeout()); 3 сайта →async with tg_rpc_bounded(...). Сверил, что permit-leak-инвариант держится через рефактор (тестtest_gate_timeout_releases_permitзелёный на новом пути).F4 [low]
TG_RPC_TIMEOUTзадокументирован вdockercompose.ymlрядом сTG_RPC_*.DROP (secret-in-logs):
/healthи/raw_jsonлогируют реальныйTOKENпри неудачной авторизации (expected: {Config['token']}) — пре-существует, не в диффе этого PR. Заведу отдельный issue на маскировку (тебе как оператору — согласен, что отдельно).Прогон (чистый venv): 180 passed. Внутренний цикл: 1 проход + адверсариальная сверка рефактора gate+timeout.
Ревью — #8 (fix(stability): стадия 1 — таймауты на все TG RPC + устойчивый воркер загрузок + супервизия, #1), round 2. Вердикт: PASS ✅
Все 5 round-1 находок закрыты и сверены:
F1 (
pytest-asyncioне объявлен) — добавленpytest-asyncio==1.4.0вrequirements.txt+asyncio_mode = autoвtests/pytest.ini. Теперь тесты запускаются с декларированных зависимостей (сверил: чистый venv изrequirements.txt→pytest tests/собирает и бежит).F2 (
/raw_jsonбез таймаута) —api_server.py:920теперьawait asyncio.wait_for(client.client.get_messages(...), timeout=30). Последний неограниченный live-RPC закрыт → DoD стадии 1 выполнен.F3 (FloodWait-ветка не ассертилась) —
_fast_sleepтеперь рекордит delay-аргументы, тест ассертит6 in sleeps(=min(FloodWait.value 1+5, 900)), отличный от success-пути2. Не вакуумен: удалиexcept FloodWait→ провал в generic,6не появится, ассерт красный.F4 —
TG_RPC_TIMEOUTзадокументирован вdockercompose.yml. F5 — вынесенtg_rpc_bounded(timeout)вtg_throttle.py, все 3 сайта (tg_cache:148/221, rss:508) через него; заодно inner-_collect()-корутина заменена наasyncio.timeout()(Python 3.11 идиома).F5 — concurrency-critical рефактор, поэтому re-верифицировал скептически (в этой сессии фиксы не раз вводили новый баг при переписывании concurrency). Permit-leak СОХРАНЁН: helper = gate (
_TgRpcGate) СНАРУЖИ,asyncio.timeout()ВНУТРИ → TimeoutError пробрасывается через__aexit__гейта →_sem.release(). Сверил ДВУМЯ независимыми способами: (а) мой standalone-probe с точной вложенностью (Semaphore(1) + asyncio.timeout + async-comprehension над зависшим генератором) → TimeoutError,sem free: True, второй вызов проходит; (б) stability-агент отдельным repro подтвердил а/б/в/г/д — async-genaclose'ится (CancelledError в точке await, finally бежит), все 3 сайта корректны, caller-исключение разматывает оба CM с релизом пермита, нет import-цикла (tg_throttleне импортит config — timeout прокидывается). Вывод: рефактор behavior-preserving, нового не вводит.Объективка ЗЕЛЁНАЯ — ВОСПРОИЗВЁЛ сам (venv full-deps, голова
03d1de29):pytest tests/test_stage1_hangs.py6 passed,pytest tests/180 passed. Permit-leak-тест по-прежнему проходит через новый helper (ключевое подтверждение, что рефактор не сломал корректность).Замечаний нет.
review/approved. (Прим.: pre-existing token-in-cleartext-logs — отдельный issue тебе, не блокер этого PR.)Fixes flaky static media serving: partial files served as 'ready', truncated stubs living for an hour, and app-wide hangs under a saturated download path. 2.1 _download_atomic(file_id, final_path, timeout): downloads to a unique {final}.part.{hex}, validates size>0, and publishes solely via os.rename (atomic on POSIX); a finally ALWAYS removes the partial (timeout/cancel/ zero-size/race-loser). Every downloader call routes through it, so a file at a FINAL name ({fid} / temp_{fid}) is GUARANTEED complete (grep-proven: the only safe_download_media caller passes a .part. path). Big-video timeout scales with size (min/max/min-speed knobs, documented in dockercompose.yml). The sweeper regex now matches both .part. and legacy .tmp. stubs. 2.2 In-flight dedup registry: the first request for a key runs the download in a DETACHED task sharing a Future; the task's finally sets the Future AND pops the key. Waiters await asyncio.shield(fut) so a client disconnect / waiter timeout cancels only the waiter, never the download — no hung waiters, no stuck key, a failed download frees the key for retry. 2.3 FloodWait->429: handler before except RPCError (FloodWait subclasses it), Retry-After = min(value + rand(1,30), 300); propagates from the detached task through the Future. 2.4 Serving a temp_* file touches its mtime so the 1h sweeper can't delete a video out from under a viewer. 2.5 The HTTP download semaphore acquire is bounded (wait_for 30 -> 503 + Retry-After); the permit is released only if the acquire succeeded. The request-scoped-permit trade-off (a disconnect can transiently exceed the download count) is documented inline for stage-7 prod observation. Tests (tests/test_stage2_static.py, 13): atomic publish/clean on every exit incl. FloodWait-through-the-finally; concurrent big-video serves no partial; dedup runs one download, a cancelled waiter doesn't hang others, a failed download frees the key; FloodWait -> 429; mtime touch; sweeper cleans .part./.tmp./stale temp_ but keeps fresh files. 193 passed (180 baseline + 13). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>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>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>