fix(tmdb): 空结果缓存使用独立短 TTL,故障恢复后快速自愈,关闭 #6332

- cached 装饰器新增 empty_ttl/empty_if,空结果按独立短 TTL 写入
- TMDB request/async_request/discover 空结果快照 30 分钟过期
- TmdbCache 未识别负缓存(id=0)同步改用 30 分钟短 TTL,正缓存不变
This commit is contained in:
jxxghp
2026-08-16 20:50:55 +08:00
parent 76a9223713
commit fa06a4ac03
7 changed files with 311 additions and 18 deletions
+11 -6
View File
@@ -3,7 +3,7 @@ import traceback
from math import ceil
from threading import RLock
from time import time
from typing import Any
from typing import Any, Optional
from app.runtime.cache import FileCache, TTLCache
from app.runtime.config import settings
@@ -11,6 +11,7 @@ from app.domain.meta.metabase import MetaBase
from app.runtime.log import logger
from app.schemas.types import MediaSource, MediaType
from app.foundation.singleton import WeakSingleton
from app.modules.themoviedb.tmdbv3api.tmdb import EMPTY_RESULT_CACHE_TTL
lock = RLock()
PERSISTENCE_VERSION = 1
@@ -94,11 +95,12 @@ class TmdbCache(metaclass=WeakSingleton):
except Exception as err:
logger.error(f"加载TMDB识别缓存失败:{str(err)} - {traceback.format_exc()}")
def _set(self, key: str, value: dict) -> None:
"""写入单条 TMDB 识别缓存并记录其独立过期时间。"""
self._cache.set(key, value)
def _set(self, key: str, value: dict, ttl: Optional[int] = None) -> None:
"""写入单条 TMDB 识别缓存并记录其独立过期时间,未指定 ttl 时用默认有效期"""
ttl = self.ttl if ttl is None else ttl
self._cache.set(key, value, ttl=ttl)
if not self._cache.is_redis():
self._expires_at[key] = time() + self.ttl
self._expires_at[key] = time() + ttl
self._dirty = True
def clear(self):
@@ -257,7 +259,10 @@ class TmdbCache(metaclass=WeakSingleton):
elif info is not None:
# None时不缓存,此时代表网络错误,允许重复请求
with lock:
self._set(key, {"id": 0})
# 负识别缓存使用独立的短 TTL:故障期间「合法 JSON 但结果为空」会被
# 记为未识别,若按完整有效期固化,故障自愈后同名仍会被判无法识别;
# 短过期让恢复后可重新识别,真不存在的条目过期后重新确认一次即可
self._set(key, {"id": 0}, ttl=EMPTY_RESULT_CACHE_TTL)
def save(self, force: bool = False) -> None:
"""
@@ -1,5 +1,5 @@
from app.runtime.cache import cached
from ..tmdb import TMDb
from ..tmdb import TMDb, EMPTY_RESULT_CACHE_TTL
try:
from urllib import urlencode
@@ -13,7 +13,7 @@ class Discover(TMDb):
"tv": "/discover/tv"
}
@cached(maxsize=1, ttl=43200)
@cached(maxsize=1, ttl=43200, empty_ttl=EMPTY_RESULT_CACHE_TTL)
def discover_movies(self, params_tuple):
"""
Discover movies by different types of data like average rating, number of votes, genres and certifications.
@@ -23,7 +23,7 @@ class Discover(TMDb):
params = dict(params_tuple)
return self._request_obj(self._urls["movies"], urlencode(params), key="results", call_cached=False)
@cached(maxsize=1, ttl=43200)
@cached(maxsize=1, ttl=43200, empty_ttl=EMPTY_RESULT_CACHE_TTL)
def discover_tv_shows(self, params_tuple):
"""
Discover TV shows by different types of data like average rating, number of votes, genres,
@@ -33,7 +33,7 @@ class Discover(TMDb):
"""
return self._request_obj(self._urls["tv"], urlencode(params_tuple), key="results", call_cached=False)
@cached(maxsize=1, ttl=43200)
@cached(maxsize=1, ttl=43200, empty_ttl=EMPTY_RESULT_CACHE_TTL)
async def async_discover_movies(self, params_tuple):
"""
Discover movies by different types of data like average rating, number of votes, genres and certifications.(异步版本)
@@ -43,7 +43,7 @@ class Discover(TMDb):
params = dict(params_tuple)
return await self._async_request_obj(self._urls["movies"], urlencode(params), key="results", call_cached=False)
@cached(maxsize=1, ttl=43200)
@cached(maxsize=1, ttl=43200, empty_ttl=EMPTY_RESULT_CACHE_TTL)
async def async_discover_tv_shows(self, params_tuple):
"""
Discover TV shows by different types of data like average rating, number of votes, genres,
+26 -2
View File
@@ -22,6 +22,11 @@ logger = logging.getLogger(__name__)
# 故取区间内的经验值。
RETRY_BACKOFF_SECONDS = 2
# 空结果缓存的独立过期时间(秒)。TMDB 代理故障期间「合法 JSON 但 results 为空」的
# 响应会随默认 TTL(可达数十小时)固化,故障自愈后同 key 仍持续命中空结果;空结果
# 改用 30 分钟短 TTL,既能拦住故障窗口内的重复回源,又能在故障恢复后较快自然失效。
EMPTY_RESULT_CACHE_TTL = 30 * 60
def _is_business_failure_snapshot(snapshot) -> bool:
"""
@@ -37,6 +42,23 @@ def _is_business_failure_snapshot(snapshot) -> bool:
return isinstance(json_data, dict) and json_data.get("success") is False
def _is_empty_result_snapshot(snapshot) -> bool:
"""
判断响应快照是否为空结果(列表/搜索类接口的 results 为空列表)。
这类快照结构合法但无业务内容,常由代理瞬时故障产生;不能靠 skip_none/skip_empty
识别(快照本身是非空字典),需单独谓词判定后按 EMPTY_RESULT_CACHE_TTL 短 TTL
缓存。详情类接口无 results 字段,不属于空结果。
"""
if not isinstance(snapshot, dict):
return False
json_data = snapshot.get("json")
if not isinstance(json_data, dict):
return False
results = json_data.get("results")
return isinstance(results, list) and not results
class TMDb(object):
_RESPONSE_SNAPSHOT_MARKER = "__mp_tmdb_response_snapshot__"
@@ -157,7 +179,8 @@ class TMDb(object):
self._wait_on_rate_limit = bool(wait_on_rate_limit)
@cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True,
skip_if=_is_business_failure_snapshot)
skip_if=_is_business_failure_snapshot,
empty_ttl=EMPTY_RESULT_CACHE_TTL, empty_if=_is_empty_result_snapshot)
def request(self, method, url, data, json, **kwargs):
req = self._request_once(method, url, data, json)
if req is None and method == "GET" and self._owns_session:
@@ -182,7 +205,8 @@ class TMDb(object):
return self._req.post_res(url, data=data, json=json)
@cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True,
skip_if=_is_business_failure_snapshot)
skip_if=_is_business_failure_snapshot,
empty_ttl=EMPTY_RESULT_CACHE_TTL, empty_if=_is_empty_result_snapshot)
async def async_request(self, method, url, data, json, **kwargs):
req = await self._async_request_once(method, url, data, json)
if req is None: