Files
pyrogram-bridge/tg_cache.py
T
claude code agent 4daf611f05 fix(stability): stage 1 — timeouts on all TG RPC + resilient download worker + supervision
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>
2026-07-05 06:51:08 +03:00

236 lines
10 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# flake8: noqa
# pylint: disable=broad-exception-raised, raise-missing-from, too-many-arguments, redefined-outer-name
# pylint: disable=multiple-statements, logging-fstring-interpolation, trailing-whitespace, line-too-long
# pylint: disable=broad-exception-caught, missing-function-docstring, missing-class-docstring
# pylint: disable=f-string-without-interpolation
# pylance: disable=reportMissingImports, reportMissingModuleSource
import os
import json
import pickle
import logging
import random
import asyncio
import time
from datetime import datetime, timedelta
from types import SimpleNamespace
from typing import Any, Optional, Union, List
from pyrogram import Client
from pyrogram.types import Chat, Message
from tg_throttle import tg_rpc
from config import get_settings
logger = logging.getLogger(__name__)
Config = get_settings()
# Path to cache directory
CACHE_DIR = os.path.join('data', 'tgcache')
# TTL (hours) for the channel-info cache. Channel title/username/id change rarely,
# so a long TTL removes the rate-limited GetFullChannel from the feed-poll hot path.
try:
CHAT_CACHE_TTL_HOURS = int(os.getenv("TG_CHAT_CACHE_TTL_HOURS", "12"))
if CHAT_CACHE_TTL_HOURS < 1:
CHAT_CACHE_TTL_HOURS = 12
except ValueError:
CHAT_CACHE_TTL_HOURS = 12
def _get_history_cache_file_path(channel_id: Union[str, int]) -> str:
"""Returns path to the message history cache file for the channel"""
if not os.path.exists(CACHE_DIR):
os.makedirs(CACHE_DIR, exist_ok=True)
logger.info(f"cache_dir_created: path {CACHE_DIR}")
# Convert to string for uniformity
channel_id_str = str(channel_id)
# Replace potentially problematic characters
safe_filename = channel_id_str.replace('/', '_').replace('\\', '_')
return os.path.join(CACHE_DIR, f"{safe_filename}.cache")
def _save_history_to_cache(channel_id: Union[str, int], messages: List[Message], limit: int) -> None:
"""Saves message history to cache"""
try:
cache_file = _get_history_cache_file_path(channel_id)
# Create cache metadata — store messages directly (no inner pickle.dumps)
cache_data = {
'timestamp': time.time(),
'limit': limit,
'messages': messages
}
with open(cache_file, 'wb') as f:
pickle.dump(cache_data, f)
logger.info(f"history_cache_saved: channel {channel_id}, limit {limit}, messages {len(messages)}, file {cache_file}")
except Exception as e:
logger.error(f"history_cache_save_error: channel {channel_id}, limit {limit}, error {str(e)}")
def _get_history_from_cache(channel_id: Union[str, int], limit: int, max_age_hours: int = 8) -> Optional[List[Message]]:
"""
Retrieves message history from cache if not older than specified age and matches the limit
Args:
channel_id: Channel ID or username
limit: Required message limit
max_age_hours: Maximum cache age in hours (default 8 hours)
Returns:
List of messages or None if cache not found, expired or limit doesn't match
"""
try:
cache_file = _get_history_cache_file_path(channel_id)
if not os.path.exists(cache_file):
logger.info(f"history_cache_miss: channel {channel_id}, limit {limit}, cache file not found")
return None
with open(cache_file, 'rb') as f:
cache_data = pickle.load(f)
# Check cache age with randomization
cache_age = time.time() - cache_data['timestamp']
# Add randomness up to 20% of max_age_seconds
random_factor = 1 - random.uniform(0, 0.2)
adjusted_max_age = max_age_hours * 3600 * random_factor
if cache_age > adjusted_max_age:
logger.info(f"history_cache_expired: channel {channel_id}, limit {limit}, age {cache_age:.1f}s > adjusted max {adjusted_max_age:.1f}s (random factor: {random_factor:.2f})")
return None
# Check if limit matches
cached_limit = cache_data.get('limit', 0)
if cached_limit != limit:
logger.info(f"history_cache_limit_mismatch: channel {channel_id}, cached limit {cached_limit}, requested limit {limit}")
return None
# Restore message list; handle old cache files that used double-pickle (bytes = old format)
raw = cache_data['messages']
if isinstance(raw, bytes):
messages = pickle.loads(raw)
else:
messages = raw
logger.info(f"history_cache_hit: channel {channel_id}, limit {limit}, messages {len(messages)}, age {cache_age:.1f}s")
return messages
except Exception as e:
logger.error(f"history_cache_read_error: channel {channel_id}, limit {limit}, error {str(e)}")
# In case of cache read error, better return None and request fresh data
return None
async def cached_get_chat_history(client: Client, channel_id: Union[str, int], limit: int = 20) -> List[Message]:
"""
Gets chat message history with caching.
Args:
client: Pyrogram client
channel_id: Channel ID or username
limit: Maximum number of messages to retrieve
Returns:
List of messages, same as original client.get_chat_history()
"""
cached_messages = await asyncio.to_thread(_get_history_from_cache, channel_id, limit)
if cached_messages is not None:
return cached_messages
try:
logger.info(f"history_cache_request: fetching fresh history for channel {channel_id}, limit {limit}")
# Hold the global RPC gate only for the live fetch. The paginated `async for`
# cannot be wrapped in wait_for directly, so collect it in an inner coroutine
# and bound THAT with the timeout. wait_for wraps only the RPC body, never the
# gate entry (`async with tg_rpc()`), so legitimate queue backpressure is not
# mistaken for a hang.
async with tg_rpc():
async def _collect():
# Full paginated history; bounded by the outer wait_for.
return [m async for m in client.get_chat_history(channel_id, limit=limit)]
messages = await asyncio.wait_for(_collect(), timeout=Config["tg_rpc_timeout"])
await asyncio.to_thread(_save_history_to_cache, channel_id, messages, limit)
return messages
except Exception as e:
logger.error(f"history_cache_request_error: channel {channel_id}, limit {limit}, error {str(e)}")
raise
def _get_chat_cache_file_path(channel_id: Union[str, int]) -> str:
"""Returns path to the channel-info cache file for the channel."""
if not os.path.exists(CACHE_DIR):
os.makedirs(CACHE_DIR, exist_ok=True)
logger.info(f"cache_dir_created: path {CACHE_DIR}")
channel_id_str = str(channel_id)
safe_filename = channel_id_str.replace('/', '_').replace('\\', '_')
# Distinct suffix so channel-info files never collide with history (.cache) files.
return os.path.join(CACHE_DIR, f"{safe_filename}.chatinfo")
def _save_chat_to_cache(channel_id: Union[str, int], data: dict) -> None:
"""Saves channel-info (id/title/username) to cache."""
try:
cache_file = _get_chat_cache_file_path(channel_id)
cache_data = {'timestamp': time.time(), 'data': data}
with open(cache_file, 'wb') as f:
pickle.dump(cache_data, f)
logger.info(f"chatinfo_cache_saved: channel {channel_id}, file {cache_file}")
except Exception as e:
logger.error(f"chatinfo_cache_save_error: channel {channel_id}, error {str(e)}")
def _get_chat_from_cache(channel_id: Union[str, int], max_age_hours: int = CHAT_CACHE_TTL_HOURS) -> Optional[dict]:
"""Retrieves channel-info from cache if not older than max_age_hours (with up-to-20% randomization)."""
try:
cache_file = _get_chat_cache_file_path(channel_id)
if not os.path.exists(cache_file):
logger.info(f"chatinfo_cache_miss: channel {channel_id}, cache file not found")
return None
with open(cache_file, 'rb') as f:
cache_data = pickle.load(f)
cache_age = time.time() - cache_data['timestamp']
# Add randomness up to 20% of max age, same approach as the history cache.
random_factor = 1 - random.uniform(0, 0.2)
adjusted_max_age = max_age_hours * 3600 * random_factor
if cache_age > adjusted_max_age:
logger.info(f"chatinfo_cache_expired: channel {channel_id}, age {cache_age:.1f}s > adjusted max {adjusted_max_age:.1f}s (random factor: {random_factor:.2f})")
return None
data = cache_data.get('data')
if not isinstance(data, dict):
logger.warning(f"chatinfo_cache_invalid: channel {channel_id}, unexpected payload type {type(data).__name__}")
return None
logger.info(f"chatinfo_cache_hit: channel {channel_id}, age {cache_age:.1f}s")
return data
except Exception as e:
logger.error(f"chatinfo_cache_read_error: channel {channel_id}, error {str(e)}")
return None
async def cached_get_chat(client: Client, channel_id: Union[str, int]) -> SimpleNamespace:
"""Gets channel info (id/title/username) with disk TTL caching.
Avoids the rate-limited channels.GetFullChannel on every feed poll. Mirrors
cached_get_chat_history. On a cache miss the live get_chat is throttled and its
exceptions (FloodWait, UsernameInvalid, ...) propagate to the caller unchanged;
only successful lookups are cached. The returned object exposes .id/.title/.username.
"""
cached = await asyncio.to_thread(_get_chat_from_cache, channel_id)
if cached is not None:
return SimpleNamespace(**cached)
logger.info(f"chatinfo_cache_request: fetching fresh chat info for channel {channel_id}")
async with tg_rpc():
# Bound the RPC itself, not the gate entry above it.
chat = await asyncio.wait_for(client.get_chat(channel_id), timeout=Config["tg_rpc_timeout"])
data = {
'id': getattr(chat, 'id', None),
'title': getattr(chat, 'title', None),
'username': getattr(chat, 'username', None),
}
await asyncio.to_thread(_save_chat_to_cache, channel_id, data)
return SimpleNamespace(**data)