"""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