refactor: own coalescer flush lifecycle

This commit is contained in:
jxxghp
2026-08-24 03:11:49 +08:00
parent e1230c0277
commit e768eb46fe
9 changed files with 71 additions and 7 deletions
+5
View File
@@ -950,6 +950,11 @@ _image_proxy_block_log_coalescer = EventCoalescer(
) )
async def close_image_proxy_block_log_coalescer() -> None:
"""刷新图片代理阻断摘要,并等待已经启动的窗口 flush 任务。"""
await _image_proxy_block_log_coalescer.close()
async def _emit_image_proxy_block_warning( async def _emit_image_proxy_block_warning(
*, *,
url: str, url: str,
+10 -1
View File
@@ -97,6 +97,7 @@ class EventCoalescer:
self._source = source self._source = source
self._buckets: Dict[Hashable, _BucketState] = {} self._buckets: Dict[Hashable, _BucketState] = {}
self._is_flush_async = inspect.iscoroutinefunction(on_flush) self._is_flush_async = inspect.iscoroutinefunction(on_flush)
self._flush_tasks: set[asyncio.Task[None]] = set()
@property @property
def window_seconds(self) -> float: def window_seconds(self) -> float:
@@ -144,6 +145,12 @@ class EventCoalescer:
if bucket.flush_handle is not None: if bucket.flush_handle is not None:
bucket.flush_handle.cancel() bucket.flush_handle.cancel()
await self._emit_summary_if_needed(key, bucket) await self._emit_summary_if_needed(key, bucket)
current_task = asyncio.current_task()
flush_tasks = tuple(
task for task in self._flush_tasks if task is not current_task
)
if flush_tasks:
await asyncio.gather(*flush_tasks, return_exceptions=True)
def _schedule_flush(self, key: Hashable) -> asyncio.TimerHandle: def _schedule_flush(self, key: Hashable) -> asyncio.TimerHandle:
""" """
@@ -161,7 +168,9 @@ class EventCoalescer:
`loop.call_later` 到期回调:从事件循环里把异步 flush 任务接力起来。 `loop.call_later` 到期回调:从事件循环里把异步 flush 任务接力起来。
""" """
try: try:
asyncio.get_running_loop().create_task(self._flush_key(key)) task = asyncio.get_running_loop().create_task(self._flush_key(key))
self._flush_tasks.add(task)
task.add_done_callback(self._flush_tasks.discard)
except RuntimeError as exc: except RuntimeError as exc:
# 事件循环已关闭等罕见路径:记录后丢弃,避免影响其它 bucket # 事件循环已关闭等罕见路径:记录后丢弃,避免影响其它 bucket
self._log_debug(f"flush 调度失败,已忽略 key={key!r}: {exc}") self._log_debug(f"flush 调度失败,已忽略 key={key!r}: {exc}")
+2
View File
@@ -77,6 +77,7 @@ from app.application.messaging.agent import (
from app.application.security.user import configure_user_lookups from app.application.security.user import configure_user_lookups
from app.application.security.auth import AuthService, configure_auth_service from app.application.security.auth import AuthService, configure_auth_service
from app.application.security.passkeys import PasskeyService, configure_passkey_service from app.application.security.passkeys import PasskeyService, configure_passkey_service
from app.application.security.url import close_image_proxy_block_log_coalescer
from app.application.security.userconfig import ( from app.application.security.userconfig import (
UserConfigurationService, UserConfigurationService,
configure_user_configuration, configure_user_configuration,
@@ -594,6 +595,7 @@ async def stop_modules():
logger.error(f"关闭{name}失败:{err}") logger.error(f"关闭{name}失败:{err}")
return True return True
await run_step("图片代理安全日志合并器", close_image_proxy_block_log_coalescer)
await run_step("模块", lambda: ModuleManager().shutdown()) await run_step("模块", lambda: ModuleManager().shutdown())
await run_step("事件消费", lambda: EventManager().stop_async()) await run_step("事件消费", lambda: EventManager().stop_async())
await run_step("浏览器会话", close_browser_sessions) await run_step("浏览器会话", close_browser_sessions)
@@ -52,7 +52,8 @@ Event Contract Registry 是 53 个事件的逐项机器清单。下表按相同
- `SubscribeAdded``SubscribeModified``SubscribeDeleted`:订阅业务行 commit 是业务完成点;事件、 - `SubscribeAdded``SubscribeModified``SubscribeDeleted`:订阅业务行 commit 是业务完成点;事件、
通知和服务端上报必须由同事务 durable intent 驱动。ARCH-251 首选 `SubscribeAdded` pilot。 通知和服务端上报必须由同事务 durable intent 驱动。ARCH-251 首选 `SubscribeAdded` pilot。
- `DownloadAdded`:下载提交成功后,历史/通知不得仅依赖进程内回调;后续独立 pilot。 - `DownloadAdded`:下载器确认接收后,下载历史与事件 intent 已在返回前原子提交;通知和模块后处理只在
commit 后启动,事件由 Outbox 恢复投递。
- `TransferComplete``TransferFailed`:整理步骤本身属于 E3,但向事件消费者发布结果属于 E2。 - `TransferComplete``TransferFailed`:整理步骤本身属于 E3,但向事件消费者发布结果属于 E2。
## 非 Event 后台机制映射 ## 非 Event 后台机制映射
@@ -66,6 +67,8 @@ Event Contract Registry 是 53 个事件的逐项机器清单。下表按相同
- Slack、Telegram、Discord、飞书、QQBot、企业微信与 WeChatClawBot 的渠道回环统一经 - Slack、Telegram、Discord、飞书、QQBot、企业微信与 WeChatClawBot 的渠道回环统一经
`application.messaging.ingress` 进入同一个 API/TaskRegistry 主链;需要立即返回 SDK 回调的渠道把同步 `application.messaging.ingress` 进入同一个 API/TaskRegistry 主链;需要立即返回 SDK 回调的渠道把同步
HTTP 交给宿主共享线程池,模块关闭后由线程池生命周期等待,不再创建逐消息 daemon 线程。 HTTP 交给宿主共享线程池,模块关闭后由线程池生命周期等待,不再创建逐消息 daemon 线程。
- 图片代理安全日志的窗口聚合属于 E1 观测;`EventCoalescer` 持有到期 flush task,模块关闭会取消未到期
timer、刷新剩余摘要并等待已启动回调,不再把 `create_task` 留给事件循环隐式回收。
- 主仓不再新增或保留裸 FastAPI `BackgroundTasks`;若任务源于已提交的用户数据且不可从数据库重建, - 主仓不再新增或保留裸 FastAPI `BackgroundTasks`;若任务源于已提交的用户数据且不可从数据库重建,
必须提升为 E2,进入 Outbox 或持久任务表。 必须提升为 E2,进入 Outbox 或持久任务表。
@@ -6,7 +6,7 @@
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本 > 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
> 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文 > 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文
> 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md` > 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md`
> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界。 > 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权
## 当前复核结论(2026-08-24 ## 当前复核结论(2026-08-24
@@ -15,7 +15,7 @@
### 长期整改阶段 0:治理门禁恢复(2026-08-23 ### 长期整改阶段 0:治理门禁恢复(2026-08-23
- 宿主依赖基线已审查 TaskRegistry、有界后台 owner 与插件变更准入接入后的语义差异:当前为 `806` 个模块、`6528` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。 - 宿主依赖基线已审查 TaskRegistry、有界后台 owner 与插件变更准入接入后的语义差异:当前为 `806` 个模块、`6529` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。
- 启动性能探针会在隔离生命周期中真实创建并释放 TaskRegistrynormal/safe 组件数分别为 `23`/`11`,CI 只读检查使用稳定的宿主模块集合和生命周期组件顺序,不再把 Python/平台模块数量当作硬合同。 - 启动性能探针会在隔离生命周期中真实创建并释放 TaskRegistrynormal/safe 组件数分别为 `23`/`11`,CI 只读检查使用稳定的宿主模块集合和生命周期组件顺序,不再把 Python/平台模块数量当作硬合同。
- 官方插件快照覆盖 `plugins.v3``plugins.v2` 以及 V3 实际会从 `package.json` 回退加载的 31 个默认实现;`app/plugins/**` 仍只是宿主运行副本,不进入扫描。 - 官方插件快照覆盖 `plugins.v3``plugins.v2` 以及 V3 实际会从 `package.json` 回退加载的 31 个默认实现;`app/plugins/**` 仍只是宿主运行副本,不进入扫描。
- SDK 快照以各模块显式 `__all__` 为公开合同,能够记录赋值别名;`typing``__future__` 等实现期导入不再被误冻结,既有数据库备份门面已补精确导出清单。 - SDK 快照以各模块显式 `__all__` 为公开合同,能够记录赋值别名;`typing``__future__` 等实现期导入不再被误冻结,既有数据库备份门面已补精确导出清单。
@@ -112,7 +112,7 @@
- 继续采用单进程控制面是正确选择,不建议现在拆成微服务;插件、调度器、工作流、事件和数据库共享进程内状态,拆分会放大部署、事务和兼容成本。 - 继续采用单进程控制面是正确选择,不建议现在拆成微服务;插件、调度器、工作流、事件和数据库共享进程内状态,拆分会放大部署、事务和兼容成本。
- `foundation/domain/runtime/adapters/application/chain/api/startup` 的职责方向基本成立;宿主架构基线、复杂度 ratchet、异步阻塞 ratchet 当前均通过。 - `foundation/domain/runtime/adapters/application/chain/api/startup` 的职责方向基本成立;宿主架构基线、复杂度 ratchet、异步阻塞 ratchet 当前均通过。
- 依赖图当前为 `806` 个 Python 模块、`6528` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。 - 依赖图当前为 `806` 个 Python 模块、`6529` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。
- 当前主要风险已经从“目录和依赖失控”转移到运行时协议、后台副作用的可靠性和遗留兼容面。换言之,下一阶段重点应是**语义收口和可验证性**,而不是继续搬文件或机械拆大文件。 - 当前主要风险已经从“目录和依赖失控”转移到运行时协议、后台副作用的可靠性和遗留兼容面。换言之,下一阶段重点应是**语义收口和可验证性**,而不是继续搬文件或机械拆大文件。
综合评价:架构方向可持续,生产可用性较高;可演进性仍处于中等水平。现阶段没有静态审计发现必须立即推倒重来的 P0 架构问题,但存在需要按 P1/P2 计划治理的真实债务。 综合评价:架构方向可持续,生产可用性较高;可演进性仍处于中等水平。现阶段没有静态审计发现必须立即推倒重来的 P0 架构问题,但存在需要按 P1/P2 计划治理的真实债务。
@@ -134,6 +134,8 @@
与业务删除原子暂存事件和统计 intent;旧类方法只作为插件 ABI 保留,不纳入宿主可靠性证明。 与业务删除原子暂存事件和统计 intent;旧类方法只作为插件 ABI 保留,不纳入宿主可靠性证明。
消息渠道回环也不再各自拥有 URL、HTTP 判定和逐消息线程:七种宿主渠道共享 ingress,三种立即返回 消息渠道回环也不再各自拥有 URL、HTTP 判定和逐消息线程:七种宿主渠道共享 ingress,三种立即返回
渠道交给生命周期持有的共享线程池;这仍是 E0 投递,不宣称跨进程恢复。 渠道交给生命周期持有的共享线程池;这仍是 E0 投递,不宣称跨进程恢复。
图片代理安全日志的窗口聚合也已补齐内部任务所有权:timer 到期创建的 flush task 由 coalescer 持有,
`stop_modules()` 会刷新未到期摘要并等待已启动回调;它属于 E1 观测,不扩大 TaskRegistry 或 Outbox 范围。
2. **Model/Base 的数据库装饰器和隐式会话 ABI 已全部清零。** 查询、写事务和 `legacy_*` 装饰器均为 `0`;所有 Model `db` 参数要求显式 Session,Base CRUD 仅在调用方事务内查询或 stage。可无会话构造的入口统一留在 Oper,经组合根事务执行器运行;插件 SDK 不再导出宿主 Model。后续重点转为减少 ORM 对象跨层流转,并保持 Model 隐式事务零回退。 2. **Model/Base 的数据库装饰器和隐式会话 ABI 已全部清零。** 查询、写事务和 `legacy_*` 装饰器均为 `0`;所有 Model `db` 参数要求显式 Session,Base CRUD 仅在调用方事务内查询或 stage。可无会话构造的入口统一留在 Oper,经组合根事务执行器运行;插件 SDK 不再导出宿主 Model。后续重点转为减少 ORM 对象跨层流转,并保持 Model 隐式事务零回退。
Oper 内部的执行入口也已统一:最后一处 `AgentTaskOper` 直接 transaction runner 调用已迁入 Oper 内部的执行入口也已统一:最后一处 `AgentTaskOper` 直接 transaction runner 调用已迁入
+1
View File
@@ -12,6 +12,7 @@ files =
app/domain/torrent.py, app/domain/torrent.py,
app/domain/meta/infopath.py, app/domain/meta/infopath.py,
app/runtime/correlation.py, app/runtime/correlation.py,
app/runtime/coalesce.py,
app/runtime/observability/__init__.py, app/runtime/observability/__init__.py,
app/runtime/event/contracts.py, app/runtime/event/contracts.py,
app/runtime/event/errors.py, app/runtime/event/errors.py,
+3 -2
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [], "runtime_to_db": [],
"workflow_to_db": [] "workflow_to_db": []
}, },
"edge_count": 6528, "edge_count": 6529,
"edge_sha256": "2e30c775e78f578d804f69972ab1348e1e9d0872b00bdbfcffad08398fae68ac", "edge_sha256": "daf8459c6194e28766d62c5f16a0e1bb64caf3f3430f51736b57da505e4401e1",
"edges": [ "edges": [
"app -> app.runtime", "app -> app.runtime",
"app -> app.runtime.compat", "app -> app.runtime.compat",
@@ -6183,6 +6183,7 @@
"app.startup.initializers.modules -> app.application.security", "app.startup.initializers.modules -> app.application.security",
"app.startup.initializers.modules -> app.application.security.auth", "app.startup.initializers.modules -> app.application.security.auth",
"app.startup.initializers.modules -> app.application.security.passkeys", "app.startup.initializers.modules -> app.application.security.passkeys",
"app.startup.initializers.modules -> app.application.security.url",
"app.startup.initializers.modules -> app.application.security.user", "app.startup.initializers.modules -> app.application.security.user",
"app.startup.initializers.modules -> app.application.security.userconfig", "app.startup.initializers.modules -> app.application.security.userconfig",
"app.startup.initializers.modules -> app.application.server", "app.startup.initializers.modules -> app.application.server",
+22
View File
@@ -171,6 +171,28 @@ class EventCoalescerTest(IsolatedAsyncioTestCase):
self.assertEqual(len(awaited), 1) self.assertEqual(len(awaited), 1)
self.assertEqual(awaited[0].count, 2) self.assertEqual(awaited[0].count, 2)
async def test_close_waits_for_started_flush_task(self):
"""窗口任务已经进入异步回调时,close 必须等待它真正结束。"""
started = asyncio.Event()
release = asyncio.Event()
async def on_flush(_summary: CoalesceSummary) -> None:
"""阻塞 flush 回调,暴露关闭与在途任务之间的时序。"""
started.set()
await release.wait()
coalescer = EventCoalescer(_TEST_WINDOW, on_flush)
await coalescer.record("k", payload="a")
await coalescer.record("k", payload="b")
await asyncio.wait_for(started.wait(), timeout=_TEST_WAIT)
close_task = asyncio.create_task(coalescer.close())
await asyncio.sleep(0)
self.assertFalse(close_task.done())
release.set()
await asyncio.wait_for(close_task, timeout=_TEST_WAIT)
async def test_on_flush_exception_is_swallowed(self): async def test_on_flush_exception_is_swallowed(self):
""" """
on_flush 抛异常不能影响 coalescer 自身或上层调用方,仅 debug 记录。 on_flush 抛异常不能影响 coalescer 自身或上层调用方,仅 debug 记录。
+19
View File
@@ -1120,6 +1120,16 @@ def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
monkeypatch.setattr(modules_initializer, name, dependency) monkeypatch.setattr(modules_initializer, name, dependency)
dependencies[name] = dependency dependencies[name] = dependency
close_image_proxy_block_log_coalescer = AsyncMock()
monkeypatch.setattr(
modules_initializer,
"close_image_proxy_block_log_coalescer",
close_image_proxy_block_log_coalescer,
)
dependencies["close_image_proxy_block_log_coalescer"] = (
close_image_proxy_block_log_coalescer
)
stop_managed_resources = AsyncMock() stop_managed_resources = AsyncMock()
monkeypatch.setattr( monkeypatch.setattr(
modules_initializer, modules_initializer,
@@ -1158,6 +1168,15 @@ def test_browser_sessions_close_before_managed_resources(monkeypatch) -> None:
assert calls == ["browser", "resources"] assert calls == ["browser", "resources"]
def test_module_shutdown_waits_for_image_proxy_log_coalescer(monkeypatch) -> None:
"""模块关闭必须等待图片安全日志的在途聚合任务收口。"""
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
asyncio.run(modules_initializer.stop_modules())
dependencies["close_image_proxy_block_log_coalescer"].assert_awaited_once_with()
def test_shared_http_close_waits_for_real_lru_eviction(monkeypatch): def test_shared_http_close_waits_for_real_lru_eviction(monkeypatch):
"""最终 HTTP 关闭必须等待真实 LRU 淘汰任务并消费其异常""" """最终 HTTP 关闭必须等待真实 LRU 淘汰任务并消费其异常"""