Fix/memory cache ttl (#6160)

This commit is contained in:
Ekko
2026-07-21 07:08:21 +08:00
committed by GitHub
parent 5c2b503a74
commit 7c2390908a
2 changed files with 412 additions and 53 deletions

View File

@@ -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):

View File

@@ -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):
"""
文件缓存遍历应递归读取有效缓存文件,不把目录当成缓存项。