本次提交包含多项代码优化与规范修正: 1. 文档与注释优化:修正注释术语、补充注解与FR编号 2. 代码格式调整:统一空格、换行与缩进规范 3. 类型与接口完善:补充__all__导出、修正返回类型注解 4. 错误处理增强:新增领域错误类与校验逻辑 5. 依赖与导入调整:修复路径引用、统一时区导入 6. 协议与契约更新:完善接口文档与一致性注解
347 lines
13 KiB
Python
347 lines
13 KiB
Python
"""RedisCacheAdapter:实现 CachePort,基于构造注入的 Redis 客户端。
|
||
|
||
- 缓存读写复用注入的 ``redis.asyncio.Redis`` 客户端
|
||
- 咨询锁使用 Redis ``SET key value NX EX ttl`` 原子获取,Lua 脚本原子释放
|
||
(ADP-022:不再依赖 PostgreSQL 事务级咨询锁)
|
||
- 读故障(get/incr/decr/expire/getStreamStatus)抛 ``DependencyError`` 由
|
||
调用方显式处理,写故障(set/delete/invalidate)降级不阻断主流程
|
||
- 咨询锁故障抛 ``DependencyError``,不静默返回 None(ADP-025 / INV-7)
|
||
|
||
依赖边界:只依赖 yuxi.channels.contract(端口 + DTO)、redis.asyncio、
|
||
标准库。Redis 客户端由外部构造注入,适配器不管理其生命周期(构造注入,
|
||
INV-5),且不持有跨请求的可变状态(ADP-023)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import uuid
|
||
from datetime import UTC, datetime, timedelta
|
||
from enum import Enum
|
||
from typing import Any
|
||
|
||
from redis.asyncio import Redis
|
||
|
||
from yuxi.channels.contract.dtos.cache import LockToken
|
||
from yuxi.channels.contract.dtos.health import RedisStreamStatus
|
||
from yuxi.channels.contract.dtos.option import Nothing, Option, Some
|
||
from yuxi.channels.contract.errors import DependencyError
|
||
from yuxi.channels.contract.errors.base import Error
|
||
from yuxi.channels.contract.ports.driven.cache_port import CachePort
|
||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||
|
||
__all__ = ["RedisCacheAdapter"]
|
||
|
||
# Lua 脚本:原子 check-and-del 释放锁,确保只有令牌持有者能释放,
|
||
# 避免"先查后删"竞态(ADP-024)。比较与删除在 Redis 服务端单步完成。
|
||
|
||
|
||
def _json_default(obj: Any) -> Any:
|
||
"""JSON 序列化兜底函数。
|
||
|
||
对 ``Enum`` 实例统一返回 ``.value``(而非 ``str(enum)``),
|
||
避免 ``StrEnum`` 迁移前后 ``str()`` 行为差异导致的缓存内容变更。
|
||
其他不可序列化对象退回 ``str()``。
|
||
"""
|
||
if isinstance(obj, Enum):
|
||
return obj.value
|
||
return str(obj)
|
||
|
||
|
||
_RELEASE_LOCK_SCRIPT = """
|
||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||
return redis.call('del', KEYS[1])
|
||
else
|
||
return 0
|
||
end
|
||
"""
|
||
|
||
|
||
class RedisCacheAdapter(CachePort):
|
||
"""Redis 缓存被驱动适配器。
|
||
|
||
基于 **构造注入** 的 ``redis.asyncio.Redis`` 客户端操作 Redis,咨询锁
|
||
使用 Redis ``SET key value NX EX ttl`` 原子获取、Lua 脚本原子释放,
|
||
不再依赖 PostgreSQL 事务级锁(ADP-022)。读故障(get/incr/decr/expire/
|
||
getStreamStatus)抛 ``DependencyError`` 由调用方显式处理;写故障
|
||
(set/delete/invalidate)降级返回 ``False`` / ``0`` 不阻断主流程。
|
||
咨询锁故障抛 ``DependencyError``,不静默返回 None(ADP-025 / INV-7)。
|
||
|
||
适配器 **不持有** 跨请求的可变状态(ADP-023 / INV-5):锁令牌携带
|
||
``key`` 字段,释放时从令牌还原锁键,无需维护实例级 ``token -> key``
|
||
映射。Redis 客户端由外部注入,其生命周期由注入方管理,``close`` 为
|
||
空操作。
|
||
|
||
通过注入 ``LoggerPort`` 记录降级分支的异常日志(错误显式化 / 日志携带
|
||
trace_id),避免静默吞噬异常。logger 为可选依赖,未注入时降级分支不
|
||
记录日志但不阻断主流程。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
redis_client: Redis,
|
||
logger: LoggerPort | None = None,
|
||
) -> None:
|
||
"""初始化适配器,注入 Redis 客户端。
|
||
|
||
Args:
|
||
redis_client: ``redis.asyncio.Redis`` 客户端实例,由外部构造并
|
||
注入;适配器不管理其生命周期(构造注入,INV-5)。
|
||
logger: 可选的日志被驱动端口,用于记录降级分支异常日志。
|
||
"""
|
||
self._redis = redis_client
|
||
self._logger = logger
|
||
|
||
async def get(self, key: str) -> Option[Any]:
|
||
"""读取缓存,故障时抛出 DependencyError(对齐 incr/decr)。
|
||
|
||
Args:
|
||
key: 缓存键。
|
||
|
||
Returns:
|
||
``Some(value)``(若存在),否则 ``Nothing``。
|
||
|
||
Raises:
|
||
DependencyError: Redis 故障。
|
||
"""
|
||
try:
|
||
raw = await self._redis.get(key)
|
||
except Exception as exc:
|
||
raise DependencyError("redis", Error(str(exc))) from exc
|
||
if raw is None:
|
||
return Nothing()
|
||
return Some(json.loads(raw))
|
||
|
||
async def set(
|
||
self,
|
||
key: str,
|
||
value: Any,
|
||
ttl_seconds: int | None = None,
|
||
) -> bool:
|
||
"""写入缓存,故障时返回 False(降级不阻断)。
|
||
|
||
Args:
|
||
key: 缓存键。
|
||
value: 缓存值(将序列化为 JSON)。
|
||
ttl_seconds: TTL(秒),None 表示不过期。
|
||
|
||
Returns:
|
||
True 表示写入成功,False 表示故障。
|
||
"""
|
||
try:
|
||
payload = json.dumps(value, default=_json_default, ensure_ascii=False)
|
||
if ttl_seconds is not None and ttl_seconds > 0:
|
||
await self._redis.set(key, payload, ex=ttl_seconds)
|
||
else:
|
||
await self._redis.set(key, payload)
|
||
return True
|
||
except Exception as exc:
|
||
if self._logger is not None:
|
||
await self._logger.warn(f"cache write failed: key={key}, error={exc}")
|
||
return False
|
||
|
||
async def delete(self, key: str) -> bool:
|
||
"""删除缓存,故障时返回 False(降级不阻断)。
|
||
|
||
Args:
|
||
key: 缓存键。
|
||
|
||
Returns:
|
||
True 表示删除成功,False 表示 key 不存在或故障。
|
||
"""
|
||
try:
|
||
deleted = await self._redis.delete(key)
|
||
return deleted > 0
|
||
except Exception as exc:
|
||
if self._logger is not None:
|
||
await self._logger.warn(f"cache delete failed: key={key}, error={exc}")
|
||
return False
|
||
|
||
async def incr(self, key: str, amount: int = 1) -> int:
|
||
"""原子递增缓存值,故障时抛出 DependencyError(不静默降级)。
|
||
|
||
使用 Redis INCRBY 原子递增。用于熔断器计数等需要原子性的场景,
|
||
静默降级会导致计数错误,故与 ``get``/``set``/``delete`` 的降级
|
||
策略不同,故障时显式抛出异常。
|
||
|
||
Args:
|
||
key: 缓存键。
|
||
amount: 递增量(正整数)。
|
||
|
||
Returns:
|
||
递增后的最新值。
|
||
|
||
Raises:
|
||
DependencyError: Redis 故障或原子操作失败。
|
||
"""
|
||
try:
|
||
return await self._redis.incrby(key, amount)
|
||
except Exception as exc:
|
||
raise DependencyError("redis", Error(str(exc))) from exc
|
||
|
||
async def decr(self, key: str, amount: int = 1) -> int:
|
||
"""原子递减缓存值,故障时抛出 DependencyError(不静默降级)。
|
||
|
||
使用 Redis DECRBY 原子递减。用于熔断器计数等需要原子性的场景,
|
||
静默降级会导致计数错误,故与 ``get``/``set``/``delete`` 的降级
|
||
策略不同,故障时显式抛出异常。
|
||
|
||
Args:
|
||
key: 缓存键。
|
||
amount: 递减量(正整数)。
|
||
|
||
Returns:
|
||
递减后的最新值。
|
||
|
||
Raises:
|
||
DependencyError: Redis 故障或原子操作失败。
|
||
"""
|
||
try:
|
||
return await self._redis.decrby(key, amount)
|
||
except Exception as exc:
|
||
raise DependencyError("redis", Error(str(exc))) from exc
|
||
|
||
async def expire(self, key: str, ttl_seconds: int) -> bool:
|
||
"""为已存在的 key 设置 TTL,不修改值(对应 Redis ``EXPIRE``)。
|
||
|
||
用于 ``incr`` 原子递增后补充设置窗口 TTL:``incr`` 创建 key 时
|
||
不带 TTL,调用方在首次递增(返回 1)时通过本方法设置窗口过期
|
||
时间。与 ``set`` 不同,本方法 **不覆盖** 现有值,消除"首次设
|
||
TTL 覆盖并发 incr 计数"的竞态。
|
||
|
||
Args:
|
||
key: 缓存键。
|
||
ttl_seconds: TTL(秒),必须 > 0。
|
||
|
||
Returns:
|
||
True 表示 key 存在且 TTL 设置成功,False 表示 key 不存在。
|
||
|
||
Raises:
|
||
DependencyError: Redis 故障(与 ``incr`` fail-closed 语义一致)。
|
||
"""
|
||
try:
|
||
return bool(await self._redis.expire(key, ttl_seconds))
|
||
except Exception as exc:
|
||
raise DependencyError("redis", Error(str(exc))) from exc
|
||
|
||
async def invalidate(self, pattern: str) -> int:
|
||
"""按模式失效缓存,故障时返回 0(降级不阻断)。
|
||
|
||
使用 SCAN 遍历匹配 key 并批量删除,避免 KEYS 阻塞。
|
||
|
||
Args:
|
||
pattern: 通配符模式(如 ``channel:*``)。
|
||
|
||
Returns:
|
||
失效的 key 数量,故障时返回 0。
|
||
"""
|
||
try:
|
||
count = 0
|
||
keys = []
|
||
async for key in self._redis.scan_iter(match=pattern, count=100):
|
||
keys.append(key)
|
||
if len(keys) >= 100:
|
||
count += await self._redis.delete(*keys)
|
||
keys.clear()
|
||
if keys:
|
||
count += await self._redis.delete(*keys)
|
||
return count
|
||
except Exception as exc:
|
||
if self._logger is not None:
|
||
await self._logger.warn(f"cache invalidate failed: pattern={pattern}, error={exc}")
|
||
return 0
|
||
|
||
async def acquireAdvisoryLock(self, key: str, ttl_seconds: int = 300) -> LockToken | None:
|
||
"""获取分布式咨询锁。
|
||
|
||
使用 Redis ``SET key value NX EX ttl`` 原子获取锁,令牌为 UUID 字符串。
|
||
覆盖 FR-06(跨渠道会话关联),用于串行化会话关联等并发敏感操作。
|
||
|
||
@pre
|
||
- key 非空
|
||
- ttl_seconds > 0
|
||
|
||
@post
|
||
- 获取锁成功返回 LockToken(携带 key、UUID 令牌值与过期时间),
|
||
Redis 中 key 已设置
|
||
- 锁已被占用返回 None(合法的"锁未获取"语义)
|
||
|
||
@failure
|
||
- DependencyError:Redis 故障(不静默返回 None,ADP-025 / INV-7)
|
||
|
||
@consistency
|
||
- 强一致性:分布式锁串行化并发操作
|
||
"""
|
||
token_value = str(uuid.uuid4())
|
||
try:
|
||
ok = await self._redis.set(key, token_value, ex=ttl_seconds, nx=True)
|
||
except Exception as exc:
|
||
raise DependencyError("redis", Error(str(exc))) from exc
|
||
if not ok:
|
||
return None
|
||
expires_at = datetime.now(UTC) + timedelta(seconds=ttl_seconds)
|
||
return LockToken(
|
||
key=key,
|
||
value=token_value,
|
||
expires_at=expires_at,
|
||
)
|
||
|
||
async def releaseAdvisoryLock(self, token: LockToken) -> bool:
|
||
"""释放分布式咨询锁。
|
||
|
||
使用 Lua 脚本原子 check-and-del 释放锁,确保只有令牌持有者能释放
|
||
(ADP-024)。覆盖 FR-06(跨渠道会话关联)。锁键从 ``token.key``
|
||
还原,无需适配器维护跨请求映射(ADP-023)。
|
||
|
||
@pre
|
||
- token 非空且为已获取的锁令牌
|
||
|
||
@post
|
||
- 锁已释放返回 True,锁不存在或令牌不匹配返回 False
|
||
|
||
@failure
|
||
- DependencyError:Redis 故障
|
||
|
||
@consistency
|
||
- 强一致性:锁释放后其他等待方可获取
|
||
"""
|
||
try:
|
||
result = await self._redis.eval(_RELEASE_LOCK_SCRIPT, 1, token.key, token.value)
|
||
except Exception as exc:
|
||
raise DependencyError("redis", Error(str(exc))) from exc
|
||
return bool(result)
|
||
|
||
async def ping(self) -> bool:
|
||
"""主动探测缓存可用性,故障时返回 False(降级不阻断)。
|
||
|
||
执行 Redis ``PING`` 验证连接可用性。故障时返回 False 并通过
|
||
``self._logger`` 记录 warning 日志,不抛异常,供 ``HealthAggregator``
|
||
判断缓存状态。
|
||
|
||
Returns:
|
||
True 表示缓存可用,False 表示故障。
|
||
"""
|
||
try:
|
||
await self._redis.ping()
|
||
return True
|
||
except Exception as exc:
|
||
if self._logger is not None:
|
||
await self._logger.warn(f"cache ping failed: error={exc}")
|
||
return False
|
||
|
||
async def getStreamStatus(self) -> RedisStreamStatus:
|
||
"""获取 Redis 流状态。
|
||
|
||
返回 ``RedisStreamStatus``,包含 Redis 可用性与 ``DBSIZE`` 等
|
||
关键指标,供 ``DiagnosticsExporter`` 填充诊断包。
|
||
|
||
Raises:
|
||
DependencyError: Redis 故障。
|
||
"""
|
||
try:
|
||
await self._redis.ping()
|
||
db_size = await self._redis.dbsize()
|
||
return RedisStreamStatus(available=True, db_size=db_size)
|
||
except Exception as exc:
|
||
raise DependencyError("redis", Error(str(exc))) from exc
|