mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
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:
@@ -305,6 +305,106 @@ def test_cached_zero_ttl_does_not_cache_async_result():
|
||||
assert asyncio.run(run_test()) == (1, 2)
|
||||
|
||||
|
||||
def test_cached_empty_ttl_expires_empty_result_sooner_sync():
|
||||
"""
|
||||
同步 cached 的空结果应按 empty_ttl 独立过期,不受默认 ttl 影响。
|
||||
"""
|
||||
calls = 0
|
||||
|
||||
@cached(region="sync_empty_ttl", ttl=600, empty_ttl=10,
|
||||
empty_if=lambda value: not value.get("results"))
|
||||
def load_value():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"results": []}
|
||||
|
||||
assert load_value() == {"results": []}
|
||||
assert load_value() == {"results": []}
|
||||
assert calls == 1
|
||||
|
||||
region_cache = MemoryBackend._region_caches[MemoryBackend.get_region("sync_empty_ttl")]
|
||||
started_at = region_cache.timer()
|
||||
region_cache.expire(time=started_at + 11)
|
||||
|
||||
assert load_value() == {"results": []}
|
||||
assert calls == 2
|
||||
|
||||
|
||||
def test_cached_empty_ttl_keeps_default_ttl_for_non_empty_result():
|
||||
"""
|
||||
非空结果应继续使用默认 ttl,不被 empty_ttl 缩短。
|
||||
"""
|
||||
calls = 0
|
||||
|
||||
@cached(region="sync_empty_ttl_nonempty", ttl=600, empty_ttl=10,
|
||||
empty_if=lambda value: not value.get("results"))
|
||||
def load_value():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"results": [1]}
|
||||
|
||||
assert load_value() == {"results": [1]}
|
||||
|
||||
region_cache = MemoryBackend._region_caches[MemoryBackend.get_region("sync_empty_ttl_nonempty")]
|
||||
started_at = region_cache.timer()
|
||||
region_cache.expire(time=started_at + 11)
|
||||
|
||||
assert load_value() == {"results": [1]}
|
||||
assert calls == 1
|
||||
|
||||
|
||||
def test_cached_empty_ttl_without_empty_if_uses_falsy_check():
|
||||
"""
|
||||
未提供 empty_if 时按假值判断空结果,空列表按 empty_ttl 独立过期。
|
||||
"""
|
||||
calls = 0
|
||||
|
||||
@cached(region="sync_empty_ttl_falsy", ttl=600, empty_ttl=10)
|
||||
def load_value():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return []
|
||||
|
||||
assert load_value() == []
|
||||
assert load_value() == []
|
||||
assert calls == 1
|
||||
|
||||
region_cache = MemoryBackend._region_caches[MemoryBackend.get_region("sync_empty_ttl_falsy")]
|
||||
started_at = region_cache.timer()
|
||||
region_cache.expire(time=started_at + 11)
|
||||
|
||||
assert load_value() == []
|
||||
assert calls == 2
|
||||
|
||||
|
||||
def test_cached_empty_ttl_expires_empty_result_sooner_async():
|
||||
"""
|
||||
异步 cached 的空结果应与同步路径一致,按 empty_ttl 独立过期。
|
||||
"""
|
||||
calls = 0
|
||||
|
||||
@cached(region="async_empty_ttl", ttl=600, empty_ttl=10,
|
||||
empty_if=lambda value: not value.get("results"))
|
||||
async def load_value():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"results": []}
|
||||
|
||||
async def run_first_round():
|
||||
assert await load_value() == {"results": []}
|
||||
assert await load_value() == {"results": []}
|
||||
|
||||
asyncio.run(run_first_round())
|
||||
assert calls == 1
|
||||
|
||||
region_cache = MemoryBackend._region_caches[MemoryBackend.get_region("async_empty_ttl")]
|
||||
started_at = region_cache.timer()
|
||||
region_cache.expire(time=started_at + 11)
|
||||
|
||||
assert asyncio.run(load_value()) == {"results": []}
|
||||
assert calls == 2
|
||||
|
||||
|
||||
def test_memory_backend_global_clear_is_safe_during_region_creation():
|
||||
"""
|
||||
全局清理与新 region 创建应由同一把锁串行化,不能并发修改注册表。
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from time import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.runtime.config import settings
|
||||
from app.modules.themoviedb.tmdb_cache import TmdbCache
|
||||
from app.modules.themoviedb.tmdbv3api.tmdb import EMPTY_RESULT_CACHE_TTL
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
@@ -120,3 +122,29 @@ def test_update_caches_tv_result_for_movie_meta():
|
||||
stored = cache._cache.get(_key("电影", begin_season=None))
|
||||
assert stored["type"] == MediaType.TV
|
||||
assert stored["title"] == "某剧"
|
||||
|
||||
|
||||
def test_update_negative_cache_uses_short_ttl():
|
||||
"""未识别负缓存应用独立短 TTL,故障自愈后同名可较快重新识别。"""
|
||||
meta = _build_meta(MediaType.TV)
|
||||
cache = _build_cache()
|
||||
|
||||
cache.update(meta, {})
|
||||
|
||||
key = _key("电视剧")
|
||||
assert cache._cache.data[key] == {"id": 0}
|
||||
assert cache._cache.ttls[key] == EMPTY_RESULT_CACHE_TTL
|
||||
# 持久化用的过期时间也应随短 TTL 计算,不能沿用默认有效期
|
||||
assert cache._expires_at[key] <= time() + EMPTY_RESULT_CACHE_TTL + 5
|
||||
|
||||
|
||||
def test_update_positive_cache_keeps_default_ttl():
|
||||
"""识别成功的正缓存不受负缓存短 TTL 影响,仍用默认有效期。"""
|
||||
meta = _build_meta(MediaType.TV)
|
||||
cache = _build_cache()
|
||||
|
||||
cache.update(meta, {"id": 329809, "media_type": MediaType.TV,
|
||||
"name": "某剧", "first_air_date": "2022-01-01"})
|
||||
|
||||
key = _key("电视剧")
|
||||
assert cache._cache.ttls[key] == cache.ttl
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
TMDB request 层空结果快照短 TTL 缓存测试。
|
||||
|
||||
TMDB 代理故障期间「合法 JSON 但 results 为空」的响应若随默认 TTL(可达数十小时)
|
||||
固化,故障自愈后同名搜索仍持续命中空结果。空结果快照必须仍入缓存(拦住故障窗口
|
||||
内的重复回源),但改用独立的 30 分钟短 TTL,过期后自然恢复回源。
|
||||
"""
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.modules.themoviedb.tmdbv3api.tmdb import (
|
||||
EMPTY_RESULT_CACHE_TTL,
|
||||
TMDb,
|
||||
_is_empty_result_snapshot,
|
||||
)
|
||||
from app.runtime.cache import MemoryBackend
|
||||
|
||||
from tests.test_tmdb_response_cache import _FakeResponse
|
||||
|
||||
EMPTY_PAYLOAD = {"page": 1, "results": [], "total_results": 0, "total_pages": 0}
|
||||
NOT_EMPTY_PAYLOAD = {"page": 1, "results": [{"id": 1}], "total_results": 1, "total_pages": 1}
|
||||
HEADERS = {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _snapshot(payload: dict) -> dict:
|
||||
"""构造一个带快照标记的响应结构。"""
|
||||
return {TMDb._RESPONSE_SNAPSHOT_MARKER: True, "headers": {}, "json": payload}
|
||||
|
||||
|
||||
def _request_region_cache():
|
||||
"""取出 TMDb.request 装饰器使用的内存缓存区实例。"""
|
||||
return MemoryBackend._region_caches[MemoryBackend.get_region(TMDb.request.cache_region)]
|
||||
|
||||
|
||||
def _make_tmdb() -> TMDb:
|
||||
"""构造带测试 API Key 的 TMDb 客户端。"""
|
||||
tmdb = TMDb()
|
||||
tmdb.api_key = "test-key"
|
||||
return tmdb
|
||||
|
||||
|
||||
def test_empty_result_cache_ttl_is_thirty_minutes():
|
||||
"""空结果缓存的独立过期时间应为 30 分钟。"""
|
||||
assert EMPTY_RESULT_CACHE_TTL == 30 * 60
|
||||
|
||||
|
||||
def test_empty_result_snapshot_predicate():
|
||||
"""空结果谓词只认 results 为空列表的快照,详情与有结果的响应不算空。"""
|
||||
assert _is_empty_result_snapshot(_snapshot(EMPTY_PAYLOAD))
|
||||
assert not _is_empty_result_snapshot(_snapshot(NOT_EMPTY_PAYLOAD))
|
||||
assert not _is_empty_result_snapshot(_snapshot({"id": 98865, "title": "Test"}))
|
||||
# json 非字典或无 results 字段时不能误判为空结果
|
||||
assert not _is_empty_result_snapshot(_snapshot("upstream error"))
|
||||
assert not _is_empty_result_snapshot(None)
|
||||
|
||||
|
||||
def test_empty_result_is_cached_but_expires_with_short_ttl():
|
||||
"""空结果快照仍入缓存避免重复回源,但按短 TTL 过期后恢复回源。"""
|
||||
tmdb = _make_tmdb()
|
||||
url = "https://api.tmdb.test/empty-short-ttl"
|
||||
fake = _FakeResponse(EMPTY_PAYLOAD, HEADERS)
|
||||
|
||||
with patch.object(TMDb, "_request_once", return_value=fake) as req:
|
||||
tmdb.request("GET", url, None, None)
|
||||
tmdb.request("GET", url, None, None)
|
||||
assert req.call_count == 1
|
||||
|
||||
region_cache = _request_region_cache()
|
||||
started_at = region_cache.timer()
|
||||
region_cache.expire(time=started_at + EMPTY_RESULT_CACHE_TTL + 1)
|
||||
|
||||
with patch.object(TMDb, "_request_once", return_value=fake) as req:
|
||||
tmdb.request("GET", url, None, None)
|
||||
assert req.call_count == 1
|
||||
|
||||
|
||||
def test_non_empty_result_keeps_default_ttl():
|
||||
"""有结果的响应不受短 TTL 影响,过期点仍为默认元数据缓存 TTL 之后。"""
|
||||
tmdb = _make_tmdb()
|
||||
url = "https://api.tmdb.test/non-empty-default-ttl"
|
||||
fake = _FakeResponse(NOT_EMPTY_PAYLOAD, HEADERS)
|
||||
|
||||
with patch.object(TMDb, "_request_once", return_value=fake) as req:
|
||||
tmdb.request("GET", url, None, None)
|
||||
assert req.call_count == 1
|
||||
|
||||
region_cache = _request_region_cache()
|
||||
started_at = region_cache.timer()
|
||||
# 推进到短 TTL 之后:非空结果不应在此刻过期
|
||||
region_cache.expire(time=started_at + EMPTY_RESULT_CACHE_TTL + 1)
|
||||
|
||||
tmdb.request("GET", url, None, None)
|
||||
assert req.call_count == 1
|
||||
|
||||
|
||||
def test_async_empty_result_is_cached_with_short_ttl():
|
||||
"""异步请求的空结果快照与同步路径一致,入缓存但按短 TTL 过期。"""
|
||||
tmdb = _make_tmdb()
|
||||
url = "https://api.tmdb.test/async-empty-short-ttl"
|
||||
fake = _FakeResponse(EMPTY_PAYLOAD, HEADERS)
|
||||
|
||||
with patch.object(TMDb, "_async_request_once", return_value=fake) as req:
|
||||
asyncio.run(tmdb.async_request("GET", url, None, None))
|
||||
asyncio.run(tmdb.async_request("GET", url, None, None))
|
||||
assert req.call_count == 1
|
||||
|
||||
region_cache = _request_region_cache()
|
||||
started_at = region_cache.timer()
|
||||
region_cache.expire(time=started_at + EMPTY_RESULT_CACHE_TTL + 1)
|
||||
|
||||
with patch.object(TMDb, "_async_request_once", return_value=fake) as req:
|
||||
asyncio.run(tmdb.async_request("GET", url, None, None))
|
||||
assert req.call_count == 1
|
||||
Reference in New Issue
Block a user