From 68a2dbfa04dd0b48412c73cbf312914f962df85d Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Sat, 13 Sep 2025 20:47:16 +0300 Subject: [PATCH] refactor api_server and post_parser: implement asynchronous JSON file operations with locking mechanism to ensure thread safety, and streamline media file ID persistence logic --- api_server.py | 20 +++----- file_io.py | 35 +++++++++++++ plan.md | 136 +++++++++++++++++++++++++++++++++++++++++++++++++ post_parser.py | 66 ++++++++++++++++-------- tg_cache.py | 5 +- 5 files changed, 225 insertions(+), 37 deletions(-) create mode 100644 file_io.py create mode 100644 plan.md diff --git a/api_server.py b/api_server.py index b0fed6c..52f880d 100644 --- a/api_server.py +++ b/api_server.py @@ -37,6 +37,7 @@ from config import get_settings, setup_logging from rss_generator import generate_channel_rss, generate_channel_html from post_parser import PostParser from url_signer import verify_media_digest, generate_media_digest +from file_io import read_json_file_sync, write_json_file_sync, get_media_file_ids_lock # Global python-magic instance for MIME type detection magic_mime = magic.Magic(mime=True) @@ -283,7 +284,8 @@ async def download_media_file(channel: Union[str, int], post_id: int, file_uniqu file_data.get('file_unique_id') == file_unique_id): file_data['added'] = datetime.now().timestamp() break - await asyncio.to_thread(write_json_file_sync, file_ids_path, media_files) + async with get_media_file_ids_lock(): + await asyncio.to_thread(write_json_file_sync, file_ids_path, media_files) except Exception as e: logger.error(f"Failed to update timestamp for {channel}/{post_id}/{file_unique_id}: {str(e)}") return cache_path, False @@ -308,7 +310,8 @@ async def download_media_file(channel: Union[str, int], post_id: int, file_uniqu ) ] if len(media_files_updated) < initial_count: - await asyncio.to_thread(write_json_file_sync, file_ids_path, media_files_updated) + async with get_media_file_ids_lock(): + await asyncio.to_thread(write_json_file_sync, file_ids_path, media_files_updated) logger.info(f"Removed invalid entry for {channel}/{post_id}/{file_unique_id} from media_file_ids.json") else: logger.warning(f"Entry for {channel}/{post_id}/{file_unique_id} not found in media_file_ids.json for removal") @@ -515,7 +518,8 @@ async def cache_media_files() -> None: if files_removed > 0: try: - await asyncio.to_thread(write_json_file_sync, file_path, updated_media_files) + async with get_media_file_ids_lock(): + await asyncio.to_thread(write_json_file_sync, file_path, updated_media_files) logger.info(f"Removed {files_removed} old files from cache") except Exception as e: logger.error(f"Failed to update media_file_ids.json: {str(e)}") @@ -591,16 +595,6 @@ def calculate_cache_stats() -> dict[str, Any]: "channels": channels_stats } -# Synchronous JSON helpers to be used via asyncio.to_thread -def read_json_file_sync(file_path: str) -> list: - with open(file_path, 'r', encoding='utf-8') as f: - return json.load(f) - -def write_json_file_sync(file_path: str, data: list) -> None: - temp_path = f"{file_path}.tmp" - with open(temp_path, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=2) - os.replace(temp_path, file_path) def is_local_request(request: Request) -> bool: local_hosts = ["127.0.0.1", "localhost"] diff --git a/file_io.py b/file_io.py new file mode 100644 index 0000000..b8c2f8a --- /dev/null +++ b/file_io.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# flake8: noqa +# pylint: disable=missing-function-docstring + +import os +import json +import asyncio +from typing import Any, List + + +# One shared lock for operations that write to media_file_ids.json +_media_file_ids_lock: asyncio.Lock | None = None + + +def get_media_file_ids_lock() -> asyncio.Lock: + global _media_file_ids_lock + if _media_file_ids_lock is None: + _media_file_ids_lock = asyncio.Lock() + return _media_file_ids_lock + + +def read_json_file_sync(file_path: str) -> List[dict[str, Any]]: + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + + +def write_json_file_sync(file_path: str, data: List[dict[str, Any]]) -> None: + temp_path = f"{file_path}.tmp" + with open(temp_path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(temp_path, file_path) + + diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..ca19473 --- /dev/null +++ b/plan.md @@ -0,0 +1,136 @@ + +### Где и почему может виснуть + +- Синхронный диск/JSON в обработчиках запросов + - `_save_media_file_ids` делает `open/json.load/json.dump` прямо в пути запроса, без `to_thread`. При большом `data/media_file_ids.json` это блокирует event loop. +```950:979:post_parser.py + if os.path.exists(file_path): + with open(file_path, 'r', encoding='utf-8') as f: + existing_data = json.load(f) +... + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(existing_data, f, ensure_ascii=False, indent=2) +``` + - Кеш истории также читает/пишет файлы синхронно: +```71:99:tg_cache.py + with open(cache_file, 'rb') as f: + cache_data = pickle.load(f) +... + with open(cache_file, 'wb') as f: + pickle.dump(cache_data, f) +``` + - Итог: под нагрузкой любой долгий sync I/O останавливает обработку всех запросов на время операции. + +- CPU‑тяжёлое формирование RSS/HTML в event loop + - Парсинг/санитизация/генерация RSS идут синхронно в корутинах (bleach, feedgen, склейка больших строк). На больших лимитах это “съедает” цикл. +```185:299:rss_generator.py +final_posts = await _render_messages_groups(...) +... +rss_feed = fg.rss_str(pretty=True) +``` +```511:533:rss_generator.py +final_posts = await _render_messages_groups(...) +... +html = '\n
\n'.join(html_posts) +``` + +- Глобальный `python-magic` в потоках + - Один общий `Magic()` используется из разных потоков — это не потокобезопасно, возможны зависания внутри libmagic. +```198:206:api_server.py +media_type = await asyncio.to_thread(magic_mime.from_file, file_path) +``` + +- BaseHTTPMiddleware над стримингом файлов + - `BaseHTTPMiddleware` оборачивает `call_next`. Для `FileResponse` это может ломать/буферизовать стриминг и усиливать задержки. +```48:62:api_server.py +class RequestLoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + ... + response = await call_next(request) +``` + +- Отсутствие таймаутов на вызовах Telegram API + - Если `get_messages/download_media` “подвисли”, запрос к `/media` висит долго. +```748:758:api_server.py +file_path, delete_after = await download_media_file(...) +``` +```232:258:api_server.py +message = await client.client.get_messages(...) +file_path = await client.client.download_media(...) +``` + +- Шумное логирование в middleware + - Лог всех заголовков и запросов при каждом хите может создавать I/O‑бутылочное горлышко под нагрузкой. +```49:59:api_server.py +logger.info(f"Request: {request.method} {request.url}") +logger.info(f"Headers: {dict(request.headers)}") +``` + +- Конкурентная запись и разрастание `media_file_ids.json` + - Записи без блокировок → гонки и порча JSON. В фоне потом ремонт json-repair (тяжёлый CPU). +```503:511:api_server.py +except json.JSONDecodeError: + media_files = await asyncio.to_thread(fix_corrupted_json, file_path) +``` + +- Один воркер uvicorn + - Любая тяжёлая задача “монополизирует” процесс — остальное, включая отдачу файлов, ждёт. +```114:121:api_server.py +uvicorn.run(..., reload=True, loop="uvloop") +``` + +### Что сделать (короткий чеклист) + +- [x] Убрать sync I/O из пути запроса + - [x] Обернуть чтение/запись JSON и pickle в `asyncio.to_thread`. + - [x] Для `media_file_ids.json` везде использовать единые sync‑хелперы с временным файлом и заменить прямые `open/json.dump`: + - [x] Вынести в отдельный модуль функции уже имеющихся `read_json_file_sync`/`write_json_file_sync` и звать их через `to_thread`. + - [x] Добавить один `asyncio.Lock` на операции записи `media_file_ids.json` чтобы не было гонок. + +- Разгрузить CPU из event loop + - В RSS/HTML: + - После того как данные получены из Telegram, вынести “склейку HTML”, bleach‑санитизацию и `fg.rss_str` в `asyncio.to_thread`. + - Ограничить `limit` по умолчанию до 50–80. Для больших значений — отдавать 429 или 400 с рекомендацией. + - Опционально — кэшировать итоговые RSS/HTML на пару минут. + +- Исправить `python-magic` + - Создавать `Magic()` per‑call внутри `to_thread` ИЛИ защищать глобальный объект `threading.Lock`. + - Если не критично — падать на `mimetypes` и не вызывать `magic` для известных расширений. + +- Middleware для логов + - В проде отключить текущий `RequestLoggingMiddleware` или перевести на ASGI‑middleware без вмешательства в стриминг. + - Снизить уровень и объём логов (заголовки — только на DEBUG, семплирование). + +- Таймауты телеграма + - Оборачивать `get_messages`/`download_media` в `asyncio.wait_for(..., timeout=30–60)` с понятной ошибкой/503. + - На FloodWait в `/media` — отдавать 429 с `Retry-After`. + +- Запись `media_file_ids.json` + - Причесать размер: периодически чистить/ротация по размеру/возрасту. + - В фоне перепись файла уже есть — не трогать event loop; убедиться, что на записи тоже используется temp‑файл + `os.replace` (как в `write_json_file_sync`). + +- Процессный параллелизм + - Запускать с несколькими воркерами, без `reload`: + - пример: `uvicorn api_server:app --host 0.0.0.0 --port 80 --workers 2 --loop uvloop --http httptools` + - Или за Gunicorn: `--workers 2 --worker-class uvicorn.workers.UvicornWorker`. + +- Пул потоков + - Явно ограничить/увеличить пул для `to_thread` если есть много фоновых задач, чтобы не выжирался из‑за долгих операций. + +### Минимальные целевые правки (по месту) + +- `_save_media_file_ids` — переписать на `await asyncio.to_thread(read_json_file_sync/ write_json_file_sync)` + общий `asyncio.Lock`. Убрать прямые `open/json.dump`. +- `tg_cache` — обернуть `pickle.load/dump` в `to_thread`. +- `prepare_file_response` — перестать шарить `magic_mime`; создавать локальный `magic.Magic(mime=True)` внутри `to_thread` или защитить глобальный объект `Lock`. +- `RequestLoggingMiddleware` — удалить/заменить на ASGI‑middleware и логировать только метод+путь, без заголовков по умолчанию. +- `get_messages/download_media` — таймауты через `asyncio.wait_for`. +- `uvicorn.run` — убрать `reload=True`, добавить `workers=2+`. + +### Как быстро проверить +- Нагрузить `/rss` c большим `limit` параллельно с `/media` и измерить задержку отдачи файлов до/после: + - до фиксов статик “замирает” во время генерации; + - после выноса CPU/I/O в `to_thread` и увеличения воркеров — файлы продолжают отдаваться. + +Коротко: основные причины — синхронный диск и CPU в корутинах, общий `python-magic`, и единичный воркер. Перенос I/O/CPU в `to_thread`, фиксы гонок JSON, таймауты на Telegram и 2–4 воркера решат подвисания. + +- Нашёл проблемные места: sync I/O и CPU в обработчиках, `python-magic`, middleware, таймауты и один воркер. Дал точечные действия для устранения подвисаний. \ No newline at end of file diff --git a/post_parser.py b/post_parser.py index d8675d1..ee3e3dc 100644 --- a/post_parser.py +++ b/post_parser.py @@ -9,9 +9,9 @@ # pylance: disable=reportMissingImports, reportMissingModuleSource import logging +import asyncio import re import os -import json import html import inspect from datetime import datetime @@ -21,6 +21,7 @@ from pyrogram.enums import MessageMediaType from bleach.css_sanitizer import CSSSanitizer from bleach import clean as HTMLSanitizer from config import get_settings +from file_io import read_json_file_sync, write_json_file_sync, get_media_file_ids_lock from url_signer import generate_media_digest Config = get_settings() @@ -913,6 +914,29 @@ class PostParser: logger.error(f"file_id_extraction_error: media_type {message.media}, error {str(e)}") return None + async def _persist_media_file_id_async(self, file_path: str, file_data: dict) -> None: + try: + existing_data = [] + if os.path.exists(file_path): + existing_data = await asyncio.to_thread(read_json_file_sync, file_path) + + found = False + for item in existing_data: + if (item.get('channel') == file_data['channel'] and + item.get('post_id') == file_data['post_id'] and + item.get('file_unique_id') == file_data['file_unique_id']): + item['added'] = datetime.now().timestamp() + found = True + break + + if not found: + existing_data.append(file_data) + + async with get_media_file_ids_lock(): + await asyncio.to_thread(write_json_file_sync, file_path, existing_data) + except Exception as e: + logger.error(f"file_id_save_error: error writing to {file_path}, error {str(e)}") + def _save_media_file_ids(self, message: Message) -> None: try: file_data = { @@ -949,27 +973,25 @@ class PostParser: file_path = os.path.join(os.path.abspath("./data"), 'media_file_ids.json') try: - existing_data = [] - if os.path.exists(file_path): - with open(file_path, 'r', encoding='utf-8') as f: - existing_data = json.load(f) - - # Check if file already exists by all three fields - found = False - for item in existing_data: - if (item.get('channel') == file_data['channel'] and - item.get('post_id') == file_data['post_id'] and - item.get('file_unique_id') == file_data['file_unique_id']): - item['added'] = datetime.now().timestamp() - found = True - break - - # Add new entry if not found - if not found: - existing_data.append(file_data) - - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(existing_data, f, ensure_ascii=False, indent=2) + loop = asyncio.get_running_loop() + if loop.is_running(): + loop.create_task(self._persist_media_file_id_async(file_path, file_data)) + else: + # Fallback sync write (should not occur during normal FastAPI runtime) + existing_data = [] + if os.path.exists(file_path): + existing_data = read_json_file_sync(file_path) # type: ignore + found = False + for item in existing_data: + if (item.get('channel') == file_data['channel'] and + item.get('post_id') == file_data['post_id'] and + item.get('file_unique_id') == file_data['file_unique_id']): + item['added'] = datetime.now().timestamp() + found = True + break + if not found: + existing_data.append(file_data) + write_json_file_sync(file_path, existing_data) # type: ignore except Exception as e: logger.error(f"file_id_save_error: error writing to {file_path}, error {str(e)}") diff --git a/tg_cache.py b/tg_cache.py index bd21b86..a9734a7 100644 --- a/tg_cache.py +++ b/tg_cache.py @@ -14,6 +14,7 @@ import json import pickle import logging import random +import asyncio import time from datetime import datetime, timedelta from typing import Any, Optional, Union, List @@ -115,7 +116,7 @@ async def cached_get_chat_history(client: Client, channel_id: Union[str, int], l Returns: List of messages, same as original client.get_chat_history() """ - cached_messages = _get_history_from_cache(channel_id, limit) + cached_messages = await asyncio.to_thread(_get_history_from_cache, channel_id, limit) if cached_messages is not None: return cached_messages @@ -125,7 +126,7 @@ async def cached_get_chat_history(client: Client, channel_id: Union[str, int], l messages = [] async for message in client.get_chat_history(channel_id, limit=limit): messages.append(message) - _save_history_to_cache(channel_id, messages, limit) + await asyncio.to_thread(_save_history_to_cache, channel_id, messages, limit) return messages except Exception as e: