ForcePilot/backend/package/yuxi/channels/adapters/redis_config_adapter.py

548 lines
22 KiB
Python
Raw Normal View History

"""RedisConfigAdapter实现 ConfigPort复用 Redis。
- 配置版本化更新WATCH/MULTI/EXEC 原子乐观并发控制
- 配置读取更新版本查询回滚热更新模式查询与 schema 查询
- 适配器不再构造/发布领域事件CON-030 / ADP-029``ConfigChanged`` /
``ConfigRollback`` 事件由应用层 ``ConfigManager`` 基于返回的
``ConfigUpdateResult`` 构造并发布
- 适配器不再承载热更新策略校验ADP-030``NON_HOT_RELOADABLE_KEYS``
校验由应用层 ``ConfigManager`` 执行
- Redis 客户端由外部构造注入INV-5适配器不管理其生命周期
不再依赖 ``yuxi.services.run_queue_service``
依赖边界只依赖 yuxi.channels.contract端口 + DTO + 错误
redis.asyncio标准库
"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
from redis.asyncio import Redis
from redis.exceptions import WatchError
from yuxi.channels.contract.dtos.config import (
ConfigField,
ConfigHistoryEntry,
ConfigHotReloadMode,
ConfigScope,
ConfigValue,
ConfigVersion,
RollbackConfigCmd,
UpdateConfigCmd,
)
from yuxi.channels.contract.dtos.config_update_result import ConfigUpdateResult
from yuxi.channels.contract.dtos.streaming import StreamingConfig
from yuxi.channels.contract.errors import (
ConfigRollbackError,
ConfigVersionConflictError,
DependencyError,
NotFoundError,
)
from yuxi.channels.contract.errors.base import Error
from yuxi.channels.contract.policy.config_schema import CONFIG_SCHEMA
from yuxi.channels.contract.ports.driven.config_port import ConfigPort
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from yuxi.utils.datetime_utils import utc_now_naive
__all__ = ["RedisConfigAdapter"]
class RedisConfigAdapter(ConfigPort):
"""Redis 配置适配器。
实现 ``ConfigPort``基于构造注入的 ``redis.asyncio.Redis`` 客户端
操作 Redis支持配置读取更新版本查询回滚热更新模式查询
schema 查询配置以 ``config:{scope}:{target}:{key}`` 格式存储
版本号存储在 ``{scoped_key}:version`` 历史记录存储在
``{scoped_key}:history`` 列表中
配置更新与回滚使用 WATCH/MULTI/EXEC 保证读取-校验-写入的原子性
适配器不再构造/发布领域事件CON-030 / ADP-029也不再承载热更新
策略校验ADP-030这两项职责由应用层 ``ConfigManager`` 承担
``update`` / ``rollback`` 返回 ``ConfigUpdateResult``含版本号与旧值
供应用层构造 ``ConfigChanged`` / ``ConfigRollback`` 领域事件
Redis 客户端由外部构造注入INV-5适配器不管理其生命周期
通过 ``LoggerPort`` 记录日志INV-7 / 端口抽象logger 强制注入
"""
def __init__(
self,
redis_client: Redis,
logger: LoggerPort,
) -> None:
"""初始化 Redis 配置适配器。
Args:
redis_client: ``redis.asyncio.Redis`` 客户端实例由外部构造并
注入适配器不管理其生命周期构造注入INV-5需使用
``decode_responses=True`` 创建保证读写值为字符串
logger: 日志被驱动端口强制注入用于记录降级分支异常日志
INV-10 可观测性
"""
self._redis = redis_client
self._logger = logger
async def get(
self,
key: str,
scope: ConfigScope = ConfigScope.GLOBAL,
target: str | None = None,
) -> ConfigValue:
"""读取配置值。
Redis 读取指定作用域与目标的配置值若作用域级别不存在回退到
全局配置PLUGIN 作用域除外插件配置独立不回退全局若全局
配置也不存在返回 schema 默认值若定义否则抛
``NotFoundError``HTTP 404 driving 端口 ``getConfig @failure``
声明一致
Args:
key: 配置键
scope: 配置作用域默认 GLOBAL
target: 目标channel_type / account_id / plugin_id可选
Returns:
ConfigValue包含键版本与作用域
Raises:
NotFoundError: 配置不存在且 schema 中无默认值
DependencyError: Redis 依赖故障
"""
try:
scoped_key = self._build_key(key, scope, target)
value = await self._redis.get(scoped_key)
actual_key = scoped_key
# PLUGIN 作用域配置独立,不回退到全局配置
if value is None and scope != ConfigScope.PLUGIN:
actual_key = self._build_key(key, ConfigScope.GLOBAL, None)
value = await self._redis.get(actual_key)
if value is None:
default = self._getSchemaDefault(key)
if default is not None:
return ConfigValue(
key=key,
value=default,
version=0,
scope=scope,
)
raise NotFoundError(
resource="config",
id=key,
)
version_str = await self._redis.get(f"{actual_key}:version")
return ConfigValue(
key=key,
value=json.loads(value),
version=int(version_str) if version_str else 0,
scope=scope,
)
except NotFoundError:
raise
except Exception as exc:
raise DependencyError("redis", Error(str(exc))) from exc
async def update(self, cmd: UpdateConfigCmd) -> ConfigUpdateResult:
"""更新配置。
使用 WATCH/MULTI/EXEC 保证读取版本-校验-写入的原子性返回
``ConfigUpdateResult``含键新版本号旧值新值与写入时间
供应用层构造 ``ConfigChanged`` 领域事件CON-030
适配器不再执行不可热更新配置项校验ADP-030该校验由应用层
``ConfigManager`` 基于 ``NON_HOT_RELOADABLE_KEYS`` 执行
Args:
cmd: 更新配置命令
Returns:
ConfigUpdateResult含键新版本号已反序列化的旧值首次
创建时为 ``None``新值与写入时间
Raises:
ConfigVersionConflictError: 配置版本冲突并发修改
DependencyError: Redis 依赖故障
"""
try:
scoped_key = self._build_key(cmd.key, cmd.scope, cmd.target)
pipe = self._redis.pipeline()
await pipe.watch(f"{scoped_key}:version")
try:
version_str = await pipe.get(f"{scoped_key}:version")
current_version = int(version_str) if version_str else 0
if cmd.expected_version is not None and cmd.expected_version != current_version:
raise ConfigVersionConflictError(cmd.expected_version, current_version)
old_value_raw = await pipe.get(scoped_key)
# 读取被归档版本的原始写入时间,作为历史条目的 updated_at
# (而非本次更新时间),确保历史时间戳语义正确。
old_updated_at_raw = await pipe.get(f"{scoped_key}:updated_at")
new_version = current_version + 1
updated_at = utc_now_naive()
pipe.multi()
pipe.set(scoped_key, json.dumps(cmd.value, ensure_ascii=False))
pipe.set(f"{scoped_key}:version", str(new_version))
pipe.set(f"{scoped_key}:updated_at", updated_at.isoformat())
if old_value_raw is not None:
pipe.lpush(
f"{scoped_key}:history",
json.dumps(
{
"version": current_version,
"value": old_value_raw,
"updated_at": old_updated_at_raw,
},
ensure_ascii=False,
),
)
try:
result = await pipe.execute()
except WatchError:
result = None
finally:
pipe.reset()
if result is None:
raise ConfigVersionConflictError(cmd.expected_version or 0, current_version)
old_value = json.loads(old_value_raw) if old_value_raw is not None else None
return ConfigUpdateResult(
key=cmd.key,
version=new_version,
old_value=old_value,
new_value=cmd.value,
updated_at=updated_at,
)
except ConfigVersionConflictError:
raise
except Exception as exc:
raise DependencyError("redis", Error(str(exc))) from exc
async def getVersion(
self,
key: str,
scope: ConfigScope = ConfigScope.GLOBAL,
target: str | None = None,
) -> ConfigVersion:
"""查询配置版本。
返回指定配置项的当前版本信息用于乐观并发控制与版本回溯
Args:
key: 配置键
scope: 配置作用域默认 GLOBAL
target: 目标channel_type / account_id / plugin_id可选
Returns:
ConfigVersion包含键版本号与更新时间配置不存在时返回
版本 0 ``updated_at=None``避免误报"刚刚更新"
"""
try:
scoped_key = self._build_key(key, scope, target)
version_str = await self._redis.get(f"{scoped_key}:version")
version = int(version_str) if version_str else 0
updated_at: datetime | None = None
if version > 0:
updated_at_raw = await self._redis.get(f"{scoped_key}:updated_at")
if updated_at_raw is not None:
try:
updated_at = datetime.fromisoformat(updated_at_raw)
except ValueError as exc:
await self._logger.warn(
"redis config updated_at parse failed",
key=key,
raw_value=updated_at_raw,
error=str(exc),
)
updated_at = None
return ConfigVersion(
key=key,
version=version,
updated_at=updated_at,
)
except Exception as exc:
raise DependencyError("redis", Error(str(exc))) from exc
async def rollback(self, cmd: RollbackConfigCmd) -> ConfigUpdateResult:
"""回滚配置版本。
将配置回滚至指定版本从历史记录中查找目标版本使用
WATCH/MULTI/EXEC 保证读取历史-读取当前值-写入的原子性返回
``ConfigUpdateResult``含键新版本号旧值即回滚前失败值
新值即回滚后目标值写入时间供应用层构造 ``ConfigRollback``
领域事件CON-030
Args:
cmd: 回滚配置命令
Returns:
ConfigUpdateResult含键新版本号已反序列化的旧值即回滚前
失败值新值即回滚后目标值与写入时间
Raises:
NotFoundError: 目标版本不存在于历史记录
ConfigVersionConflictError: 配置版本冲突并发修改
ConfigRollbackError: Redis 回滚失败
"""
try:
scoped_key = self._build_key(cmd.key, cmd.scope, cmd.target)
history_raw = await self._redis.lrange(f"{scoped_key}:history", 0, -1)
target_entry: dict[str, Any] | None = None
for raw in history_raw:
entry = json.loads(raw)
if entry.get("version") == cmd.target_version:
target_entry = entry
break
if target_entry is None:
raise NotFoundError(
resource="config_version",
id=f"{cmd.key}@v{cmd.target_version}",
)
pipe = self._redis.pipeline()
await pipe.watch(f"{scoped_key}:version")
try:
current_value_raw = await pipe.get(scoped_key)
# 读取被归档版本的原始写入时间,作为历史条目的 updated_at
# (而非本次回滚时间),确保历史时间戳语义正确。
old_updated_at_raw = await pipe.get(f"{scoped_key}:updated_at")
version_str = await pipe.get(f"{scoped_key}:version")
current_version = int(version_str) if version_str else 0
new_version = current_version + 1
updated_at = utc_now_naive()
pipe.multi()
# target_entry["value"] 已是 JSON 字符串(从历史记录反序列化得到),
# 直接写入 Redis避免 json.dumps 导致双重编码。
pipe.set(scoped_key, target_entry["value"])
pipe.set(f"{scoped_key}:version", str(new_version))
pipe.set(f"{scoped_key}:updated_at", updated_at.isoformat())
if current_value_raw is not None:
pipe.lpush(
f"{scoped_key}:history",
json.dumps(
{
"version": current_version,
"value": current_value_raw,
"updated_at": old_updated_at_raw,
},
ensure_ascii=False,
),
)
try:
result = await pipe.execute()
except WatchError:
result = None
finally:
pipe.reset()
if result is None:
raise ConfigVersionConflictError(cmd.target_version, current_version)
failed_value = json.loads(current_value_raw) if current_value_raw is not None else None
rollback_value = json.loads(target_entry["value"])
return ConfigUpdateResult(
key=cmd.key,
version=new_version,
old_value=failed_value,
new_value=rollback_value,
updated_at=updated_at,
)
except (
NotFoundError,
ConfigVersionConflictError,
):
raise
except Exception as exc:
raise ConfigRollbackError(
key=cmd.key,
reason=f"redis rollback failed: {exc}",
) from exc
async def listHistory(
self,
key: str,
scope: ConfigScope = ConfigScope.GLOBAL,
target: str | None = None,
) -> tuple[ConfigHistoryEntry, ...]:
"""查询配置版本历史列表。
Redis ``{scoped_key}:history`` 列表读取历史版本条目解析为
``ConfigHistoryEntry`` 元组返回历史条目按 LPUSH 顺序存储最新
在前适配器保持原始顺序返回
兼容性旧版历史条目未持久化 ``updated_at`` 字段解析时缺失则
``updated_at`` ``None``新版条目适配器 update/rollback
写入携带 ``updated_at`` ISO 字符串
Args:
key: 配置键
scope: 配置作用域默认 GLOBAL
target: 目标channel_type / account_id / plugin_id可选
Returns:
ConfigHistoryEntry 元组无历史记录时返回空元组
"""
try:
scoped_key = self._build_key(key, scope, target)
history_raw = await self._redis.lrange(f"{scoped_key}:history", 0, -1)
entries: list[ConfigHistoryEntry] = []
for raw in history_raw:
entry = json.loads(raw)
updated_at_raw = entry.get("updated_at")
updated_at: datetime | None = None
if updated_at_raw is not None:
try:
updated_at = datetime.fromisoformat(updated_at_raw)
except ValueError as exc:
await self._logger.warn(
"redis config history updated_at parse failed",
key=key,
raw_value=updated_at_raw,
error=str(exc),
)
updated_at = None
# entry["value"] 为 JSON 字符串(写入时已序列化),
# 反序列化为 Python 对象返回。
value = json.loads(entry["value"])
entries.append(
ConfigHistoryEntry(
version=entry["version"],
value=value,
updated_at=updated_at,
)
)
return tuple(entries)
except Exception as exc:
raise DependencyError("redis", Error(str(exc))) from exc
async def getHotReloadMode(self, key: str) -> ConfigHotReloadMode:
"""查询配置热更新模式。
当前实现固定返回 HYBRID未来可扩展为从 Redis 读取可配置模式
Args:
key: 配置键
Returns:
ConfigHotReloadMode固定 HYBRID
"""
return ConfigHotReloadMode.HYBRID
async def getSchema(self, scope: ConfigScope = ConfigScope.GLOBAL) -> tuple[ConfigField, ...]:
"""查询配置 schema。
返回指定作用域的配置字段 schema描述所有配置字段的元信息包括
可热更新与不可热更新的配置字段schema 元数据由契约层
``config_schema.CONFIG_SCHEMA`` 集中定义适配器直接引用确保
单一真相源§13.1 / §6.1
Args:
scope: 配置作用域默认 GLOBAL
Returns:
ConfigField 元组包含可热更新与不可热更新的配置字段
"""
return CONFIG_SCHEMA
def _build_key(self, key: str, scope: ConfigScope, target: str | None) -> str:
"""构建 Redis 存储键。
依据作用域与目标构建分层配置键支持全局渠道级账户级与插件级配置
Args:
key: 配置键
scope: 配置作用域
target: 目标channel_type / account_id / plugin_id可选
Returns:
Redis 存储键字符串
"""
if scope == ConfigScope.GLOBAL:
return f"config:global:{key}"
if target is None:
return f"config:{scope.value}:{key}"
return f"config:{scope.value}:{target}:{key}"
async def getStreamingConfig(
self,
scope: ConfigScope = ConfigScope.GLOBAL,
target: str | None = None,
) -> StreamingConfig:
"""读取流式配置。
读取 ``streaming_enabled`` / ``enable_typing`` /
``streaming_min_chunk_interval_ms`` / ``streaming_ttl_seconds`` /
``typing_ttl_ms`` 五个配置项未持久化时由 ``get`` 方法
返回 schema 默认值``streaming_ttl_seconds`` 转换为毫秒返回
``min_chunk_interval_ms`` 单位一致
Args:
scope: 配置作用域默认 GLOBAL
target: 目标channel_type account_id可选
Returns:
StreamingConfig包含流式管道运行时配置
Raises:
DependencyError: Redis 依赖故障
"""
enabled_value = await self.get("streaming_enabled", scope, target)
typing_enabled_value = await self.get("enable_typing", scope, target)
interval_value = await self.get("streaming_min_chunk_interval_ms", scope, target)
ttl_value = await self.get("streaming_ttl_seconds", scope, target)
typing_ttl_value = await self.get("typing_ttl_ms", scope, target)
return StreamingConfig(
streaming_enabled=bool(enabled_value.value),
enable_typing=bool(typing_enabled_value.value),
min_chunk_interval_ms=int(interval_value.value),
streaming_ttl_ms=int(ttl_value.value) * 1000,
typing_ttl_ms=int(typing_ttl_value.value),
)
def _getSchemaDefault(self, key: str) -> Any:
"""查询配置 schema 默认值。
从契约层 ``CONFIG_SCHEMA`` 字段元组中查找指定键的默认值用于配置
未持久化时返回 schema 默认值Task 30直接引用 ``CONFIG_SCHEMA``
常量避免在同步方法中调用 ``async getSchema``
Args:
key: 配置键
Returns:
默认值schema 中不存在或默认值为 None 时返回 None
"""
for field in CONFIG_SCHEMA:
if field.key == key:
return field.default
return None
async def ping(self) -> bool:
"""主动探测 Redis 连接可用性,故障时返回 False降级不阻断
执行 Redis ``PING`` 验证连接可用性故障时返回 False 并通过
``self._logger`` 记录 warning 日志不抛异常 ``HostBootstrap``
启动期连通性检查使用
Returns:
True 表示连接可用False 表示故障
"""
try:
await self._redis.ping()
return True
except Exception as exc:
await self._logger.warn(
"redis config ping failed",
error=str(exc),
)
return False