mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-15 14:38:46 +08:00
fix: improve cache locking mechanism and enhance key handling in file and redis backends
This commit is contained in:
@@ -421,7 +421,8 @@ class MemoryBackend(CacheBackend):
|
||||
region_cache = self.__get_region_cache(region)
|
||||
if region_cache is None:
|
||||
return False
|
||||
return key in region_cache
|
||||
with self._lock:
|
||||
return key in region_cache
|
||||
|
||||
def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Any:
|
||||
"""
|
||||
@@ -434,7 +435,8 @@ class MemoryBackend(CacheBackend):
|
||||
region_cache = self.__get_region_cache(region)
|
||||
if region_cache is None:
|
||||
return None
|
||||
return region_cache.get(key)
|
||||
with self._lock:
|
||||
return region_cache.get(key)
|
||||
|
||||
def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION):
|
||||
"""
|
||||
@@ -447,7 +449,8 @@ class MemoryBackend(CacheBackend):
|
||||
if region_cache is None:
|
||||
return
|
||||
with self._lock:
|
||||
del region_cache[key]
|
||||
if key in region_cache:
|
||||
del region_cache[key]
|
||||
|
||||
def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:
|
||||
"""
|
||||
@@ -803,8 +806,10 @@ class FileBackend(CacheBackend):
|
||||
:param region: 缓存的区
|
||||
"""
|
||||
cache_path = self.base / region / key
|
||||
if cache_path.exists():
|
||||
if cache_path.is_file():
|
||||
cache_path.unlink()
|
||||
elif cache_path.exists():
|
||||
shutil.rmtree(cache_path, ignore_errors=True)
|
||||
|
||||
def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:
|
||||
"""
|
||||
@@ -840,10 +845,11 @@ class FileBackend(CacheBackend):
|
||||
if not cache_path.exists():
|
||||
yield from ()
|
||||
return
|
||||
for item in cache_path.iterdir():
|
||||
for item in sorted(cache_path.rglob("*")):
|
||||
if item.is_file():
|
||||
with open(item, 'r') as f:
|
||||
yield item.as_posix(), f.read()
|
||||
key = item.relative_to(cache_path).as_posix()
|
||||
with open(item, 'rb') as f:
|
||||
yield key, f.read()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
@@ -916,8 +922,10 @@ class AsyncFileBackend(AsyncCacheBackend):
|
||||
:param region: 缓存的区
|
||||
"""
|
||||
cache_path = AsyncPath(self.base) / region / key
|
||||
if await cache_path.exists():
|
||||
if await cache_path.is_file():
|
||||
await cache_path.unlink()
|
||||
elif await cache_path.exists():
|
||||
await aioshutil.rmtree(cache_path, ignore_errors=True)
|
||||
|
||||
async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:
|
||||
"""
|
||||
@@ -951,12 +959,12 @@ class AsyncFileBackend(AsyncCacheBackend):
|
||||
"""
|
||||
cache_path = AsyncPath(self.base) / region
|
||||
if not await cache_path.exists():
|
||||
yield "", None
|
||||
return
|
||||
async for item in cache_path.iterdir():
|
||||
async for item in cache_path.rglob("*"):
|
||||
if await item.is_file():
|
||||
async with aiofiles.open(item, 'r') as f:
|
||||
yield item.as_posix(), await f.read()
|
||||
key = Path(str(item)).relative_to(Path(str(cache_path))).as_posix()
|
||||
async with aiofiles.open(item, 'rb') as f:
|
||||
yield key, await f.read()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import json
|
||||
import pickle
|
||||
from typing import Any, Optional, Generator, Tuple, AsyncGenerator, Union
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import redis
|
||||
from redis.asyncio import Redis
|
||||
@@ -160,7 +160,7 @@ class RedisHelper(ConfigReloadMixin, metaclass=Singleton):
|
||||
if isinstance(redis_key, bytes):
|
||||
redis_key = redis_key.decode("utf-8")
|
||||
parts = redis_key.split(":key:")
|
||||
return parts[-1]
|
||||
return unquote(parts[-1])
|
||||
except Exception as e:
|
||||
logger.warn(f"Failed to parse redis key: {redis_key}, error: {e}")
|
||||
return redis_key
|
||||
@@ -410,7 +410,7 @@ class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton):
|
||||
if isinstance(redis_key, bytes):
|
||||
redis_key = redis_key.decode("utf-8")
|
||||
parts = redis_key.split(":key:")
|
||||
return parts[-1]
|
||||
return unquote(parts[-1])
|
||||
except Exception as e:
|
||||
logger.warn(f"Failed to parse redis key: {redis_key}, error: {e}")
|
||||
return redis_key
|
||||
|
||||
92
tests/test_cache_system.py
Normal file
92
tests/test_cache_system.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import asyncio
|
||||
|
||||
from app.core.cache import AsyncFileBackend, FileBackend, MemoryBackend
|
||||
from app.helper.redis import RedisHelper
|
||||
|
||||
|
||||
def test_file_backend_items_keep_relative_keys_and_bytes(tmp_path):
|
||||
"""
|
||||
文件缓存遍历应返回可继续删除的相对 key,并保持二进制内容不变。
|
||||
"""
|
||||
cache = FileBackend(base=tmp_path)
|
||||
cache.set("nested/poster.jpg", b"\xff\xd8image", region="images")
|
||||
|
||||
items = list(cache.items(region="images"))
|
||||
|
||||
assert items == [("nested/poster.jpg", b"\xff\xd8image")]
|
||||
assert cache.popitem(region="images") == ("nested/poster.jpg", b"\xff\xd8image")
|
||||
assert not cache.exists("nested/poster.jpg", region="images")
|
||||
|
||||
|
||||
def test_file_backend_delete_missing_key_is_noop(tmp_path):
|
||||
"""
|
||||
删除不存在的文件缓存 key 应保持幂等,不向调用方抛出文件系统异常。
|
||||
"""
|
||||
cache = FileBackend(base=tmp_path)
|
||||
|
||||
cache.delete("missing", region="default")
|
||||
|
||||
assert not cache.exists("missing", region="default")
|
||||
|
||||
|
||||
def test_memory_backend_delete_missing_key_is_noop():
|
||||
"""
|
||||
内存缓存后端 delete 与其他后端保持一致,不存在时直接返回。
|
||||
"""
|
||||
cache = MemoryBackend()
|
||||
|
||||
cache.delete("missing", region="missing_delete")
|
||||
|
||||
assert not cache.exists("missing", region="missing_delete")
|
||||
|
||||
|
||||
def test_redis_original_key_decodes_quoted_key():
|
||||
"""
|
||||
Redis items 返回的 key 应还原为原始缓存 key,确保带特殊字符的 key 可继续删除。
|
||||
"""
|
||||
redis_key = b"region:DEFAULT:key:nested/poster%20one.jpg"
|
||||
|
||||
assert RedisHelper._RedisHelper__get_original_key(redis_key) == "nested/poster one.jpg"
|
||||
|
||||
|
||||
def test_async_file_backend_missing_region_has_no_items(tmp_path):
|
||||
"""
|
||||
异步文件缓存缺失区域时应返回空迭代,而不是伪造空 key。
|
||||
"""
|
||||
|
||||
async def collect_items():
|
||||
cache = AsyncFileBackend(base=tmp_path)
|
||||
return [item async for item in cache.items(region="missing")]
|
||||
|
||||
assert asyncio.run(collect_items()) == []
|
||||
|
||||
|
||||
def test_async_file_backend_items_keep_relative_keys_and_bytes(tmp_path):
|
||||
"""
|
||||
异步文件缓存遍历应与同步文件缓存保持相同 key 和二进制语义。
|
||||
"""
|
||||
|
||||
async def collect_items():
|
||||
cache = AsyncFileBackend(base=tmp_path)
|
||||
await cache.set("nested/poster.jpg", b"\xff\xd8image", region="images")
|
||||
items = [item async for item in cache.items(region="images")]
|
||||
popped = await cache.popitem(region="images")
|
||||
exists = await cache.exists("nested/poster.jpg", region="images")
|
||||
return items, popped, exists
|
||||
|
||||
items, popped, exists = asyncio.run(collect_items())
|
||||
|
||||
assert items == [("nested/poster.jpg", b"\xff\xd8image")]
|
||||
assert popped == ("nested/poster.jpg", b"\xff\xd8image")
|
||||
assert not exists
|
||||
|
||||
|
||||
def test_file_backend_items_skip_directories(tmp_path):
|
||||
"""
|
||||
文件缓存遍历应递归读取有效缓存文件,不把目录当成缓存项。
|
||||
"""
|
||||
cache = FileBackend(base=tmp_path)
|
||||
cache.set("nested/value", b"value", region="region")
|
||||
(tmp_path / "region" / "empty_dir").mkdir()
|
||||
|
||||
assert list(cache.items(region="region")) == [("nested/value", b"value")]
|
||||
Reference in New Issue
Block a user