fix(media): follow-up self-heal (ретро-ревью #43) — чистка негативкэша на рестарте + кап + тесты
По ретроспективному ревью 3c9ce72:
Finding A (medium): негативный кэш падений (_download_failures) НЕ чистился после
успешного self-heal рестарта -> выздоровевший файл отдавал 503 до _DOWNLOAD_BACKOFF_MAX
(до часа), сам ретраиться не мог (sweeper continue, get_media fast-503) -> прямое
противоречие цели фикса. Теперь: telegram_client._restart_client на ВЕРИФИЦИРОВАННОМ
рестарте (verify_get_me ok, verify_ok=True) зовёт колбэк, зарегистрированный
api_server через client.set_restart_callback -> _download_failures.clear(). Колбэк
НЕ срабатывает на упавшем/прерванном рестарте/SIGTERM-fallback (исключение колбэка
подавляется). Зависимость односторонняя (telegram_client не импортит api_server).
_DOWNLOAD_BACKOFF_MAX 3600->600с (система самолечится за минуты). Retry-storm
ограничен: каждый retry timeout-bounded, заново взводит backoff.
Finding B (low): _download_failures рос безлимитно -> OrderedDict + LRU-кап
_DOWNLOAD_FAILURES_MAX=10000 (move_to_end на запись сбоя, popitem(last=False) сверх
капа; eviction безвреден).
Тесты (+10, мутационно проверены): hung-download отменяется по таймауту и
освобождает слот; стрик эскалирует к РОВНО одному рестарту и верифицированный
рестарт ЧИСТИТ кэш; провал verify -> кэш НЕ чистится; консистентность ключа
(str(channel),int(post_id),fid) на всех путях после канонизации #24; LRU-кап.
Doc-коммент: стрик считает ТОЛЬКО consecutive-таймауты, не-таймаут ошибки
streak-нейтральны (поведение не менялось).
closes #43
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+33
-1
@@ -45,6 +45,7 @@ class TelegramClient:
|
||||
self._download_timeout_streak = 0
|
||||
self.media_timeout_restart_threshold = settings["media_timeout_restart_threshold"]
|
||||
self._media_recovery_task = None # strong ref to a scheduled recovery restart task
|
||||
self._on_restart_verified = None # optional callback fired after a VERIFIED restart (see set_restart_callback)
|
||||
self._disconnect_times = [] # Monotonic timestamps of recent disconnects (sliding window)
|
||||
self.disconnect_window = settings["tg_disconnect_flap_window"] # Seconds; window for flap detection
|
||||
self._watchdog_task = None
|
||||
@@ -188,6 +189,17 @@ class TelegramClient:
|
||||
except Exception as e:
|
||||
logger.critical(f"watchdog: loop crashed unexpectedly ({type(e).__name__}: {e}); liveness protection is now DISABLED until next start")
|
||||
|
||||
def set_restart_callback(self, callback) -> None:
|
||||
"""Register a callback invoked after a VERIFIED in-process restart (verify_get_me OK).
|
||||
|
||||
Used by api_server to clear its download negative cache: a completed restart rebuilds
|
||||
the whole client (all DCs, including the media DC), so any per-file download backoff is
|
||||
stale and a previously-failing file may now download. Kept as a plain hook so
|
||||
telegram_client never has to import api_server (which would be a circular import).
|
||||
The callback is synchronous, best-effort, and never fires on a failed/aborted restart.
|
||||
"""
|
||||
self._on_restart_verified = callback
|
||||
|
||||
async def _restart_client(self, reason: str = "unspecified"):
|
||||
"""Recover the client without killing the process when possible.
|
||||
|
||||
@@ -214,10 +226,14 @@ class TelegramClient:
|
||||
await asyncio.wait_for(self.client.start(), timeout=self.watchdog_restart_timeout)
|
||||
duration = time.monotonic() - restart_started
|
||||
self._wd_restart_count += 1
|
||||
# Verification probe to prove the network layer is actually back (diagnostic only).
|
||||
# Verification probe to prove the network layer is actually back. It also gates the
|
||||
# verified-restart callback below: we only treat the restart as a real recovery
|
||||
# (and clear the download negative cache) when this probe actually succeeds.
|
||||
verify_ok = False
|
||||
try:
|
||||
me = await asyncio.wait_for(self.client.get_me(), timeout=self.watchdog_timeout)
|
||||
self._wd_last_ok_monotonic = time.monotonic()
|
||||
verify_ok = True
|
||||
verify = f", verify_get_me ok (me_id={getattr(me, 'id', None)})"
|
||||
except Exception as ve:
|
||||
verify = f", verify_get_me FAILED ({type(ve).__name__}: {ve})"
|
||||
@@ -226,6 +242,16 @@ class TelegramClient:
|
||||
f"(is_connected={self.client.is_connected}{verify}, total in-process restarts={self._wd_restart_count})"
|
||||
)
|
||||
self._disconnect_times.clear()
|
||||
# On a VERIFIED restart the whole client (all DCs, incl. the media DC) is
|
||||
# re-established, so any per-file download backoff is stale — fire the registered
|
||||
# hook (api_server clears its negative cache) so recovered media load immediately
|
||||
# instead of fast-503'ing until the backoff expires. Only on verify success, never
|
||||
# on a failed/aborted restart. Best-effort: a callback error must not abort recovery.
|
||||
if verify_ok and self._on_restart_verified is not None:
|
||||
try:
|
||||
self._on_restart_verified()
|
||||
except Exception as cbe:
|
||||
logger.warning(f"recovery: restart-verified callback raised {type(cbe).__name__}: {cbe}")
|
||||
# Re-arm the watchdog in case it had previously crashed (self-healing).
|
||||
self._start_watchdog()
|
||||
except asyncio.CancelledError:
|
||||
@@ -347,6 +373,12 @@ class TelegramClient:
|
||||
self.note_download_timeout()
|
||||
raise
|
||||
except Exception as e:
|
||||
# INTENTIONAL: the restart streak counts CONSECUTIVE TIMEOUTS only. Non-timeout
|
||||
# download errors (RPCError, FILE_REFERENCE_EXPIRED, etc.) reach here and call
|
||||
# neither note_download_ok nor note_download_timeout, so they are streak-neutral:
|
||||
# they neither advance nor reset it. This is deliberate — a zombie media-DC
|
||||
# manifests as timeouts, not RPC errors, so only timeouts should escalate to a
|
||||
# connection-rebuilding restart; a burst of unrelated RPC errors must not.
|
||||
if isinstance(e, KeyError) and attempt < max_retries - 1:
|
||||
logger.warning(f"Download auth error on attempt {attempt + 1}, retrying...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
Reference in New Issue
Block a user