mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
refactor: bound async log writer shutdown
This commit is contained in:
+129
-40
@@ -8,16 +8,17 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional, Protocol
|
||||
from types import FrameType
|
||||
from typing import Any, Callable, Dict, Optional, Protocol, Self
|
||||
|
||||
import click
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class LogConfigModel(BaseModel):
|
||||
# strict mypy 跳过第三方实现导入,因此无法在本文件解析 Pydantic 元类类型。
|
||||
class LogConfigModel(BaseModel): # type: ignore[misc]
|
||||
"""描述日志级别、格式和文件写入策略。"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
@@ -34,6 +35,7 @@ class LogConfigModel(BaseModel):
|
||||
"【%(levelname)s】%(asctime)s [%(correlation_id)s] - %(message)s"
|
||||
)
|
||||
ASYNC_FILE_QUEUE_SIZE: int = 1000
|
||||
# 保留历史配置解析兼容;协程环境文件日志已统一由单一有界队列 writer 执行。
|
||||
ASYNC_FILE_WORKERS: int = 2
|
||||
BATCH_WRITE_SIZE: int = 50
|
||||
WRITE_TIMEOUT: float = 3.0
|
||||
@@ -72,12 +74,13 @@ class LogWriter(Protocol):
|
||||
def write_log(self, level: str, message: str, file_path: Path) -> None:
|
||||
"""将一条日志写入指定文件。"""
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""排空待写日志并释放写入资源。"""
|
||||
def shutdown(self) -> Optional[bool]:
|
||||
"""排空待写日志并释放写入资源,未收敛时返回 False。"""
|
||||
|
||||
|
||||
log_settings = LogSettings()
|
||||
_correlation_id_provider: Callable[[], str | None] = lambda: None
|
||||
_LOG_STOP_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
|
||||
def configure_correlation_id_provider(provider: Callable[[], str | None]) -> None:
|
||||
@@ -96,9 +99,9 @@ class NonBlockingFileHandler:
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
_stop_sentinel = object()
|
||||
_stop_sentinel = None
|
||||
|
||||
def __new__(cls):
|
||||
def __new__(cls) -> Self:
|
||||
"""返回进程内唯一的文件写入器。"""
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
@@ -114,12 +117,14 @@ class NonBlockingFileHandler:
|
||||
self._state_lock = threading.RLock()
|
||||
self._handlers_lock = threading.Lock()
|
||||
self._rotating_handlers: dict[Path, RotatingFileHandler] = {}
|
||||
self._write_queue = queue.Queue(maxsize=log_settings.ASYNC_FILE_QUEUE_SIZE)
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=log_settings.ASYNC_FILE_WORKERS,
|
||||
thread_name_prefix="LogWriter",
|
||||
self._write_queue: queue.Queue[Optional[LogEntry]] = queue.Queue(
|
||||
maxsize=log_settings.ASYNC_FILE_QUEUE_SIZE,
|
||||
)
|
||||
self._stop_requested = threading.Event()
|
||||
self._running = True
|
||||
self._closed = False
|
||||
self._close_thread: Optional[threading.Thread] = None
|
||||
self._close_error: Optional[BaseException] = None
|
||||
self._write_thread = threading.Thread(
|
||||
target=self._batch_writer,
|
||||
daemon=True,
|
||||
@@ -170,7 +175,8 @@ class NonBlockingFileHandler:
|
||||
try:
|
||||
self._write_queue.put_nowait(entry)
|
||||
except queue.Full:
|
||||
self._executor.submit(self._write_sync, entry)
|
||||
# 文件日志属于 E1 观测数据;队列达到显式上限时不能再创建无界线程池旁路。
|
||||
return False
|
||||
return True
|
||||
|
||||
def _write_sync(self, entry: LogEntry) -> None:
|
||||
@@ -193,8 +199,10 @@ class NonBlockingFileHandler:
|
||||
msg=entry.message,
|
||||
args=(),
|
||||
exc_info=None,
|
||||
created=entry.timestamp.timestamp(),
|
||||
)
|
||||
created_at = entry.timestamp.timestamp()
|
||||
record.created = created_at
|
||||
record.msecs = (created_at - int(created_at)) * 1000
|
||||
record.correlation_id = entry.correlation_id
|
||||
return record
|
||||
|
||||
@@ -202,15 +210,21 @@ class NonBlockingFileHandler:
|
||||
"""持续收集队列日志,并在停止哨兵后排空已有批次。"""
|
||||
while True:
|
||||
try:
|
||||
batch = []
|
||||
batch: list[LogEntry] = []
|
||||
should_stop = False
|
||||
end_time = time.time() + log_settings.WRITE_TIMEOUT
|
||||
end_time = time.monotonic() + log_settings.WRITE_TIMEOUT
|
||||
while (
|
||||
len(batch) < log_settings.BATCH_WRITE_SIZE
|
||||
and time.time() < end_time
|
||||
and time.monotonic() < end_time
|
||||
):
|
||||
try:
|
||||
remaining_time = max(0, end_time - time.time())
|
||||
if (
|
||||
self._stop_requested.is_set()
|
||||
and self._write_queue.empty()
|
||||
):
|
||||
should_stop = True
|
||||
break
|
||||
remaining_time = max(0, end_time - time.monotonic())
|
||||
entry = self._write_queue.get(timeout=remaining_time)
|
||||
if entry is self._stop_sentinel:
|
||||
should_stop = True
|
||||
@@ -222,6 +236,8 @@ class NonBlockingFileHandler:
|
||||
self._write_batch(batch)
|
||||
if should_stop:
|
||||
break
|
||||
if self._stop_requested.is_set() and self._write_queue.empty():
|
||||
break
|
||||
except Exception as err:
|
||||
print(f"批量写入线程错误: {err}")
|
||||
time.sleep(0.1)
|
||||
@@ -241,23 +257,86 @@ class NonBlockingFileHandler:
|
||||
for entry in entries:
|
||||
self._write_sync(entry)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""停止接收新日志,排空队列并关闭线程池和文件处理器。"""
|
||||
def _close_handlers(self) -> None:
|
||||
"""在独立 owner 中关闭文件处理器,保留失败项供后续重试。"""
|
||||
first_error: Optional[BaseException] = None
|
||||
with self._handlers_lock:
|
||||
handlers = tuple(self._rotating_handlers.items())
|
||||
for file_path, handler in handlers:
|
||||
try:
|
||||
handler.flush()
|
||||
handler.close()
|
||||
except BaseException as err: # noqa: BLE001 需要保留关闭失败 owner
|
||||
if first_error is None:
|
||||
first_error = err
|
||||
print(f"日志处理器关闭失败 {file_path}: {err}")
|
||||
continue
|
||||
with self._handlers_lock:
|
||||
if self._rotating_handlers.get(file_path) is handler:
|
||||
self._rotating_handlers.pop(file_path, None)
|
||||
with self._state_lock:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
if self._write_thread.is_alive():
|
||||
self._write_queue.put(self._stop_sentinel)
|
||||
if self._write_thread.is_alive():
|
||||
self._write_thread.join()
|
||||
self._executor.shutdown(wait=True)
|
||||
for handler in self._rotating_handlers.values():
|
||||
handler.flush()
|
||||
handler.close()
|
||||
self._rotating_handlers.clear()
|
||||
self._close_error = first_error
|
||||
|
||||
_LEVEL_NAME_COLORS = {
|
||||
def _close_handlers_bounded(self, deadline: float) -> bool:
|
||||
"""复用关停总预算有限等待文件处理器关闭 owner。"""
|
||||
with self._state_lock:
|
||||
if self._closed:
|
||||
return True
|
||||
close_thread = self._close_thread
|
||||
if close_thread is None:
|
||||
self._close_error = None
|
||||
close_thread = threading.Thread(
|
||||
target=self._close_handlers,
|
||||
daemon=True,
|
||||
name="LogHandlerCloser",
|
||||
)
|
||||
self._close_thread = close_thread
|
||||
close_thread.start()
|
||||
if close_thread is threading.current_thread():
|
||||
return False
|
||||
close_thread.join(timeout=max(0.0, deadline - time.monotonic()))
|
||||
if close_thread.is_alive():
|
||||
return False
|
||||
with self._state_lock:
|
||||
if self._close_error is not None:
|
||||
if self._close_thread is close_thread:
|
||||
self._close_thread = None
|
||||
return False
|
||||
self._closed = True
|
||||
return True
|
||||
|
||||
def shutdown(
|
||||
self,
|
||||
timeout: float = _LOG_STOP_TIMEOUT_SECONDS,
|
||||
) -> bool:
|
||||
"""
|
||||
停止接收新日志,并在总预算内排空队列和关闭文件处理器。
|
||||
|
||||
:param timeout: 等待写线程和文件处理器收敛的最长秒数
|
||||
:return: 全部日志资源真实终止时返回 True,否则返回 False
|
||||
"""
|
||||
deadline = time.monotonic() + max(0.0, timeout)
|
||||
with self._state_lock:
|
||||
if self._closed:
|
||||
return True
|
||||
if self._running:
|
||||
self._running = False
|
||||
self._stop_requested.set()
|
||||
if self._write_thread.is_alive():
|
||||
try:
|
||||
self._write_queue.put_nowait(self._stop_sentinel)
|
||||
except queue.Full:
|
||||
# 队列非空会自然唤醒 writer;停止事件让其排空后退出。
|
||||
pass
|
||||
if self._write_thread is threading.current_thread():
|
||||
return False
|
||||
self._write_thread.join(timeout=max(0.0, deadline - time.monotonic()))
|
||||
if self._write_thread.is_alive():
|
||||
return False
|
||||
return self._close_handlers_bounded(deadline)
|
||||
|
||||
|
||||
_LEVEL_NAME_COLORS: dict[int, Callable[[str], str]] = {
|
||||
logging.DEBUG: lambda level_name: click.style(str(level_name), fg="cyan"),
|
||||
logging.INFO: lambda level_name: click.style(str(level_name), fg="green"),
|
||||
logging.WARNING: lambda level_name: click.style(str(level_name), fg="yellow"),
|
||||
@@ -310,6 +389,7 @@ class LoggerManager:
|
||||
"""
|
||||
caller_name = None
|
||||
plugin_name = None
|
||||
frame: Optional[FrameType]
|
||||
try:
|
||||
frame = sys._getframe(3) # noqa: SLF001
|
||||
except (AttributeError, ValueError):
|
||||
@@ -368,12 +448,16 @@ class LoggerManager:
|
||||
"""装配文件写入器,并补写装配前暂存的启动日志。"""
|
||||
with cls._lock:
|
||||
previous_writer = cls._writer
|
||||
if previous_writer and previous_writer is not writer:
|
||||
if previous_writer.shutdown() is False:
|
||||
raise RuntimeError("既有日志写入器未收敛,拒绝丢失其资源 owner")
|
||||
with cls._lock:
|
||||
if cls._writer is not previous_writer:
|
||||
raise RuntimeError("日志写入器在装配期间被并发替换")
|
||||
cls._writer = writer
|
||||
cls._log_path = Path(log_path)
|
||||
pending = list(cls._pending_file_logs)
|
||||
cls._pending_file_logs.clear()
|
||||
if previous_writer and previous_writer is not writer:
|
||||
previous_writer.shutdown()
|
||||
for level, message, logfile in pending:
|
||||
writer.write_log(level, message, Path(log_path) / logfile)
|
||||
|
||||
@@ -456,14 +540,19 @@ class LoggerManager:
|
||||
self.logger("critical", msg, *args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def shutdown(cls) -> None:
|
||||
"""断开并关闭当前文件写入器。"""
|
||||
def shutdown(cls) -> bool:
|
||||
"""关闭当前文件写入器,未收敛时保留 owner 供后续重试。"""
|
||||
with cls._lock:
|
||||
writer = cls._writer
|
||||
cls._writer = None
|
||||
cls._log_path = None
|
||||
if writer:
|
||||
writer.shutdown()
|
||||
if writer is None:
|
||||
return True
|
||||
if writer.shutdown() is False:
|
||||
return False
|
||||
with cls._lock:
|
||||
if cls._writer is writer:
|
||||
cls._writer = None
|
||||
cls._log_path = None
|
||||
return True
|
||||
|
||||
|
||||
logger = LoggerManager()
|
||||
|
||||
@@ -582,7 +582,8 @@ async def lifespan(app: FastAPI):
|
||||
finally:
|
||||
try:
|
||||
# 日志最后关闭,确保其他组件的收尾信息已写入文件
|
||||
LoggerManager.shutdown()
|
||||
if LoggerManager.shutdown() is False:
|
||||
raise RuntimeError("日志写入资源未在关停预算内收敛")
|
||||
finally:
|
||||
if main_loop_owner is not None:
|
||||
global_vars.clear_loop(main_loop_owner)
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
> 文档性质:当前架构复核、优秀 Python 后端实践对标、AI 可执行任务手册
|
||||
> 适用仓库:`MoviePilot`,分支 `v3`
|
||||
> 审计基线:`ad45bfac`(2026-08-24)
|
||||
> 审计基线:`88262191`(2026-08-24)
|
||||
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
|
||||
> 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文
|
||||
> 相关文档:`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 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口;阶段 15 已收口站点数据端口;阶段 16 已收口媒体服务器数据端口;阶段 17 已收口下载数据端口;阶段 18 已收口主订阅数据端口;阶段 19 已收口整理数据端口;阶段 20 已收口 Agent 数据端口;阶段 21 已收口监控历史端口;阶段 22 已统一服务配置应用边界;阶段 23 已补齐媒体服务器 API 遗留的类形配置读取路径;阶段 24 已清除 Scheduler 内部无 owner 的协程提交双轨;阶段 25 已补齐 TaskRegistry 跨线程 owner 并迁移整理 AI 接管;阶段 26 已统一 Agent 会话清理提交;阶段 27 已统一历史 AI 进度 owner;阶段 28 已托管旧插件订阅统计线程;阶段 29 已统一 Emby 系条目转换并清零重复代码白名单;阶段 30 已收口插件市场请求级子任务;阶段 31 已托管搜索 AI 推荐任务;阶段 32 已清除事件调度器绕过生命周期 owner 的投递回退;阶段 33 已统一宿主 Agent 运行时的获取路径;阶段 34 已统一 durable-required 事件与 Outbox topic 事实源;阶段 35 已统一 LLM provider 管理 API 的运行时解析路径;阶段 36 已统一 WebAgent 音频能力访问边界;阶段 37 已统一插件输入事件发布路径;阶段 38 已统一 WebAgent 通知事件监听与队列边界;阶段 39 已补齐搜索 SSE 断线时的上游任务清理;阶段 40 已补齐异步防抖取消的终态所有权;阶段 41 已统一优雅重启兜底线程的唯一所有权;阶段 42 已补齐 Telegram typing 的多实例隔离和终态 owner;阶段 43 已统一 Discord typing 的异步 owner 和 shutdown 收尾;阶段 44 已清除 WebAgent 测试临时事件循环提前关闭产生的 CI 红注解;阶段 45 已统一影视与字幕搜索的请求级逐页任务编排;阶段 46 已收口启动性能门禁的托管 runner 假失败与诊断输出;阶段 47 已补齐 Agent 渠道流式刷新任务的重入 owner;阶段 48 已统一工件上传 action 的 Node 24 主版本;阶段 49 已统一插件安装的同步/异步代际解析事实源;阶段 50 已统一插件市场 GitHub 请求降级策略;阶段 51 已统一插件索引请求与响应三态策略;阶段 52 已统一插件 Release 分页策略;阶段 53 已统一远端插件安装模式决策;阶段 54 已补齐同步安装成功后的临时回滚备份清理;阶段 55~56 已收口官方插件观察基线与报告保留策略;阶段 57 已统一进程级运行时 Facade 门禁并补齐 ModuleManager 边界;阶段 58 已消除 AgentTask 关闭回归的跨线程零时长等待竞态;阶段 59 已统一 Feishu 多实例长连接的 SDK 循环路由;阶段 60 已清除命令服务虚假的关停 owner 声明;阶段 61 已统一 Capability Runtime 同步/异步关闭的诚实收敛结果;阶段 62 已统一消息渠道长连接的多实例关闭收敛合同;阶段 63 已补齐应用消息队列线程的关闭收敛合同;阶段 64 已统一共享线程池的有界关闭 owner。
|
||||
> 实施进度:阶段 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 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口;阶段 15 已收口站点数据端口;阶段 16 已收口媒体服务器数据端口;阶段 17 已收口下载数据端口;阶段 18 已收口主订阅数据端口;阶段 19 已收口整理数据端口;阶段 20 已收口 Agent 数据端口;阶段 21 已收口监控历史端口;阶段 22 已统一服务配置应用边界;阶段 23 已补齐媒体服务器 API 遗留的类形配置读取路径;阶段 24 已清除 Scheduler 内部无 owner 的协程提交双轨;阶段 25 已补齐 TaskRegistry 跨线程 owner 并迁移整理 AI 接管;阶段 26 已统一 Agent 会话清理提交;阶段 27 已统一历史 AI 进度 owner;阶段 28 已托管旧插件订阅统计线程;阶段 29 已统一 Emby 系条目转换并清零重复代码白名单;阶段 30 已收口插件市场请求级子任务;阶段 31 已托管搜索 AI 推荐任务;阶段 32 已清除事件调度器绕过生命周期 owner 的投递回退;阶段 33 已统一宿主 Agent 运行时的获取路径;阶段 34 已统一 durable-required 事件与 Outbox topic 事实源;阶段 35 已统一 LLM provider 管理 API 的运行时解析路径;阶段 36 已统一 WebAgent 音频能力访问边界;阶段 37 已统一插件输入事件发布路径;阶段 38 已统一 WebAgent 通知事件监听与队列边界;阶段 39 已补齐搜索 SSE 断线时的上游任务清理;阶段 40 已补齐异步防抖取消的终态所有权;阶段 41 已统一优雅重启兜底线程的唯一所有权;阶段 42 已补齐 Telegram typing 的多实例隔离和终态 owner;阶段 43 已统一 Discord typing 的异步 owner 和 shutdown 收尾;阶段 44 已清除 WebAgent 测试临时事件循环提前关闭产生的 CI 红注解;阶段 45 已统一影视与字幕搜索的请求级逐页任务编排;阶段 46 已收口启动性能门禁的托管 runner 假失败与诊断输出;阶段 47 已补齐 Agent 渠道流式刷新任务的重入 owner;阶段 48 已统一工件上传 action 的 Node 24 主版本;阶段 49 已统一插件安装的同步/异步代际解析事实源;阶段 50 已统一插件市场 GitHub 请求降级策略;阶段 51 已统一插件索引请求与响应三态策略;阶段 52 已统一插件 Release 分页策略;阶段 53 已统一远端插件安装模式决策;阶段 54 已补齐同步安装成功后的临时回滚备份清理;阶段 55~56 已收口官方插件观察基线与报告保留策略;阶段 57 已统一进程级运行时 Facade 门禁并补齐 ModuleManager 边界;阶段 58 已消除 AgentTask 关闭回归的跨线程零时长等待竞态;阶段 59 已统一 Feishu 多实例长连接的 SDK 循环路由;阶段 60 已清除命令服务虚假的关停 owner 声明;阶段 61 已统一 Capability Runtime 同步/异步关闭的诚实收敛结果;阶段 62 已统一消息渠道长连接的多实例关闭收敛合同;阶段 63 已补齐应用消息队列线程的关闭收敛合同;阶段 64 已统一共享线程池的有界关闭 owner;阶段 65 已统一异步文件日志的单一有界写入和关闭 owner。
|
||||
> 当前 canonical 状态:API/Application 公共复杂度基线已清零,组合根外 `SystemConfigOper()` 构造和 Model/Oper 隐式事务均为 0;命名 Chain/Agent 数据端口、TaskRegistry owner、Module Contract V2、typed Event、Outbox durable intent、请求关联和插件运行时 getter 已形成当前路径。插件仓适配、未知第三方 fallback 和其它 E1/E3 副作用仍按风险持续治理。
|
||||
> 最新阶段:阶段 64 已统一共享线程池的有界关闭 owner。
|
||||
> 最新阶段:阶段 65 已统一异步文件日志的单一有界写入和关闭 owner。
|
||||
|
||||
## 当前复核结论(2026-08-24)
|
||||
|
||||
@@ -687,6 +687,26 @@
|
||||
故障注入;线程池/生命周期专项 78 项、架构与兼容调用链 164 项、Pylint 10.00/10、strict mypy
|
||||
40 文件、宿主与质量 ratchet 及四分片全量 `5946 passed, 3 skipped` 均通过。
|
||||
|
||||
### 长期整改阶段 65:异步文件日志单一有界写入与关闭 owner 统一(2026-08-24)
|
||||
|
||||
- 协程环境的日志运行时原先同时保留批量队列 writer 和“队列满后提交独立 `ThreadPoolExecutor` 直写”两条路径;
|
||||
配置的队列容量并不是真实上限,溢出任务会进入 executor 的无界队列,写入顺序、资源 owner 和关闭
|
||||
语义也分裂。当前异步文件日志只由一个有界队列 writer 执行;达到容量时拒绝新增文件副本,既有
|
||||
控制台输出保持不变;无事件循环的同步调用仍保留直接写入语义,不再以第二套线程池掩盖 E1 过载。
|
||||
- 原 `shutdown()` 会无界等待批量线程、executor 和文件处理器;任一文件系统写入或 `close()` 阻塞都会
|
||||
卡住 lifespan 所在事件循环。当前默认使用 10 秒共享 deadline,先封口新日志并排空已接受队列,再有限
|
||||
等待 writer 和一次性文件处理器关闭 owner;超时返回 `False`,阻塞 owner 与 handler 均保留,释放后
|
||||
可由同一实例重试到真实终态。
|
||||
- `LoggerManager` 不再先清空 writer 引用再关闭;未收敛时保留原 writer,重新装配也拒绝覆盖活动 owner。
|
||||
日志仍在全部宿主组件之后最后关闭,但 `False` 会让 lifespan 以关闭失败结束,测试会话收尾也会报告
|
||||
同一结果,不再把记录清理动作当成资源已经终止。
|
||||
- `NonBlockingFileHandler`、`LoggerManager`、无参数 `shutdown()`、历史 `ASYNC_FILE_WORKERS` 配置解析、
|
||||
`configure_log_writer()` 及 `app.log` 精确兼容映射均保留;新增 timeout 和布尔结果为向后兼容扩展,
|
||||
未修改 SDK/Compat 清单、插件 Hook 或插件仓,V1/V2/V3 插件观察面不变。
|
||||
- writer/handler 阻塞、队列过载、重试终态、重新装配拒绝覆盖和 lifespan 失败传播均有故障注入;
|
||||
日志/生命周期/旧导入专项 83 项、架构合同 94 项、兼容调用链 102 项、Pylint 10.00/10、strict mypy
|
||||
41 文件、全部宿主与质量 ratchet 及四分片全量 `5952 passed, 3 skipped` 均通过。
|
||||
|
||||
### 总体判断
|
||||
|
||||
当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**:
|
||||
@@ -757,7 +777,7 @@
|
||||
### P2:中长期可演进性债务
|
||||
|
||||
- **大型职责域仍偏重。** 代表性热点包括 `app/chain/subscribe.py`(约 `4141` 行)、`app/chain/transfer.py`(约 `2944` 行)、`app/agent/orchestrator.py`(约 `3540` 行)、`app/agent/llm/provider.py`(约 `3529` 行)、`app/adapters/external/market.py`(约 `3139` 行)和 `app/api/endpoints/agent.py`(约 `2489` 行)。复杂度 ratchet 只保证不超过当前基线,不代表这些文件已经易维护。只有在行为快照、调用命中和事务边界明确后,才值得按用例拆分。
|
||||
- **类型门禁覆盖面不足。** `mypy.ini` strict 文件清单目前为 `39` 个文件,Agent、Chain、Module、Adapter 大量代码仍依赖动态类型。应从模块契约、生命周期、Repository/Port 和关键 Chain 返回值开始扩展,而不是直接开启全仓 strict。
|
||||
- **类型门禁覆盖面不足。** `mypy.ini` strict 文件清单目前为 `41` 个文件,Agent、Chain、Module、Adapter 大量代码仍依赖动态类型。应从模块契约、生命周期、Repository/Port 和关键 Chain 返回值开始扩展,而不是直接开启全仓 strict。
|
||||
- **Pylint 仍是增量硬门禁。** `.github/workflows/pylint.yml` 对改动 Python 文件执行硬检查,但全仓报告使用 `|| true` 仅作 advisory。该策略适合存量迁移,却没有形成全仓质量趋势约束;应增加按目录和新增问题数的 ratchet。
|
||||
- **测试风格存在历史混用。** 当前有 `527` 个测试文件,仍有 `70` 个 `unittest.TestCase` 文件。它不是生产架构缺陷,但会增加 fixture、状态隔离和异步测试迁移成本,应在触碰相关模块时渐进迁移。
|
||||
- **跨仓治理链路尚未完全闭环。** 前端已有 lint、typecheck、分片 Vitest 和构建门禁;插件仓有 V1/V2/V3 索引及版本/依赖检查;资源和 Rust 仓有独立构建发布链路。但插件 CI 本地复核因插件仓环境缺少主仓依赖 `httpx2` 无法完成收集,说明“插件仓测试环境与主仓锁定依赖”的可复现性仍需加强。资源构建通过 PR 同步到 `MoviePilot-Resources`,Rust 发布后自动向主仓发依赖 bump PR,链路合理但仍是多仓异步发布,需保留版本 provenance 和回滚点。
|
||||
@@ -810,7 +830,7 @@ MoviePilot V3 当前不是“目录混乱、必须推倒重来”的状态。第
|
||||
5. **模块与事件契约登记均已完成。**当前 212 个模块 spec 的宿主观察面已无 legacy aggregation,53 个事件全部绑定 typed payload,可见性、投递等级、错误行为和敏感字段均有基线,legacy event payload 为 `0`。六个 durable-required 事件的宿主正式生产者已通过业务同事务 Outbox 提供真实持久投递,事件与 topic 映射及恢复 handler 完整性已纳入 ratchet。后续重点是保持新增能力门禁和观察未知第三方 fallback 命中,不是重复创建契约、DTO 或 Outbox。
|
||||
6. **后台副作用已有统一可靠性分类,但其他 E1/E3 机制仍需逐项收口。** ADR-0007 已分类事件队列、APScheduler、进程内任务、Agent task 与 transfer pending 的完成点、恢复和失败表达;不能因六个关键事件已接 Outbox,就把仍需定时重建、持久任务表或人工恢复的其他 E1/E3 机制误报为全部完成。
|
||||
7. **核心关联与健康边界已落地,指标导出仍未收口。**HTTP/SSE correlation ID 已传播到线程池、事件、工作流、子进程、外部请求和日志;`/health/live`、`/health/ready` 已由部署入口消费,事件/数据库队列深度及模块/事件耗时使用低基数指标登记。当前缺口是稳定 exporter、运维查询面和跨进程聚合,而不是重新实现 request ID 或健康路由。
|
||||
8. **质量门禁已具备增量硬约束,但覆盖面仍需扩大。**push/PR 对变更 Python 文件执行 Pylint,CI 同时运行 host architecture、39 个 strict mypy 文件、复杂度、async 阻塞和 task owner ratchet;全仓 Pylint 仍是 advisory,strict 类型和复杂度拆分仍应随业务切片渐进扩展。
|
||||
8. **质量门禁已具备增量硬约束,但覆盖面仍需扩大。**push/PR 对变更 Python 文件执行 Pylint,CI 同时运行 host architecture、41 个 strict mypy 文件、复杂度、async 阻塞和 task owner ratchet;全仓 Pylint 仍是 advisory,strict 类型和复杂度拆分仍应随业务切片渐进扩展。
|
||||
|
||||
建议保持**模块化单体**,按以下顺序治理:
|
||||
|
||||
|
||||
@@ -201,6 +201,9 @@ ModuleManager 与 startup 组合根继续关闭其余资源但必须向上返回
|
||||
并向 startup 返回 `False`,不得用无界 `join()` 阻塞生命周期或把日志当作成功。
|
||||
共享 `ThreadHelper` 必须追踪通过宿主 `submit()` 和旧兼容 `.pool.submit()` 接受的全部 Future;关闭时
|
||||
先封口新任务,再有限等待且保留未终止 owner,结果由 startup 聚合,不得恢复无界 executor shutdown。
|
||||
协程环境文件日志属于有界 E1 观测能力,只允许单一队列 writer;队列满时不得再以无界 executor
|
||||
形成第二条异步写入路径。日志关闭必须有限等待 writer 与文件处理器,未收敛时 `LoggerManager`
|
||||
保留原 owner 并让 lifespan 以关闭失败结束,不得先清空引用或用无界 `join()` 掩盖失败。
|
||||
API 中允许丢失或可重建的进程内任务必须登记到 `app/runtime/tasks.py`;登记器先于其他
|
||||
运行资源启动,并在资源释放前停止接收、取消和有限等待。需要崩溃恢复的 E2/E3 副作用仍应
|
||||
进入 Outbox 或持久任务表,不能把 TaskRegistry 当成 durable queue。
|
||||
|
||||
@@ -13,6 +13,7 @@ files =
|
||||
app/domain/meta/infopath.py,
|
||||
app/runtime/correlation.py,
|
||||
app/runtime/coalesce.py,
|
||||
app/runtime/log.py,
|
||||
app/runtime/observability/__init__.py,
|
||||
app/runtime/thread.py,
|
||||
app/runtime/event/contracts.py,
|
||||
|
||||
+2
-1
@@ -493,6 +493,7 @@ def pytest_sessionfinish(session, exitstatus):
|
||||
try:
|
||||
from app.runtime.log import LoggerManager
|
||||
|
||||
LoggerManager.shutdown()
|
||||
if LoggerManager.shutdown() is False:
|
||||
raise RuntimeError("log writer did not converge")
|
||||
except Exception as err:
|
||||
_report_session_cleanup_error(session, "logger manager", err)
|
||||
|
||||
@@ -180,6 +180,26 @@ def test_lifespan_normal_mode_starts_full_runtime(monkeypatch):
|
||||
_assert_completed_once(step)
|
||||
|
||||
|
||||
def test_lifespan_propagates_logger_nonconvergence(monkeypatch):
|
||||
"""最后一个日志 owner 未收敛时 lifespan 必须以关闭失败结束。"""
|
||||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||||
shutdown_steps["logger"].return_value = False
|
||||
|
||||
async def run_lifespan() -> None:
|
||||
"""运行完整生命周期并触发日志 writer 的诚实失败结果。"""
|
||||
async with lifecycle.lifespan(FastAPI()):
|
||||
pass
|
||||
|
||||
with pytest.raises(RuntimeError, match="日志写入资源未在关停预算内收敛"):
|
||||
asyncio.run(run_lifespan())
|
||||
|
||||
for step in shutdown_steps.values():
|
||||
_assert_completed_once(step)
|
||||
lifecycle.global_vars.clear_loop.assert_called_once_with(
|
||||
lifecycle.global_vars.set_loop.return_value
|
||||
)
|
||||
|
||||
|
||||
def test_lifespan_validation_failure_does_not_clear_outer_loop_owner(monkeypatch):
|
||||
"""当前生命周期尚未取得 owner 时,启动失败不得清理外层登记。"""
|
||||
_patch_lifespan(monkeypatch)
|
||||
|
||||
+168
-1
@@ -2,7 +2,14 @@ import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.runtime.log import LogEntry, NonBlockingFileHandler, log_settings
|
||||
import pytest
|
||||
|
||||
from app.runtime.log import (
|
||||
LogEntry,
|
||||
LoggerManager,
|
||||
NonBlockingFileHandler,
|
||||
log_settings,
|
||||
)
|
||||
|
||||
|
||||
def test_non_blocking_file_handler_shutdown_wakes_writer_and_closes_handlers(tmp_path):
|
||||
@@ -147,3 +154,163 @@ def test_non_blocking_file_handler_uses_handler_lock(monkeypatch, tmp_path):
|
||||
finally:
|
||||
handler.shutdown()
|
||||
NonBlockingFileHandler._instance = original_instance
|
||||
|
||||
|
||||
def test_non_blocking_file_handler_shutdown_is_bounded_and_retryable(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
"""批量写入阻塞时关闭必须有限返回,并保留同一 writer 供重试。"""
|
||||
original_instance = NonBlockingFileHandler._instance
|
||||
NonBlockingFileHandler._instance = None
|
||||
monkeypatch.setattr(log_settings, "WRITE_TIMEOUT", 0.01)
|
||||
handler = NonBlockingFileHandler()
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def block_batch(_batch):
|
||||
"""模拟文件系统写入永久占用批量 writer。"""
|
||||
entered.set()
|
||||
release.wait()
|
||||
|
||||
monkeypatch.setattr(handler, "_write_batch", block_batch)
|
||||
try:
|
||||
assert handler._write_non_blocking(
|
||||
LogEntry("info", "blocked", tmp_path / "blocked.log")
|
||||
) is True
|
||||
assert entered.wait(timeout=1)
|
||||
|
||||
started_at = time.monotonic()
|
||||
assert handler.shutdown(timeout=0.01) is False
|
||||
assert time.monotonic() - started_at < 1
|
||||
assert handler._write_thread.is_alive()
|
||||
assert handler._write_non_blocking(
|
||||
LogEntry("info", "late", tmp_path / "blocked.log")
|
||||
) is False
|
||||
release.set()
|
||||
assert handler.shutdown(timeout=1) is True
|
||||
assert not handler._write_thread.is_alive()
|
||||
assert handler.shutdown(timeout=1) is True
|
||||
finally:
|
||||
release.set()
|
||||
handler.shutdown(timeout=1)
|
||||
NonBlockingFileHandler._instance = original_instance
|
||||
|
||||
|
||||
def test_non_blocking_file_handler_does_not_bypass_full_queue(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
"""队列达到显式容量后不得再通过无界线程池形成第二条写入路径。"""
|
||||
original_instance = NonBlockingFileHandler._instance
|
||||
NonBlockingFileHandler._instance = None
|
||||
monkeypatch.setattr(log_settings, "ASYNC_FILE_QUEUE_SIZE", 1)
|
||||
monkeypatch.setattr(log_settings, "BATCH_WRITE_SIZE", 1)
|
||||
handler = NonBlockingFileHandler()
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
written: list[str] = []
|
||||
|
||||
def block_batch(batch):
|
||||
"""占住唯一 writer,使后续日志稳定留在有界队列中。"""
|
||||
written.extend(entry.message for entry in batch)
|
||||
entered.set()
|
||||
release.wait()
|
||||
|
||||
monkeypatch.setattr(handler, "_write_batch", block_batch)
|
||||
try:
|
||||
assert handler._write_non_blocking(
|
||||
LogEntry("info", "first", tmp_path / "bounded.log")
|
||||
) is True
|
||||
assert entered.wait(timeout=1)
|
||||
assert handler._write_non_blocking(
|
||||
LogEntry("info", "queued", tmp_path / "bounded.log")
|
||||
) is True
|
||||
assert handler._write_non_blocking(
|
||||
LogEntry("info", "rejected", tmp_path / "bounded.log")
|
||||
) is False
|
||||
release.set()
|
||||
started_at = time.monotonic()
|
||||
assert handler.shutdown(timeout=1) is True
|
||||
assert time.monotonic() - started_at < 1
|
||||
assert written == ["first", "queued"]
|
||||
finally:
|
||||
release.set()
|
||||
handler.shutdown(timeout=1)
|
||||
NonBlockingFileHandler._instance = original_instance
|
||||
|
||||
|
||||
def test_non_blocking_file_handler_bounds_handler_close(monkeypatch, tmp_path):
|
||||
"""文件处理器 close 阻塞时也必须保留关闭线程并支持最终重试。"""
|
||||
original_instance = NonBlockingFileHandler._instance
|
||||
NonBlockingFileHandler._instance = None
|
||||
handler = NonBlockingFileHandler()
|
||||
close_entered = threading.Event()
|
||||
close_release = threading.Event()
|
||||
log_handler = MagicMock()
|
||||
|
||||
def block_close():
|
||||
"""模拟文件系统在 flush 后阻塞关闭句柄。"""
|
||||
close_entered.set()
|
||||
close_release.wait()
|
||||
|
||||
log_handler.close.side_effect = block_close
|
||||
log_path = tmp_path / "close.log"
|
||||
handler._rotating_handlers = {log_path: log_handler}
|
||||
try:
|
||||
assert handler.shutdown(timeout=0.05) is False
|
||||
assert close_entered.wait(timeout=1)
|
||||
assert handler._close_thread is not None
|
||||
assert handler._close_thread.is_alive()
|
||||
assert handler._rotating_handlers[log_path] is log_handler
|
||||
close_release.set()
|
||||
assert handler.shutdown(timeout=1) is True
|
||||
assert handler._rotating_handlers == {}
|
||||
finally:
|
||||
close_release.set()
|
||||
handler.shutdown(timeout=1)
|
||||
NonBlockingFileHandler._instance = original_instance
|
||||
|
||||
|
||||
def test_logger_manager_retains_nonconverged_writer_for_retry(tmp_path):
|
||||
"""平台日志门面不得在底层 writer 未收敛时丢失其 owner。"""
|
||||
previous_writer = LoggerManager._writer
|
||||
previous_log_path = LoggerManager._log_path
|
||||
writer = MagicMock()
|
||||
writer.shutdown.side_effect = [False, True]
|
||||
LoggerManager._writer = writer
|
||||
LoggerManager._log_path = tmp_path
|
||||
try:
|
||||
assert LoggerManager.shutdown() is False
|
||||
assert LoggerManager._writer is writer
|
||||
assert LoggerManager._log_path == tmp_path
|
||||
|
||||
assert LoggerManager.shutdown() is True
|
||||
assert LoggerManager._writer is None
|
||||
assert LoggerManager._log_path is None
|
||||
assert writer.shutdown.call_count == 2
|
||||
finally:
|
||||
LoggerManager._writer = previous_writer
|
||||
LoggerManager._log_path = previous_log_path
|
||||
|
||||
|
||||
def test_logger_manager_refuses_to_replace_nonconverged_writer(tmp_path):
|
||||
"""重新装配不得用新 writer 覆盖仍持有资源的旧 owner。"""
|
||||
original_writer = LoggerManager._writer
|
||||
original_log_path = LoggerManager._log_path
|
||||
previous_writer = MagicMock()
|
||||
previous_writer.shutdown.return_value = False
|
||||
replacement_writer = MagicMock()
|
||||
old_path = tmp_path / "old"
|
||||
LoggerManager._writer = previous_writer
|
||||
LoggerManager._log_path = old_path
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="既有日志写入器未收敛"):
|
||||
LoggerManager.configure_writer(replacement_writer, tmp_path / "new")
|
||||
|
||||
assert LoggerManager._writer is previous_writer
|
||||
assert LoggerManager._log_path == old_path
|
||||
replacement_writer.write_log.assert_not_called()
|
||||
finally:
|
||||
LoggerManager._writer = original_writer
|
||||
LoggerManager._log_path = original_log_path
|
||||
|
||||
Reference in New Issue
Block a user