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

645 lines
28 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 collections.abc import Mapping
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,
ConfigValidationError,
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"]
# CONFIG_SCHEMA 已声明键集合§13.1 单一真相源):作为无 registry 注入时的
# 向后兼容回退。正常路径下 _assertDeclared 通过 registry.declared_keys 动态
# 查询,使插件 manifest 声明的键也能通过校验。
_DECLARED_KEYS: frozenset[str] = frozenset(f.key for f in CONFIG_SCHEMA)
# 哨兵:区分"key 不在 CONFIG_SCHEMA 中"(插件 manifest 键)与"default=None"
# 合法默认值。_getSchemaDefault 返回 _MISSING 表示前者,返回 None 表示
# 后者,避免把 default=None 误判为"无默认值"而抛 NotFoundError。
_MISSING: Any = object()
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,
key_to_scope_map: Mapping[str, ConfigScope] | None = None,
declared_keys: set[str] | None = None,
) -> None:
"""初始化 Redis 配置适配器。
Args:
redis_client: ``redis.asyncio.Redis`` 客户端实例由外部构造并
注入适配器不管理其生命周期构造注入INV-5需使用
``decode_responses=True`` 创建保证读写值为字符串
logger: 日志被驱动端口强制注入用于记录降级分支异常日志
INV-10 可观测性
key_to_scope_map: F-02 配置作用域映射``key ConfigScope``
``ConfigScopeRegistry.key_to_scope_map`` 注入只读视图
``_build_key`` key 存在于映射时校验声明作用域与实际作用域
一致性不一致产生 WARNING 日志不阻断读写 ``None`` 时不
校验向后兼容registry 通过 ``dict.update`` 原地修改内部
dictadapter 持有的只读视图在 discover 后可见插件声明的作用域
declared_keys: 已声明的配置键集合
``ConfigScopeRegistry._declared_keys`` 注入内部 ``set`` 引用
``_assertDeclared`` 通过此集合校验键合法性使插件 manifest
声明的键也能通过校验registry 通过 ``set.add`` 原地修改内部
setadapter 持有的引用在 discover 后可见插件键 ``None``
回退到模块级 ``_DECLARED_KEYS``仅含 CONFIG_SCHEMA 向后兼容
"""
self._redis = redis_client
self._logger = logger
self._key_to_scope_map: Mapping[str, ConfigScope] | None = key_to_scope_map
self._declared_keys: set[str] | None = declared_keys
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 依赖故障
ConfigValidationError: 键未在 CONFIG_SCHEMA 中声明
"""
self._assertDeclared(key)
try:
scoped_key = await self._build_key(key, scope, target)
value = await self._redis.get(scoped_key)
actual_key = scoped_key
# PLUGIN 作用域配置独立,不回退到全局配置
# skip_scope_check=True回退到 GLOBAL 时,声明作用域(如 CHANNEL
# 与实际作用域GLOBAL必然不一致此为正常回退语义而非配置错误
# 跳过 F-02 校验避免误报 "config scope mismatch" 告警。
if value is None and scope != ConfigScope.PLUGIN:
actual_key = await self._build_key(key, ConfigScope.GLOBAL, None, skip_scope_check=True)
value = await self._redis.get(actual_key)
if value is None:
default = self._getSchemaDefault(key)
if default is not _MISSING:
# schema 中声明了该 key含 default=None 的合法默认值),
# 返回 schema 默认值;仅当 key 不在 CONFIG_SCHEMA 中
# (插件 manifest 键且未持久化)时才抛 NotFoundError。
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 依赖故障
ConfigValidationError: 键未在 CONFIG_SCHEMA 中声明
"""
self._assertDeclared(cmd.key)
try:
scoped_key = await 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:
await 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``避免误报"刚刚更新"
"""
self._assertDeclared(key)
try:
scoped_key = await 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 回滚失败
ConfigValidationError: 键未在 CONFIG_SCHEMA 中声明
"""
self._assertDeclared(cmd.key)
try:
scoped_key = await 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:
await 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 元组无历史记录时返回空元组
"""
self._assertDeclared(key)
try:
scoped_key = await 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 _assertDeclared(self, key: str) -> None:
"""校验配置键已声明§13.1 单一真相源)。
正常路径下通过 ``ConfigScopeRegistry`` 注入的 ``declared_keys`` 集合
动态校验该集合聚合了 ``CONFIG_SCHEMA`` 全部键与插件
``manifest.config_schema`` 全部键使插件专有键 corp_id
bridge_url也能通过校验manifest 真正成为插件配置的单真相源
F-03
未注入 ``declared_keys`` 时回退到模块级 ``_DECLARED_KEYS``仅含
CONFIG_SCHEMA 向后兼容旧测试 / 独立构造场景
未声明 key 直接拒绝不访问 Redis防止绕过 schema 直接写 Redis
的脏值被读回破坏单一真相源
Args:
key: 配置键
Raises:
ConfigValidationError: 键未声明
"""
declared = self._declared_keys if self._declared_keys is not None else _DECLARED_KEYS
if key not in declared:
raise ConfigValidationError([f"Config key '{key}' is not declared in CONFIG_SCHEMA"])
async def _build_key(
self,
key: str,
scope: ConfigScope,
target: str | None,
*,
skip_scope_check: bool = False,
) -> str:
"""构建 Redis 存储键并校验作用域一致性F-02
依据作用域与目标构建分层配置键支持全局渠道级账户级与插件级配置
F-02 作用域一致性校验 ``key_to_scope_map`` 已注入且 ``key``
映射中声明了作用域时校验声明作用域与传入的实际作用域是否一致
不一致时产生 WARNING 日志 key 声明作用域实际作用域
**不阻断读写**向后兼容允许迁移期存在暂时不一致未声明作用域
key 不产生告警向后兼容
``skip_scope_check=True`` 用于 ``get`` 方法的 GLOBAL 回退路径
回退是设计内的降级行为CHANNEL/ACCOUNT 未命中时查 GLOBAL并非
作用域使用错误不应触发 F-02 告警
Args:
key: 配置键
scope: 配置作用域实际读写作用域
target: 目标channel_type / account_id / plugin_id可选
skip_scope_check: 跳过 F-02 校验 ``get`` 回退路径使用
Returns:
Redis 存储键字符串
"""
if not skip_scope_check and self._key_to_scope_map is not None:
declared_scope = self._key_to_scope_map.get(key)
if declared_scope is not None and declared_scope != scope:
await self._logger.warn(
"config scope mismatch",
key=key,
declared_scope=declared_scope.value,
actual_scope=scope.value,
target=target or "",
)
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:
默认值可能为 ``None``表示 ``default=None`` 的合法默认值
key 不在 ``CONFIG_SCHEMA`` 中时返回 ``_MISSING`` 哨兵
由调用方据此抛 ``NotFoundError``
"""
for field in CONFIG_SCHEMA:
if field.key == key:
return field.default
return _MISSING
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