优化(连接池与SSE): 连接池配置独立解耦与启动参数化,SSE空轮询平滑退避

This commit is contained in:
zizi 2026-09-21 21:17:23 +08:00
parent d5e338ad97
commit 1571abad43
5 changed files with 341 additions and 3 deletions

View File

@ -239,11 +239,19 @@ class 应用装配:
def 构建(配置: 应用配置, *, 时钟: 时钟 | None = None, 流程: 流程登记 | None = None) -> 应用装配: def 构建(配置: 应用配置, *, 时钟: 时钟 | None = None, 流程: 流程登记 | None = None) -> 应用装配:
"""注入按用途绑定的数据库工厂;装配与自检不建立连接。""" """注入按用途绑定的数据库工厂;装配与自检不建立连接。"""
最大连接 = 配置.连接池.最大连接
if 最大连接 is None:
最大连接 = 2 if 配置.运行用途.value == "evaluation" else 4
等待秒 = 配置.连接池.等待秒
if 等待秒 is None:
等待秒 = 2.0
数据库 = 数据库工厂( 数据库 = 数据库工厂(
配置.数据库, 配置.数据库,
配置.运行用途, 配置.运行用途,
常驻=True, 常驻=True,
最大连接=2 if 配置.运行用途.value == "evaluation" else 4, 最大连接=最大连接,
等待秒=等待秒,
) )
目录 = 参与者目录() 目录 = 参与者目录()
登记作品参与者(目录, 探索服务(Path(配置.探索草稿)) if 配置.探索草稿 else None, 解析本书规划引用) 登记作品参与者(目录, 探索服务(Path(配置.探索草稿)) if 配置.探索草稿 else None, 解析本书规划引用)

View File

@ -79,6 +79,7 @@ async def 订阅事件(
async def 数据流(): async def 数据流():
游标 = max(cursor, last_event_id or 0) 游标 = max(cursor, last_event_id or 0)
空轮询次数 = 0
while not await request.is_disconnected(): while not await request.is_disconnected():
结果 = await run_in_threadpool(服务.续接事件, task_id, 游标) 结果 = await run_in_threadpool(服务.续接事件, task_id, 游标)
页 = 呈现事件(结果) 页 = 呈现事件(结果)
@ -100,7 +101,11 @@ async def 订阅事件(
return return
if not 页.items: if not 页.items:
yield ": keep-alive\n\n" yield ": keep-alive\n\n"
await asyncio.sleep(1) 空轮询次数 += 1
else:
空轮询次数 = 0
等待秒 = 2.0 if (protocol == "bounded-v2" and 空轮询次数 >= 2) else 1.0
await asyncio.sleep(等待秒)
return StreamingResponse( return StreamingResponse(
数据流(), 数据流(),

View File

@ -9,6 +9,7 @@
from __future__ import annotations from __future__ import annotations
import math
import tomllib import tomllib
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
@ -21,6 +22,25 @@ from muse.共享.错误 import 配置错误
_允许的取值方式 = frozenset({"环境变量", "受控存储"}) _允许的取值方式 = frozenset({"环境变量", "受控存储"})
@dataclass(frozen=True, slots=True)
class 连接池配置:
"""运行连接池参数;独立于凭据引用,不参与数据库目标身份相等比较。"""
最大连接: int | None = None
等待秒: float | None = None
def __post_init__(self) -> None:
if self.最大连接 is not None:
if type(self.最大连接) is not int or self.最大连接 <= 0 or self.最大连接 > 100:
raise 配置错误("连接池.最大连接 必须是 1 到 100 之间的整数")
if self.等待秒 is not None:
if not isinstance(self.等待秒, (int, float)) or type(self.等待秒) is bool:
raise 配置错误("连接池.等待秒 必须是有效数值")
val = float(self.等待秒)
if math.isnan(val) or math.isinf(val) or not (0.1 <= val <= 60.0):
raise 配置错误("连接池.等待秒 必须在 0.1 到 60.0 之间的有限数值")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class 数据库引用: class 数据库引用:
"""数据库连接引用:只记录取值方式与位置,不含连接串明文。""" """数据库连接引用:只记录取值方式与位置,不含连接串明文。"""
@ -83,6 +103,7 @@ class 应用配置:
探索草稿: str | None = None 探索草稿: str | None = None
备份目录: str | None = None 备份目录: str | None = None
运行装配: 运行装配配置 = 运行装配配置() 运行装配: 运行装配配置 = 运行装配配置()
连接池: 连接池配置 = 连接池配置()
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not self.资源发布身份.strip(): if not self.资源发布身份.strip():
@ -130,6 +151,18 @@ def 解析配置(数据: dict[str, Any]) -> 应用配置:
运行装配 = 运行装配配置(**装配节) 运行装配 = 运行装配配置(**装配节)
except TypeError as exc: except TypeError as exc:
raise 配置错误("运行装配配置字段不合法") from exc raise 配置错误("运行装配配置字段不合法") from exc
池节 = 数据.get("数据库", {}).get("连接池")
if 池节 is None:
池节 = 数据.get("连接池")
if 池节 is not None:
if not isinstance(池节, dict):
raise 配置错误("数据库.连接池 必须是映射结构")
try:
连接池 = 连接池配置(**池节)
except TypeError as exc:
raise 配置错误("数据库.连接池 字段不合法") from exc
else:
连接池 = 连接池配置()
return 应用配置( return 应用配置(
数据库=_校验数据库节(数据["数据库"]), 数据库=_校验数据库节(数据["数据库"]),
资源发布身份=发布身份, 资源发布身份=发布身份,
@ -139,6 +172,7 @@ def 解析配置(数据: dict[str, Any]) -> 应用配置:
探索草稿=数据.get("文件", {}).get("探索草稿"), 探索草稿=数据.get("文件", {}).get("探索草稿"),
备份目录=数据.get("文件", {}).get("备份目录"), 备份目录=数据.get("文件", {}).get("备份目录"),
运行装配=运行装配, 运行装配=运行装配,
连接池=连接池,
) )
@ -187,4 +221,12 @@ def 读取配置(路径: str | Path) -> 应用配置:
return 配置 return 配置
__all__ = ["数据库引用", "应用配置", "服务配置", "运行装配配置", "解析配置", "读取配置"] __all__ = [
"数据库引用",
"连接池配置",
"应用配置",
"服务配置",
"运行装配配置",
"解析配置",
"读取配置",
]

View File

@ -0,0 +1,217 @@
"""连接池配置解耦、校验及 SSE 空轮询退避机制验证。"""
import asyncio
import pytest
from muse.共享.错误 import 配置错误
from muse.配置 import 数据库引用, 解析配置, 连接池配置
@pytest.mark.case_id("TC-POOL-CONFIG-VALIDATION")
def test_连接池配置字段强校验():
# 正常值
cfg = 连接池配置(最大连接=8, 等待秒=5.0)
assert cfg.最大连接 == 8
assert cfg.等待秒 == 5.0
# 最大连接不接受非 int、bool、越界
with pytest.raises(配置错误, match="连接池.最大连接"):
连接池配置(最大连接=True) # type: ignore
with pytest.raises(配置错误, match="连接池.最大连接"):
连接池配置(最大连接=0)
with pytest.raises(配置错误, match="连接池.最大连接"):
连接池配置(最大连接=101)
with pytest.raises(配置错误, match="连接池.最大连接"):
连接池配置(最大连接="8") # type: ignore
# 等待秒不接受 bool、NaN、Inf、越界
with pytest.raises(配置错误, match="连接池.等待秒"):
连接池配置(等待秒=False) # type: ignore
with pytest.raises(配置错误, match="连接池.等待秒"):
连接池配置(等待秒=float("nan"))
with pytest.raises(配置错误, match="连接池.等待秒"):
连接池配置(等待秒=float("inf"))
with pytest.raises(配置错误, match="连接池.等待秒"):
连接池配置(等待秒=0.05)
with pytest.raises(配置错误, match="连接池.等待秒"):
连接池配置(等待秒=61.0)
@pytest.mark.case_id("TC-POOL-CONFIG-DECOUPLING")
def test_连接池配置不改变数据库引用相等性():
ref1 = 数据库引用("环境变量", "DB_URL")
ref2 = 数据库引用("环境变量", "DB_URL")
assert ref1 == ref2
# 配置解析产出独立连接池配置
cfg = 解析配置(
{
"数据库": {
"取值方式": "环境变量",
"位置": "DB_URL",
"连接池": {"最大连接": 10, "等待秒": 3.0},
},
"资源": {"发布身份": "identity-1"},
}
)
assert cfg.数据库 == ref1
assert cfg.连接池.最大连接 == 10
assert cfg.连接池.等待秒 == 3.0
@pytest.mark.case_id("TC-POOL-CONFIG-STARTUP-INTEGRATION")
def test_启动构建消费连接池配置(monkeypatch):
from muse.启动 import 构建
# 1. 显式配置生效
cfg = 解析配置(
{
"数据库": {
"取值方式": "环境变量",
"位置": "DB_URL",
"连接池": {"最大连接": 12, "等待秒": 4.5},
},
"资源": {"发布身份": "identity-1"},
"运行": {"用途": "production"},
}
)
装配 = 构建(cfg)
assert 装配.数据库.最大连接 == 12
assert 装配.数据库.等待秒 == 4.5
# 2. 未配置时回退默认(生产 4 / 2.0,评测 2 / 2.0)
cfg_prod = 解析配置(
{
"数据库": {"取值方式": "环境变量", "位置": "DB_URL"},
"资源": {"发布身份": "identity-1"},
"运行": {"用途": "production"},
}
)
装配_prod = 构建(cfg_prod)
assert 装配_prod.数据库.最大连接 == 4
assert 装配_prod.数据库.等待秒 == 2.0
cfg_eval = 解析配置(
{
"数据库": {"取值方式": "环境变量", "位置": "DB_URL"},
"资源": {"发布身份": "identity-1"},
"运行": {"用途": "evaluation"},
}
)
装配_eval = 构建(cfg_eval)
assert 装配_eval.数据库.最大连接 == 2
assert 装配_eval.数据库.等待秒 == 2.0
@pytest.mark.case_id("TC-SSE-BACKOFF-ADAPTIVE")
def test_SSE空轮询退避机制(monkeypatch):
from types import SimpleNamespace
from muse.任务运行.模型 import 事件类型, 事件续接, 运行事件
from muse.接入.http import 事件订阅
def 构造事件(seq):
from datetime import UTC, datetime
return 运行事件(
f"event-{seq}",
"task",
None,
None,
seq,
事件类型.步骤完成,
datetime(2026, 9, 17, tzinfo=UTC),
1,
{"step": f"step-{seq}"},
)
class 模拟事件服务:
def __init__(self, 页集):
self.页集 = 页集
def 续接事件(self, task_id, cursor, 数量=100):
return self.页集.get(cursor, 事件续接((), False, cursor, cursor, None))
def 运行订阅(protocol, 页集, 循环次数=4):
服务 = 模拟事件服务(页集)
monkeypatch.setattr(事件订阅, "取得任务服务", lambda _: 服务)
monkeypatch.setattr(事件订阅, "读取作者任务", lambda req, tid, wid=None: None)
等待记录 = []
async def 记录等待(秒):
等待记录.append(秒)
monkeypatch.setattr(事件订阅.asyncio, "sleep", 记录等待)
检查次数 = [0]
async def 已断开():
检查次数[0] += 1
return 检查次数[0] > 循环次数
async def 执行():
响应 = await 事件订阅.订阅事件(
"task-1",
SimpleNamespace(is_disconnected=已断开),
"author",
cursor=0,
last_event_id=None,
protocol=protocol,
)
return [块 async for 块 in 响应.body_iterator]
asyncio.run(执行())
return 等待记录
# 1. bounded-v2: 连续空轮询从 1.0s 退避到 2.0s
等待_v2 = 运行订阅("bounded-v2", {}, 循环次数=4)
assert 等待_v2 == [1.0, 2.0, 2.0, 2.0]
# 2. legacy: 始终保持 1.0s
等待_legacy = 运行订阅("legacy", {}, 循环次数=4)
assert 等待_legacy == [1.0, 1.0, 1.0, 1.0]
# 3. bounded-v2: 退避后有新事件到达,立即重置回 1.0s
class 动态服务:
def __init__(self):
self.count = 0
def 续接事件(self, task_id, cursor, 数量=100):
self.count += 1
if self.count == 3:
return 事件续接((构造事件(1),), False, 1, 1, None)
return 事件续接((), False, cursor, cursor, None)
服务动态 = 动态服务()
monkeypatch.setattr(事件订阅, "取得任务服务", lambda _: 服务动态)
monkeypatch.setattr(事件订阅, "读取作者任务", lambda req, tid, wid=None: None)
动态等待 = []
async def 记录动态等待(秒):
动态等待.append(秒)
monkeypatch.setattr(事件订阅.asyncio, "sleep", 记录动态等待)
检查 = [0]
async def 已断开_动态():
检查[0] += 1
return 检查[0] > 4
async def 执行动态():
响应 = await 事件订阅.订阅事件(
"task-1",
SimpleNamespace(is_disconnected=已断开_动态),
"author",
cursor=0,
last_event_id=None,
protocol="bounded-v2",
)
return [块 async for 块 in 响应.body_iterator]
asyncio.run(执行动态())
# 轮询1: 空 (等待1.0)
# 轮询2: 空 (等待2.0)
# 轮询3: 有事件 (重置为0,等待1.0)
# 轮询4: 空 (等待1.0)
assert 动态等待 == [1.0, 2.0, 1.0, 1.0]

View File

@ -35588,6 +35588,72 @@
], ],
"markers": [] "markers": []
}, },
{
"case_id": "TC-POOL-CONFIG-DECOUPLING",
"file": "tests/单元/test_连接池配置与退避.py",
"symbol": "test_连接池配置不改变数据库引用相等性",
"parameter_ids": [],
"node_ids": [
"tests/单元/test_连接池配置与退避.py::test_连接池配置不改变数据库引用相等性"
],
"fixtures": [
"request",
"测试资源接缝",
"源码资源",
"离线防护"
],
"markers": []
},
{
"case_id": "TC-POOL-CONFIG-STARTUP-INTEGRATION",
"file": "tests/单元/test_连接池配置与退避.py",
"symbol": "test_启动构建消费连接池配置",
"parameter_ids": [],
"node_ids": [
"tests/单元/test_连接池配置与退避.py::test_启动构建消费连接池配置"
],
"fixtures": [
"monkeypatch",
"request",
"测试资源接缝",
"源码资源",
"离线防护"
],
"markers": []
},
{
"case_id": "TC-POOL-CONFIG-VALIDATION",
"file": "tests/单元/test_连接池配置与退避.py",
"symbol": "test_连接池配置字段强校验",
"parameter_ids": [],
"node_ids": [
"tests/单元/test_连接池配置与退避.py::test_连接池配置字段强校验"
],
"fixtures": [
"request",
"测试资源接缝",
"源码资源",
"离线防护"
],
"markers": []
},
{
"case_id": "TC-SSE-BACKOFF-ADAPTIVE",
"file": "tests/单元/test_连接池配置与退避.py",
"symbol": "test_SSE空轮询退避机制",
"parameter_ids": [],
"node_ids": [
"tests/单元/test_连接池配置与退避.py::test_SSE空轮询退避机制"
],
"fixtures": [
"monkeypatch",
"request",
"测试资源接缝",
"源码资源",
"离线防护"
],
"markers": []
},
{ {
"case_id": "TC-a089d73880d8", "case_id": "TC-a089d73880d8",
"environment": "隔离PG、实际S02与合成HTTP;不认证外部模型文学收益", "environment": "隔离PG、实际S02与合成HTTP;不认证外部模型文学收益",