From 7c2390908a79883ff90cebefca4cfc9f24cc1518 Mon Sep 17 00:00:00 2001 From: Ekko Date: Tue, 21 Jul 2026 07:08:21 +0800 Subject: [PATCH] Fix/memory cache ttl (#6160) --- app/core/cache.py | 126 +++++++++----- tests/test_cache_system.py | 339 +++++++++++++++++++++++++++++++++++-- 2 files changed, 412 insertions(+), 53 deletions(-) diff --git a/app/core/cache.py b/app/core/cache.py index 24076644..e0bf7c86 100644 --- a/app/core/cache.py +++ b/app/core/cache.py @@ -13,7 +13,7 @@ import aiofiles import aioshutil from anyio import Path as AsyncPath from cachetools import LRUCache as MemoryLRUCache -from cachetools import TTLCache as MemoryTTLCache +from cachetools import TLRUCache as MemoryTLRUCache from cachetools.keys import hashkey from app.core.config import settings @@ -357,15 +357,52 @@ class AsyncCacheBackend(CacheBackend): pass +class _MemoryTLRUCache(MemoryTLRUCache): + """ + 支持为每个 key 设置独立 TTL 的内存缓存 + """ + + def __init__(self, maxsize: int, ttl: int): + self.__ttl = ttl + self.__setting_ttls: Dict[str, int] = {} + super().__init__(maxsize=maxsize, ttu=self._get_expiration) + + def _get_expiration(self, key: str, _value: Any, now: float) -> float: + return now + self.__setting_ttls.get(key, self.__ttl) + + @property + def ttl(self) -> int: + """ + 默认缓存存活时间,单位秒 + """ + return self.__ttl + + def set(self, key: str, value: Any, ttl: int) -> None: + """ + 使用指定 TTL 设置缓存值 + """ + if ttl <= 0: + try: + del self[key] + except KeyError: + pass + return + self.__setting_ttls[key] = ttl + try: + super().__setitem__(key, value) + finally: + self.__setting_ttls.pop(key, None) + + class MemoryBackend(CacheBackend): """ - 基于 `cachetools.TTLCache` 实现的缓存后端 + 基于 `cachetools.TLRUCache` 实现的缓存后端 """ # 类变量 _region_caches 的互斥锁 _lock = threading.Lock() - # 存储各个 region 的缓存实例,region -> TTLCache - _region_caches: Dict[str, Union[MemoryTTLCache, MemoryLRUCache]] = {} + # 存储各个 region 的缓存实例,region -> TLRUCache/LRUCache + _region_caches: Dict[str, Union[_MemoryTLRUCache, MemoryLRUCache]] = {} def __init__(self, cache_type: Literal['ttl', 'lru'] = 'ttl', maxsize: Optional[int] = None, ttl: Optional[int] = None): @@ -378,9 +415,9 @@ class MemoryBackend(CacheBackend): """ self.cache_type = cache_type self.maxsize = maxsize or DEFAULT_CACHE_SIZE - self.ttl = ttl or DEFAULT_CACHE_TTL + self.ttl = DEFAULT_CACHE_TTL if ttl is None else ttl - def __get_region_cache(self, region: str) -> Optional[Union[MemoryTTLCache, MemoryLRUCache]]: + def __get_region_cache(self, region: str) -> Optional[Union[_MemoryTLRUCache, MemoryLRUCache]]: """ 获取指定区域的缓存实例,如果不存在则返回 None """ @@ -394,21 +431,29 @@ class MemoryBackend(CacheBackend): :param key: 缓存的键 :param value: 缓存的值 - :param ttl: 缓存的存活时间,不传入为永久缓存,单位秒 + :param ttl: 缓存的存活时间,未传入则使用 backend 默认值,单位秒 :param region: 缓存的区 """ - ttl = ttl or self.ttl - maxsize = kwargs.get("maxsize", self.maxsize) + ttl = self.ttl if ttl is None else ttl + maxsize = kwargs.get("maxsize") or self.maxsize region = self.get_region(region) # 设置缓存值 with self._lock: - # 如果该 key 尚未有缓存实例,则创建一个新的 TTLCache 实例 - region_cache = self._region_caches.setdefault( - region, - MemoryTTLCache(maxsize=maxsize, ttl=ttl) if self.cache_type == 'ttl' - else MemoryLRUCache(maxsize=maxsize) - ) - region_cache[key] = value + region_cache = self._region_caches.get(region) + if region_cache is None: + region_cache = ( + _MemoryTLRUCache(maxsize=maxsize, ttl=ttl) if self.cache_type == 'ttl' + else MemoryLRUCache(maxsize=maxsize) + ) + self._region_caches[region] = region_cache + elif isinstance(region_cache, _MemoryTLRUCache) != (self.cache_type == 'ttl'): + raise ValueError( + f"Cache region {region!r} already uses a different cache type" + ) + if isinstance(region_cache, _MemoryTLRUCache): + region_cache.set(key, value, ttl=ttl) + else: + region_cache[key] = value def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: """ @@ -458,19 +503,18 @@ class MemoryBackend(CacheBackend): :param region: 缓存的区,为None时清空所有区缓存 """ - if region: - # 清理指定缓存区 - region_cache = self.__get_region_cache(region) - if region_cache: - with self._lock: + with self._lock: + if region: + # 清理指定缓存区 + region_cache = self.__get_region_cache(region) + if region_cache is not None: region_cache.clear() - logger.debug(f"Cleared cache for region: {region}") - else: - # 清除所有区域的缓存 - for region_cache in self._region_caches.values(): - with self._lock: + logger.debug(f"Cleared cache for region: {region}") + else: + # 清除所有区域的缓存 + for region_cache in self._region_caches.values(): region_cache.clear() - logger.info("Cleared all cache") + logger.info("Cleared all cache") def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[Tuple[str, Any], None, None]: """ @@ -520,7 +564,7 @@ class AsyncMemoryBackend(AsyncCacheBackend): :param key: 缓存的键 :param value: 缓存的值 - :param ttl: 缓存的存活时间,不传入为永久缓存,单位秒 + :param ttl: 缓存的存活时间,未传入则使用 backend 默认值,单位秒 :param region: 缓存的区 """ return self._backend.set(key=key, value=value, ttl=ttl, region=region, **kwargs) @@ -600,11 +644,14 @@ class RedisBackend(CacheBackend): :param key: 缓存的键 :param value: 缓存的值 - :param ttl: 缓存的存活时间,未传入则为永久缓存,单位秒 + :param ttl: 缓存的存活时间,未传入则使用 backend 默认值,单位秒 :param region: 缓存的区 :param kwargs: kwargs """ - ttl = ttl or self.ttl + ttl = self.ttl if ttl is None else ttl + if ttl is not None and ttl <= 0: + self.redis_helper.delete(key, region=region) + return self.redis_helper.set(key, value, ttl=ttl, region=region, **kwargs) def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: @@ -681,11 +728,14 @@ class AsyncRedisBackend(AsyncCacheBackend): :param key: 缓存的键 :param value: 缓存的值 - :param ttl: 缓存的存活时间,未传入则为永久缓存,单位秒 + :param ttl: 缓存的存活时间,未传入则使用 backend 默认值,单位秒 :param region: 缓存的区 :param kwargs: kwargs """ - ttl = ttl or self.ttl + ttl = self.ttl if ttl is None else ttl + if ttl is not None and ttl <= 0: + await self.redis_helper.delete(key, region=region) + return await self.redis_helper.set(key, value, ttl=ttl, region=region, **kwargs) async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: @@ -1018,7 +1068,7 @@ def FileCache(base: Path = settings.TEMP_PATH, ttl: Optional[int] = None) -> Cac """ if settings.CACHE_BACKEND_TYPE == "redis": # 如果使用 Redis,则设置缓存的存活时间为配置的天数转换为秒 - return RedisBackend(ttl=ttl or settings.TEMP_FILE_DAYS * 24 * 3600) + return RedisBackend(ttl=ttl if ttl is not None else settings.TEMP_FILE_DAYS * 24 * 3600) else: # 如果使用文件系统,在停止服务时会自动清理过期文件 return FileBackend(base=base) @@ -1030,7 +1080,7 @@ def AsyncFileCache(base: Path = settings.TEMP_PATH, ttl: Optional[int] = None) - """ if settings.CACHE_BACKEND_TYPE == "redis": # 如果使用 Redis,则设置缓存的存活时间为配置的天数转换为秒 - return AsyncRedisBackend(ttl=ttl or settings.TEMP_FILE_DAYS * 24 * 3600) + return AsyncRedisBackend(ttl=ttl if ttl is not None else settings.TEMP_FILE_DAYS * 24 * 3600) else: # 如果使用文件系统,在停止服务时会自动清理过期文件 return AsyncFileBackend(base=base) @@ -1075,11 +1125,11 @@ def AsyncCache(cache_type: Literal['ttl', 'lru'] = 'ttl', def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Optional[int] = None, skip_none: Optional[bool] = True, skip_empty: Optional[bool] = False, shared_key: Optional[str] = None): """ - 自定义缓存装饰器,支持为每个 key 动态传递 maxsize 和 ttl + 自定义缓存装饰器,支持配置缓存区域的 maxsize 和每个 key 的 ttl :param region: 缓存区域的标识符,默认根据模块名、函数名等自动生成标识 :param maxsize: 缓存区内的最大条目数 - :param ttl: 缓存的存活时间,单位秒,未传入则为永久缓存,单位秒 + :param ttl: 缓存的存活时间,单位秒;未传入时使用 LRU 缓存 :param skip_none: 跳过 None 缓存,默认为 True :param skip_empty: 跳过空值缓存(如 None, [], {}, "", set()),默认为 False :param shared_key: 同步/异步函数共享缓存的键,默认使用函数名(异步函数名会标准化为同步格式,如移除 `async_` 前缀) @@ -1186,7 +1236,7 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt if is_async: # 异步函数使用异步缓存后端 - cache_backend = AsyncCache(cache_type="ttl" if ttl else "lru", maxsize=maxsize, ttl=ttl) + cache_backend = AsyncCache(cache_type="ttl" if ttl is not None else "lru", maxsize=maxsize, ttl=ttl) # 异步函数的缓存装饰器 @wraps(func) async def async_wrapper(*args, **kwargs): @@ -1230,7 +1280,7 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt return async_wrapper else: # 同步函数使用同步缓存后端 - cache_backend = Cache(cache_type="ttl" if ttl else "lru", maxsize=maxsize, ttl=ttl) + cache_backend = Cache(cache_type="ttl" if ttl is not None else "lru", maxsize=maxsize, ttl=ttl) # 同步函数的缓存装饰器 @wraps(func) def wrapper(*args, **kwargs): diff --git a/tests/test_cache_system.py b/tests/test_cache_system.py index 5ad3870a..436c2d58 100644 --- a/tests/test_cache_system.py +++ b/tests/test_cache_system.py @@ -1,12 +1,22 @@ import asyncio import os +import threading import time -from app.core.cache import AsyncFileBackend, FileBackend, MemoryBackend +from app.core.cache import ( + AsyncFileBackend, + AsyncMemoryBackend, + AsyncRedisBackend, + AsyncFileCache, + FileBackend, + FileCache, + MemoryBackend, + RedisBackend, + cached, +) from app.core.config import settings from app.helper.redis import AsyncRedisHelper, RedisHelper - def test_file_backend_items_keep_relative_keys_and_bytes(tmp_path): """ 文件缓存遍历应返回可继续删除的相对 key,并保持二进制内容不变。 @@ -20,7 +30,6 @@ def test_file_backend_items_keep_relative_keys_and_bytes(tmp_path): assert cache.popitem(region="images") == ("nested/poster.jpg", b"\xff\xd8image") assert not cache.exists("nested/poster.jpg", region="images") - def test_clear_package_tool_cache_only_removes_pip_and_uv_old_files(tmp_path, monkeypatch): """ 包安装工具缓存清理只处理 pip/uv 子目录,不接管整个 .cache 或业务缓存。 @@ -49,7 +58,6 @@ def test_clear_package_tool_cache_only_removes_pip_and_uv_old_files(tmp_path, mo assert unknown.exists() assert business.exists() - def test_clear_package_tool_cache_disabled_when_days_non_positive(tmp_path, monkeypatch): """ PACKAGE_CACHE_DAYS 小于等于 0 时不清理包安装缓存。 @@ -70,7 +78,6 @@ def test_clear_package_tool_cache_disabled_when_days_non_positive(tmp_path, monk assert old_pip.exists() - def test_clear_package_tool_cache_isolates_subdir_errors(tmp_path, monkeypatch): """ 单个工具缓存目录清理失败,不影响另一个工具缓存目录。 @@ -93,7 +100,6 @@ def test_clear_package_tool_cache_isolates_subdir_errors(tmp_path, monkeypatch): assert calls == [("pip", 30), ("uv", 30)] - def test_clear_package_tool_cache_uses_package_cache_root(tmp_path, monkeypatch): """ PACKAGE_CACHE_ROOT 用作 pip/uv 清理根目录,不扩大到配置目录下其他缓存。 @@ -118,7 +124,6 @@ def test_clear_package_tool_cache_uses_package_cache_root(tmp_path, monkeypatch) assert not old_pip.exists() assert default_pip.exists() - def test_init_modules_does_not_clear_package_tool_cache(monkeypatch): """ 包安装缓存清理由通用临时清理入口触发,模块启动路径不直接执行清理。 @@ -152,7 +157,6 @@ def test_init_modules_does_not_clear_package_tool_cache(monkeypatch): assert called is False - def test_file_backend_delete_missing_key_is_noop(tmp_path): """ 删除不存在的文件缓存 key 应保持幂等,不向调用方抛出文件系统异常。 @@ -163,7 +167,6 @@ def test_file_backend_delete_missing_key_is_noop(tmp_path): assert not cache.exists("missing", region="default") - def test_memory_backend_delete_missing_key_is_noop(): """ 内存缓存后端 delete 与其他后端保持一致,不存在时直接返回。 @@ -174,6 +177,318 @@ def test_memory_backend_delete_missing_key_is_noop(): assert not cache.exists("missing", region="missing_delete") +def test_memory_backend_supports_per_key_ttl(): + """ + 同一 region 的 key 应按各自 TTL 过期,不受首个 key 的 TTL 影响。 + """ + region = "per_key_ttl" + cache = MemoryBackend() + cache.set("short", "short-value", ttl=10, region=region) + cache.set("long", "long-value", ttl=20, region=region) + region_cache = MemoryBackend._region_caches[cache.get_region(region)] + started_at = region_cache.timer() + + region_cache.expire(time=started_at + 11) + + assert cache.get("short", region=region) is None + assert cache.get("long", region=region) == "long-value" + assert list(cache.items(region=region)) == [("long", "long-value")] + +def test_memory_backend_resets_ttl_when_key_is_rewritten(): + """ + 重写已有 key 时应从重写时刻按新 TTL 重新计算过期时间。 + """ + region = "rewrite_per_key_ttl" + cache = MemoryBackend() + cache.set("key", "old", ttl=10, region=region) + region_cache = MemoryBackend._region_caches[cache.get_region(region)] + started_at = region_cache.timer() + + cache.set("key", "new", ttl=20, region=region) + region_cache.expire(time=started_at + 11) + + assert cache.get("key", region=region) == "new" + + region_cache.expire(time=started_at + 21) + + assert cache.get("key", region=region) is None + +def test_memory_backend_instances_share_region_with_per_key_ttl(): + """ + 多个 backend 仍共享 region 数据,但每次写入的显式 TTL 应独立生效。 + """ + region = "shared_per_key_ttl" + first = MemoryBackend() + second = MemoryBackend() + first.set("first", "first-value", ttl=10, region=region) + second.set("second", "second-value", ttl=20, region=region) + region_cache = MemoryBackend._region_caches[first.get_region(region)] + started_at = region_cache.timer() + + region_cache.expire(time=started_at + 11) + + assert second.get("first", region=region) is None + assert first.get("second", region=region) == "second-value" + +def test_async_memory_backend_supports_per_key_ttl(): + """ + 异步代理路径应与同步 backend 共享 region,并保留每个 key 的 TTL。 + """ + async def run_test(): + region = "async_per_key_ttl" + sync_cache = MemoryBackend() + async_cache = AsyncMemoryBackend() + await async_cache.set("short", "short-value", ttl=10, region=region) + sync_cache.set("long", "long-value", ttl=20, region=region) + region_cache = MemoryBackend._region_caches[sync_cache.get_region(region)] + started_at = region_cache.timer() + region_cache.expire(time=started_at + 11) + + assert await async_cache.get("short", region=region) is None + assert await async_cache.get("long", region=region) == "long-value" + + asyncio.run(run_test()) + +def test_memory_lru_backend_keeps_capacity_eviction_behavior(): + """ + per-key TTL 改造不应影响 LRU region 的容量淘汰行为。 + """ + region = "memory_lru" + cache = MemoryBackend(cache_type="lru", maxsize=2) + cache.set("first", 1, region=region) + cache.set("second", 2, region=region) + cache.set("third", 3, region=region) + + assert cache.get("first", region=region) is None + assert list(cache.items(region=region)) == [("second", 2), ("third", 3)] + +def test_cached_zero_ttl_does_not_cache_sync_result(): + """ + 同步 cached(ttl=0) 应立即过期,不能退化为 LRU 永久缓存。 + """ + calls = 0 + + @cached(region="sync_zero_ttl", ttl=0) + def load_value(): + nonlocal calls + calls += 1 + return calls + + assert load_value() == 1 + assert load_value() == 2 + +def test_cached_zero_ttl_does_not_cache_async_result(): + """ + 异步 cached(ttl=0) 应与同步路径保持一致。 + """ + calls = 0 + + @cached(region="async_zero_ttl", ttl=0) + async def load_value(): + nonlocal calls + calls += 1 + return calls + + async def run_test(): + return await load_value(), await load_value() + + assert asyncio.run(run_test()) == (1, 2) + + +def test_memory_backend_global_clear_is_safe_during_region_creation(): + """ + 全局清理与新 region 创建应由同一把锁串行化,不能并发修改注册表。 + """ + cache = MemoryBackend() + cache.set("existing", 1, region="clear_existing") + started = threading.Event() + release = threading.Event() + region_cache = MemoryBackend._region_caches[cache.get_region("clear_existing")] + original_clear = region_cache.clear + + def blocking_clear(): + started.set() + release.wait(timeout=5) + original_clear() + + region_cache.clear = blocking_clear + clear_thread = threading.Thread(target=cache.clear, args=(None,)) + clear_thread.start() + assert started.wait(timeout=5) + + set_thread = threading.Thread( + target=cache.set, + args=("new", 2), + kwargs={"region": "clear_new"}, + ) + set_thread.start() + set_thread.join(timeout=0.1) + + assert set_thread.is_alive() + + release.set() + clear_thread.join(timeout=5) + set_thread.join(timeout=5) + + assert not clear_thread.is_alive() + assert not set_thread.is_alive() + assert cache.get("existing", region="clear_existing") is None + assert cache.get("new", region="clear_new") == 2 + + +def test_memory_backend_rejects_region_cache_type_conflicts(): + """ + 同名 region 不得同时作为 TTL 和 LRU 缓存使用。 + """ + region = "cache_type_conflict" + ttl_cache = MemoryBackend(cache_type="ttl") + lru_cache = MemoryBackend(cache_type="lru") + ttl_cache.set("ttl", 1, ttl=10, region=region) + + try: + lru_cache.set("lru", 2, region=region) + except ValueError as err: + assert "different cache type" in str(err) + else: + raise AssertionError("cache type conflict must be rejected") + + assert ttl_cache.get("ttl", region=region) == 1 + assert ttl_cache.get("lru", region=region) is None + +def test_memory_backend_reuses_existing_region_cache(): + """ + 同一 region 的后续写入应复用首次创建的底层缓存对象。 + """ + region = "reuse_region_cache" + cache = MemoryBackend() + cache.set("first", 1, ttl=10, region=region) + first_region_cache = MemoryBackend._region_caches[cache.get_region(region)] + + cache.set("second", 2, ttl=20, region=region) + + assert MemoryBackend._region_caches[cache.get_region(region)] is first_region_cache + + +def test_memory_backend_uses_default_maxsize_for_zero_override(): + """ + 动态 maxsize=0 应与构造参数一致,回退到 backend 默认容量。 + """ + region = "zero_maxsize_override" + cache = MemoryBackend(maxsize=8) + cache.set("key", "value", maxsize=0, region=region) + + region_cache = MemoryBackend._region_caches[cache.get_region(region)] + + assert region_cache.maxsize == 8 + assert cache.get("key", region=region) == "value" + + +def test_memory_backend_preserves_zero_ttl(): + """ + 显式 ttl=0 不应回退到默认 TTL,并应删除已有同名值。 + """ + cache = MemoryBackend(ttl=30) + cache.set("key", "old", region="zero_ttl") + cache.set("key", "new", ttl=0, region="zero_ttl") + + assert cache.get("key", region="zero_ttl") is None + +def test_memory_backend_preserves_negative_ttl(): + """ + 显式负 TTL 应保持立即过期语义,并删除已有同名值。 + """ + cache = MemoryBackend(ttl=30) + cache.set("key", "old", region="negative_ttl") + cache.set("key", "new", ttl=-1, region="negative_ttl") + + assert cache.get("key", region="negative_ttl") is None + +def test_memory_backend_uses_zero_default_ttl(): + """ + backend 的默认 ttl=0 应保持立即过期语义。 + """ + cache = MemoryBackend(ttl=0) + cache.set("key", "value", region="zero_default_ttl") + + assert cache.get("key", region="zero_default_ttl") is None + +def test_redis_backend_treats_zero_ttl_as_expired(): + """ + Redis backend 应删除 ttl=0 的同名 key,避免向 Redis 发送无效 EX 0。 + """ + class RedisHelperStub: + deleted = None + set_called = False + + def set(self, key, value, ttl, region, **kwargs): + self.set_called = True + + def delete(self, key, region): + self.deleted = (key, region) + + cache = object.__new__(RedisBackend) + cache.ttl = 30 + cache.redis_helper = RedisHelperStub() + + cache.set("key", "value", ttl=0, region="zero_ttl") + + assert cache.redis_helper.deleted == ("key", "zero_ttl") + assert not cache.redis_helper.set_called + +def test_async_redis_backend_treats_zero_ttl_as_expired(): + """ + 异步 Redis backend 应删除 ttl=0 的同名 key,不发送无效 EX 0。 + """ + class AsyncRedisHelperStub: + deleted = None + set_called = False + + async def set(self, key, value, ttl, region, **kwargs): + self.set_called = True + + async def delete(self, key, region): + self.deleted = (key, region) + + async def run_test(): + cache = object.__new__(AsyncRedisBackend) + cache.ttl = 30 + cache.redis_helper = AsyncRedisHelperStub() + await cache.set("key", "value", ttl=0, region="zero_ttl") + return cache.redis_helper + + helper = asyncio.run(run_test()) + + assert helper.deleted == ("key", "zero_ttl") + assert not helper.set_called + +def test_file_cache_preserves_zero_ttl_in_redis_mode(monkeypatch): + """ + FileCache 在 Redis 模式下不应把显式 ttl=0 替换为临时文件默认 TTL。 + """ + monkeypatch.setattr(settings, "CACHE_BACKEND_TYPE", "redis") + + assert FileCache(ttl=0).ttl == 0 + + +def test_async_file_cache_preserves_zero_ttl_in_redis_mode(monkeypatch): + """ + AsyncFileCache 在 Redis 模式下应与同步工厂保持相同 TTL 语义。 + """ + monkeypatch.setattr(settings, "CACHE_BACKEND_TYPE", "redis") + + assert AsyncFileCache(ttl=0).ttl == 0 + + +def test_file_cache_uses_default_ttl_when_omitted(monkeypatch): + """ + 未传 TTL 时仍使用 TEMP_FILE_DAYS 配置的默认值。 + """ + monkeypatch.setattr(settings, "CACHE_BACKEND_TYPE", "redis") + monkeypatch.setattr(settings, "TEMP_FILE_DAYS", 7) + + assert FileCache().ttl == 7 * 24 * 3600 + assert AsyncFileCache().ttl == 7 * 24 * 3600 + def test_redis_original_key_decodes_quoted_key(): """ @@ -183,7 +498,6 @@ def test_redis_original_key_decodes_quoted_key(): assert RedisHelper._RedisHelper__get_original_key(redis_key) == "nested/poster one.jpg" - def test_redis_helper_uses_blocking_pool_settings(monkeypatch): """ Redis 同步客户端应使用阻塞连接池,避免并发峰值直接耗尽 Redis 连接数。 @@ -234,7 +548,6 @@ def test_redis_helper_uses_blocking_pool_settings(monkeypatch): helper.close() - def test_async_redis_helper_uses_blocking_pool_settings(monkeypatch): """ Redis 异步客户端应使用阻塞连接池,避免高并发缓存读取立刻抛出连接耗尽错误。 @@ -289,7 +602,6 @@ def test_async_redis_helper_uses_blocking_pool_settings(monkeypatch): assert calls["ping"] is True assert ("maxmemory-policy", "allkeys-lru") in config_calls - def test_redis_helpers_watch_pool_settings(): """ Redis 连接池配置变化应触发客户端重建。 @@ -299,7 +611,6 @@ def test_redis_helpers_watch_pool_settings(): assert "CACHE_REDIS_MAX_CONNECTIONS" in AsyncRedisHelper.CONFIG_WATCH assert "CACHE_REDIS_POOL_TIMEOUT" in AsyncRedisHelper.CONFIG_WATCH - def test_async_file_backend_missing_region_has_no_items(tmp_path): """ 异步文件缓存缺失区域时应返回空迭代,而不是伪造空 key。 @@ -311,7 +622,6 @@ def test_async_file_backend_missing_region_has_no_items(tmp_path): assert asyncio.run(collect_items()) == [] - def test_async_file_backend_items_keep_relative_keys_and_bytes(tmp_path): """ 异步文件缓存遍历应与同步文件缓存保持相同 key 和二进制语义。 @@ -331,7 +641,6 @@ def test_async_file_backend_items_keep_relative_keys_and_bytes(tmp_path): assert popped == ("nested/poster.jpg", b"\xff\xd8image") assert not exists - def test_file_backend_items_skip_directories(tmp_path): """ 文件缓存遍历应递归读取有效缓存文件,不把目录当成缓存项。