From fa24ec4e2af2bcfa1751ca0e6ef156cdbb356085 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 2 Jul 2026 11:39:10 +0200 Subject: [PATCH] fix(core): stop blocking the event loop in OpenAI parsing and static routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAIParser now uses AsyncOpenAI — the previous sync SDK call ran directly on the event loop (submitting to a ThreadPoolExecutor and immediately blocking on .result() serialized it anyway). TitleParser.raw_parser and its callers become async accordingly. The SPA catch-all route listed dist/ on every request; snapshot it once at startup. The module-level bangumi TTL cache is now lock-guarded — it is written from asyncio.to_thread workers in notification paths while the event loop reads it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh --- backend/src/main.py | 7 +++-- backend/src/module/database/bangumi.py | 29 ++++++++++++-------- backend/src/module/parser/analyser/openai.py | 16 ++++------- backend/src/module/parser/title_parser.py | 4 +-- backend/src/module/rss/analyser.py | 4 +-- backend/src/test/test_issue_bugs.py | 8 +++--- backend/src/test/test_openai.py | 4 +-- backend/src/test/test_title_parser.py | 8 +++--- 8 files changed, 43 insertions(+), 37 deletions(-) diff --git a/backend/src/main.py b/backend/src/main.py index cd2b6bc1..25d33ed2 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -83,10 +83,13 @@ if VERSION != "DEV_VERSION": # app.mount("/icons", StaticFiles(directory="dist/icons"), name="icons") templates = Jinja2Templates(directory="dist") + # dist/ is immutable inside the container — snapshot once instead of + # hitting the filesystem on every request. + _DIST_FILES = frozenset(os.listdir("dist")) + @app.get("/{path:path}") def html(request: Request, path: str): - files = os.listdir("dist") - if path in files: + if path in _DIST_FILES: return FileResponse(f"dist/{path}") else: context = {"request": request} diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index 07649122..92f550d0 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -1,6 +1,7 @@ import json import logging import re +import threading import time from typing import Optional @@ -64,16 +65,20 @@ def _set_aliases_list(bangumi: Bangumi, aliases: list[str]) -> None: bangumi.title_aliases = json.dumps(unique_aliases, ensure_ascii=False) -# Module-level TTL cache for search_all results +# Module-level TTL cache for search_all results. +# Guarded by a lock: notification paths read/write it from asyncio.to_thread +# worker threads while the event loop uses it concurrently. _bangumi_cache: list[Bangumi] | None = None _bangumi_cache_time: float = 0 _BANGUMI_CACHE_TTL: float = 300.0 # 5 minutes - extended from 60s to reduce DB queries +_bangumi_cache_lock = threading.Lock() def _invalidate_bangumi_cache(): global _bangumi_cache, _bangumi_cache_time - _bangumi_cache = None - _bangumi_cache_time = 0 + with _bangumi_cache_lock: + _bangumi_cache = None + _bangumi_cache_time = 0 class BangumiDatabase: @@ -365,11 +370,12 @@ class BangumiDatabase: def search_all(self) -> list[Bangumi]: global _bangumi_cache, _bangumi_cache_time now = time.time() - if ( - _bangumi_cache is not None - and (now - _bangumi_cache_time) < _BANGUMI_CACHE_TTL - ): - return _bangumi_cache + with _bangumi_cache_lock: + if ( + _bangumi_cache is not None + and (now - _bangumi_cache_time) < _BANGUMI_CACHE_TTL + ): + return _bangumi_cache statement = select(Bangumi) result = self.session.execute(statement) bangumis = list(result.scalars().all()) @@ -377,9 +383,10 @@ class BangumiDatabase: # cached objects are accessed from a different session/request context for b in bangumis: self.session.expunge(b) - _bangumi_cache = bangumis - _bangumi_cache_time = now - return _bangumi_cache + with _bangumi_cache_lock: + _bangumi_cache = bangumis + _bangumi_cache_time = now + return bangumis def search_id(self, _id: int) -> Optional[Bangumi]: statement = select(Bangumi).where(Bangumi.id == _id) diff --git a/backend/src/module/parser/analyser/openai.py b/backend/src/module/parser/analyser/openai.py index 5aa7a981..eb31b595 100644 --- a/backend/src/module/parser/analyser/openai.py +++ b/backend/src/module/parser/analyser/openai.py @@ -1,9 +1,8 @@ import json import logging -from concurrent.futures import ThreadPoolExecutor from typing import Any, Optional -from openai import AzureOpenAI, OpenAI +from openai import AsyncAzureOpenAI, AsyncOpenAI from pydantic import BaseModel from module.models import Bangumi @@ -62,19 +61,19 @@ class OpenAIParser: if not api_key: raise ValueError("API key is required.") if api_type == "azure": - self.client = AzureOpenAI( + self.client = AsyncAzureOpenAI( api_key=api_key, base_url=api_base, azure_deployment=kwargs.get("deployment_id", ""), api_version=kwargs.get("api_version", "2023-05-15"), ) else: - self.client = OpenAI(api_key=api_key, base_url=api_base) + self.client = AsyncOpenAI(api_key=api_key, base_url=api_base) self.model = model self.openai_kwargs = kwargs - def parse( + async def parse( self, text: str, prompt: str | None = None, asdict: bool = True ) -> dict | str: """parse text with openai @@ -96,11 +95,8 @@ class OpenAIParser: params = self._prepare_params(text, prompt) - with ThreadPoolExecutor(max_workers=1) as worker: - future = worker.submit(self.client.beta.chat.completions.parse, **params) - resp = future.result() - - result = resp.choices[0].message.parsed + resp = await self.client.beta.chat.completions.parse(**params) + result = resp.choices[0].message.parsed if asdict: if hasattr(result, "model_dump"): diff --git a/backend/src/module/parser/title_parser.py b/backend/src/module/parser/title_parser.py index b56b23fb..e19fc86e 100644 --- a/backend/src/module/parser/title_parser.py +++ b/backend/src/module/parser/title_parser.py @@ -57,14 +57,14 @@ class TitleParser: logger.warning("Please change bangumi info manually.") @staticmethod - def raw_parser(raw: str) -> Bangumi | None: + async def raw_parser(raw: str) -> Bangumi | None: language = settings.rss_parser.language try: # use OpenAI ChatGPT to parse raw title and get structured data if settings.experimental_openai.enable: kwargs = settings.experimental_openai.dict(exclude={"enable"}) gpt = OpenAIParser(**kwargs) - episode_dict = gpt.parse(raw, asdict=True) + episode_dict = await gpt.parse(raw, asdict=True) episode = Episode(**episode_dict) else: episode = raw_parser(raw) diff --git a/backend/src/module/rss/analyser.py b/backend/src/module/rss/analyser.py index cbd7a9af..d4279b26 100644 --- a/backend/src/module/rss/analyser.py +++ b/backend/src/module/rss/analyser.py @@ -49,7 +49,7 @@ class RSSAnalyser(TitleParser): new_data = [] seen_titles: set[str] = set() for torrent in torrents: - bangumi = self.raw_parser(raw=torrent.name) + bangumi = await self.raw_parser(raw=torrent.name) if bangumi and bangumi.title_raw not in seen_titles: await self.official_title_parser(bangumi=bangumi, rss=rss, torrent=torrent) if not full_parse: @@ -60,7 +60,7 @@ class RSSAnalyser(TitleParser): return new_data async def torrent_to_data(self, torrent: Torrent, rss: RSSItem) -> Bangumi: - bangumi = self.raw_parser(raw=torrent.name) + bangumi = await self.raw_parser(raw=torrent.name) if bangumi: await self.official_title_parser(bangumi=bangumi, rss=rss, torrent=torrent) bangumi.rss_link = rss.url diff --git a/backend/src/test/test_issue_bugs.py b/backend/src/test/test_issue_bugs.py index 80f3114a..71d6a40d 100644 --- a/backend/src/test/test_issue_bugs.py +++ b/backend/src/test/test_issue_bugs.py @@ -340,11 +340,11 @@ class TestIssue990NumberPrefixTitle: assert result.resolution == "1080P" assert result.group == "ANi" - def test_title_parser_returns_bangumi_for_number_prefix_title(self): + async def test_title_parser_returns_bangumi_for_number_prefix_title(self): """TitleParser.raw_parser returns a valid Bangumi for number-prefixed titles.""" from module.parser.title_parser import TitleParser - result = TitleParser.raw_parser(self.PROBLEM_TITLE) + result = await TitleParser.raw_parser(self.PROBLEM_TITLE) assert result is not None assert result.official_title == "29 岁单身中坚冒险家的日常" assert result.title_raw == "29 岁单身中坚冒险家的日常" @@ -504,11 +504,11 @@ class TestIssue992NonEpisodicAttributeError: ] @pytest.mark.parametrize("title", NON_EPISODIC_TITLES) - def test_title_parser_returns_none_for_non_episodic(self, title): + async def test_title_parser_returns_none_for_non_episodic(self, title): """TitleParser.raw_parser should return None instead of crashing.""" from module.parser.title_parser import TitleParser - result = TitleParser.raw_parser(title) + result = await TitleParser.raw_parser(title) assert result is None def test_raw_parser_returns_none_for_unparseable(self): diff --git a/backend/src/test/test_openai.py b/backend/src/test/test_openai.py index db25cf0c..4d2ee08e 100644 --- a/backend/src/test/test_openai.py +++ b/backend/src/test/test_openai.py @@ -51,7 +51,7 @@ class TestOpenAIParser: params = azure_parser._prepare_params(text, DEFAULT_PROMPT) assert expected == params - def test_parse(self): + async def test_parse(self): text = "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]" expected = { "group": "梦蓝字幕组", @@ -69,5 +69,5 @@ class TestOpenAIParser: with mock.patch("module.parser.analyser.OpenAIParser.parse") as mocker: mocker.return_value = json.dumps(expected) - result = self.parser.parse(text=text, asdict=False) + result = await self.parser.parse(text=text, asdict=False) assert json.loads(result) == expected diff --git a/backend/src/test/test_title_parser.py b/backend/src/test/test_title_parser.py index b23ab4a2..012ac25f 100644 --- a/backend/src/test/test_title_parser.py +++ b/backend/src/test/test_title_parser.py @@ -4,9 +4,9 @@ from module.parser.title_parser import TitleParser class TestTitleParser: - def test_parse_without_openai(self): + async def test_parse_without_openai(self): text = "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]" - result = TitleParser.raw_parser(text) + result = await TitleParser.raw_parser(text) assert result.group_name == "梦蓝字幕组" assert result.title_raw == "New Doraemon" assert result.dpi == "1080P" @@ -17,9 +17,9 @@ class TestTitleParser: not settings.experimental_openai.enable, reason="OpenAI is not enabled in settings", ) - def test_parse_with_openai(self): + async def test_parse_with_openai(self): text = "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]" - result = TitleParser.raw_parser(text) + result = await TitleParser.raw_parser(text) assert result.group_name == "梦蓝字幕组" assert result.title_raw == "New Doraemon" assert result.dpi == "1080P"