ForcePilot/backend/package/yuxi/channels/adapters/redis_cache_adapter.py
Kris 00092c818e chore: 批量代码优化与规范完善
本次提交包含多项代码优化与规范修正:
1. 文档与注释优化:修正注释术语、补充注解与FR编号
2. 代码格式调整:统一空格、换行与缩进规范
3. 类型与接口完善:补充__all__导出、修正返回类型注解
4. 错误处理增强:新增领域错误类与校验逻辑
5. 依赖与导入调整:修复路径引用、统一时区导入
6. 协议与契约更新:完善接口文档与一致性注解
2026-07-03 19:18:13 +08:00

347 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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``,不静默返回 NoneADP-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``,不静默返回 NoneADP-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: TTLNone 表示不过期。
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
- DependencyErrorRedis 故障(不静默返回 NoneADP-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
- DependencyErrorRedis 故障
@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